Unzip .zip that cannot unzip with zipfile

この記事は[BrainPad Advent Calendar](https://qiita.com/advent-calendar/2021/brainpad) 6日目の記事になります

こんにちは[@nissy0409240](https://twitter.com/nissy0409240)です
BrainPadでエンジニアをしています

2021年もあと一ヶ月を切りましたが
皆様いかがお過ごしでしょうか

先日ですが休日に博物館へ行きました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100333/ac7d9ef5-6ed9-0399-140d-c4f285fefb81.png)

道中のコンビニで買った3色パンとルイボスティを飲みながらせっかちなペースで歩みを進め
ひと夏の長さに思いを馳せながら、ありがちな日常を取り戻すきっかけは何なのか
考えていた時見つけた友人に話しかけたつもりが他人のそら似でした

偶然を言い訳にして博物館に到着した時は駅前より人も少なく
ゆっくりと咲く花のような時間が流れていました
混み込みしていない圧縮されることのない空間でマスク越しに深呼吸をし
私の色を取り戻す感覚がした休日でした
そんな晴れやかな休日を過ごした後に書くエントリーでは
zip圧縮されたファイルについて書こうと思います

# 圧縮形式

ビッグデータという言葉のもの珍しさも薄れつつある今日この頃ではありますが
大量のデータのやり取りにおいて現在もファイル圧縮は一般的な手法であることに変わりはありません
よく見る圧縮形式は以下のようなものが存在します

|圧縮形式|拡張子|説明|
|:-:|:-:|:-:|
|ZIP|.zip|一般的に使われている圧縮形式。そこまで圧縮率は高くない|
|GZIP|.gzip .gz .tar.gz .tgz|ZIPより圧縮率は高い。テキストファイルなら9割近く圧縮可能|
|BZIP2|.bzip2 .bz2 .tar.bz2|GZIPより圧縮率は高い。圧縮・展開ともに時間がかかる|

主要なプログラミング言語ではこれらの形式に対応しており
解凍するためのモジュールも実装されています

例えば.zipファイルは以下のように簡単に解凍出来ます

```python
import zipfile

with zipfile.ZipFile('/temp/test.zip') as f:
    f.extractall('/temp/out/')
```

パスワード付きだと以下の通りです

```python
import zipfile

with zipfile.ZipFile('/temp/test.zip') as f:
    f.extractall('/temp/out/', pwd=b'testpass')
```

上記のコードはリファレンスですぐに見つかるレベルのコードです
しかし、実行した時に以下のような挙動をするケースに出会いました

```python
>>> import zipfile
>>> with zipfile.ZipFile('/temp/test_aes.zip') as f:
...     f.extractall('/temp/out', pwd=b'testpass')
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/zipfile.py", line 1633, in extractall
    self._extract_member(zipinfo, path, pwd)
  File "/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/zipfile.py", line 1686, in _extract_member
    with self.open(member, pwd=pwd) as source, \
  File "/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/zipfile.py", line 1559, in open
    return ZipExtFile(zef_file, mode, zinfo, pwd, True)
  File "/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/zipfile.py", line 797, in __init__
    self._decompressor = _get_decompressor(self._compress_type)
  File "/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/zipfile.py", line 698, in _get_decompressor
    _check_compression(compress_type)
  File "/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/zipfile.py", line 678, in _check_compression
    raise NotImplementedError("That compression method is not supported")
NotImplementedError: That compression method is not supported
```

ファイルコマンドで調べてみると
同じzipファイルでもバージョンが違うことが分かりました

```sh
$ file *.zip
test.zip:     Zip archive data, at least v2.0 to extract
test_aes.zip: Zip archive data, at least v4.5 to extract
```

# .ZIPフォーマット

普段はあまり意識することはありませんが
.ZIPフォーマットには[こちら](https://gist.github.com/ysakasin/2edf8d3bf55c6ebf63f82851e302b030)に記載されている通り複数のバージョンが存在します
またフォーマットごとに差異もあります

[過去開かれていたpythonのissue](https://bugs.python.org/issue9170)を確認すると以下のようなコメントがありました

```text
zipfile only supports the "Traditional PKWARE Encryption" method.
Support for other encryption methods would be useful.
```

[暗号化方式](https://so-zou.jp/software/tech/security/encryption/algorithms.htm)を確認すると解凍出来なかったファイルは[AES方式](https://e-words.jp/w/AES.html)で暗号化されていたものでした

# pythonでAES方式で暗号化されたzipファイルを解凍する

このファイルを解凍する際、手作業で解凍するだけならそこまで難しいものではありません
よく使われる[7-Zip](https://sevenzip.osdn.jp/)を使用すれば通常の.zipファイルと同様に解凍出来ます

今回は[p7zip](https://wiki.archlinux.jp/index.php/P7zip)をインストールして実行してみます

```shell
$ 7z x test_aes.zip

7-Zip [64] 17.04 : Copyright (c) 1999-2021 Igor Pavlov : 2017-08-28
p7zip Version 17.04 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,4 CPUs x64)

Scanning the drive for archives:
1 file, 552423218 bytes (527 MiB)

Extracting archive: test_aes.zip
--
Path = test_aes.zip
Type = zip
Physical Size = 552423218


Enter password (will not be echoed):
Everything is Ok

Size:       4452467111
Compressed: 552423218
```
これをsubprocessで呼び出せば以下の通り解凍自体は出来ます

```python
>>> import subprocess
>>> subprocess.call(["7z", "x", "-ptestpass", "test_aes.zip"])

7-Zip [64] 17.04 : Copyright (c) 1999-2021 Igor Pavlov : 2017-08-28
p7zip Version 17.04 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,4 CPUs x64)

Scanning the drive for archives:
1 file, 552423218 bytes (527 MiB)

Extracting archive: test_aes.zip
--
Path = test_aes.zip
Type = zip
Physical Size = 552423218

Everything is Ok

Size:       4452467111
Compressed: 552423218
0
```

しかし、これではWebアプリケーション上で毎回コマンド実行している状態でしたので
解凍可能なライブラリを探してみたところ[pyzipper](https://github.com/danifus/pyzipper)というライブラリを見つけたのでこちらを用いて解凍してみます

```python
>>> import pyzipper
>>> with pyzipper.AESZipFile('test_aes.zip') as f:
...     f.extractall('dist/', pwd=b'testpass')
...
>>>
```
実行結果は

```shell
$ ls dist/
test_aes.csv
```

となり想定通りに解凍出来ていることが確認できました
zipfileモジュールを呼び出す時と同じ呼び出し方で解凍出来るので
分かりやすいと個人的には感じています

# 結び

以上、zipfileコマンドで解凍出来ない.zipファイルを解凍してみました
困ったらsubprocessモジュール使ってコマンド実行で何とかしていたのですが
使いやすいライブラリを探してみることも大事だなぁと改めて気づかされました
最後までお付き合い頂きありがとうございました

# 参考

https://bi.biopapyrus.jp/os/linux/compress.html
https://gist.github.com/ysakasin/2edf8d3bf55c6ebf63f82851e302b030
https://stackoverflow.com/questions/15553150/python-unzip-aes-128-encrypted-file
https://so-zou.jp/software/tech/security/encryption/algorithms.htm