標準愚痴出力

個人的なIT作業ログです。もしかしたら一般的に参考になることが書いているかもしれません(弱気

💩口に出せない ZIP ファイルのひみつ(主に名前のせい)

(当文書は zenn.dev あたりに投稿しようかなと思って書き始めたんですが、書いているうちに文書量の割に役立つ内容がないのでボツにしたものです。もったいないのでブログの方に残しておきます)

きっかけ

twitter の先行きがいろいろ不安なので自分の全ツイートをダウンロードしようとしました。でも、アーカイブがあまりに巨大なため、貧弱なうちの回線では途中で切れっぱなしです。1 ファイルが巨大になる理由はアーカイブの中に静止画・動画が全部入っているせいです。でも、もしツイート本体の JSON が静止画・動画といった巨大ファイルよりも先に格納されているなら、別に全部ダウンロードできていなくとも取り出せそうなものです。

シーケンシャルデータ想定の tar と違い、ZIP はアーカイブの任意の個別ファイルに大して自由にアクセスする使い方が想定されています。そのため、ファイル末尾付近にインデックス("central directory header")が設置されています。ゆえに途中で切れてしまうと、このインデックスが参照できないため、展開できなくなります。

ですが、ZIPファイルは結構堅牢で、インデックスがなくとも、各ファイルエントリのヘッダから必要最小限の情報が得られます。実際 Info-ZIP でも、それを利用した復元オプションがあります。

[C:] zip -FF "twitter-2023-01-13-59def7f0fc4962235a2fa30c1cdefa2df9b20da46feba28ae38b2354a12af693 (1).zip" --out New.zip
 :(中略)
 copying: data/tweets_media/1234705029774200832-ESKNGhtVAAEFDsM.jpg  (62733 bytes)
 copying: data/tweets_media/1234844166489661445-ESBEPHyUYAE-Xfi.jpg
zip I/O error: Invalid argument
zip error: Output file write failure (write error on zip file)
exit status 14

あ…

これは多分アーカイブ全体のサイズが 4GB を越えているせいでしょう。ZIPファイルのフォーマットって一応公開されているし、どうせ時間はあるし、これは一つ、自分で展開するツールを書いてみるかなと思い立ちました。

(なお、着手したその晩、就寝前にダウンロードをリトライした結果、無事全部ダウンロードできてしまったのは秘密です。だが愚かな人類はふりあげたこぶしをさげられなかった)

せっかちな人のために、いきなり成果物

末尾のインデックスが欠けた ZIP ファイルからでも中身のファイルを展開するツール uncozip を作成しました。

より最新バージョンをダウンロードしてください。 scoop が使える人は

scoop install https://raw.githubusercontent.com/hymkor/uncozip/master/uncozip.json

もしくは

scoop bucket add hymkor https://github.com/hymkor/scoop-bucket
scoop install uncozip

でインストール可能です。uncozip アーカイブファイル名 でどうぞ

( なお、v0.5.0 までは UP していた Linux バイナリが実質使えない状態でした。原因はファイル名を UTF8 に変換するパッケージでエラーになっていたせいでした。「たぶん動くだろう」でちゃんと確認せずにリリースしてました。たいへん申し訳ありません>ダウンロードされた数名の方 )

基本原理

「ZIP フォーマット」でググると、次のようなページがヒットします。

もうお腹いっぱいです、十分すぎます。 ZIPファイルの構造はおおよそ次のような形になります。

1ファイル目 "local file header" (固定長)
ファイル名 (可変長:長さは "local file header" 内に記載)
拡張フィールド (可変長:長さは "local file header" 内に記載)
ファイルデータ本体 (可変長:長さは不明な場合がある)
"data descriptor" (ない時がある/2バージョンがある)
2ファイル目 以下繰り返し
インデックス ("central directory header")
今回は参照しないので省略

これらに従うと ZIPファイルを展開するには

  1. 各ファイルのヘッダ(local file header)を "encoding/binary" の Read 関数で読む
  2. 直後にあるファイルネームフィールドを io.ReadFull で読む
    • ファイル名の長さはヘッダに明記されている
    • ファイル名は基本動作環境が定めるデフォルトの文字コードが使われているが、UTF8 が使われているときはヘッダ内の UTF8 ビットがセットされている
  3. 拡張フィールドのサイズが1以上の時は、その数分のバイト数を読み飛ばす
    • 個別ファイルが4GBを越えている場合はここに ZIP64 フィールドが入っているため、ちゃんと参照しなければいけないが、幸い、twitter の ZIP はそうではなかったので、プロトタイプ作成では対応せずに済んだ(最新バージョンでは対応済み)
  4. この後にデータが含まれているので、それを読みだして、"compress/flate" で伸長する

の繰り返しで良さそうです。実際、テスト用に作ったシンプルな ZIP ファイルであれば、OK でした。

圧縮時にサイズが未定のファイル

ところが twitterアーカイブファイルの場合、"local file header"に記載のファイルサイズがゼロになっています。これは不具合ではありません。おそらくですが、データを生成しながら、一時ファイルなどに落とさず、直接 ZIP ファイルの中に格納することを想定した、ZIPの仕様なのでしょう。この場合、次のような出力が追加されます。

  • "local file header"の圧縮前サイズ 圧縮後サイズ・CRC32(チェックサム)がゼロになる
  • "local file header"内の汎用フラグビットの第3ビット(=0x0008)がセットされる
  • データ領域の後の "data descriptor" という領域が追加され、ここに最終的なファイルサイズ・CRC32 が格納されるようになる

これを見る限りは DataDescritor のマーク(正しくは Signature:"PK\0x07\x08")を検索すればよさそうです…

Data descriptor

オフセット サイズ 内容
0 0/4 (オプショナル)シグネチャ="PK\0x07\x08"
0/4 4 CRC-32
4/8 4 圧縮サイズ
8/12 4 非圧縮サイズ

> オプショナル <

あかんやん

"data descriptor" の検出があてにならないのであれば、次のファイルの"local file header"をそのマーク("PK\x03\x04")から検索して、そこから遡るしかありません。さらに壊れていない ZIP も扱う場合は次に来るのは"local file header"ではなくセントラルディレクトリのマーク("PK\x01\x02")が来るかもしれません。

えぇ、やりましたとも、えぇ!

"PK\x03\x04" を検索して、そこから "data descriptor" 分をカットすれば、それがファイルデータ本体です。 データの中に "PK\003\x04"が入っていることなんて滅多にないでしょうから、これで大丈夫でしょう(フラグ)

このあたりまでを実装したのが「たぶん動くと思うよバージョン( The version "I think it will probably work" )」:uncozip v0.0.1です。ここまで一応当初の目的の「twitter の ZIP の展開」という目的は達成できました。

「そんな実装で大丈夫か?」

滅多どころか頻繁にありました2。ZIP ファイルの中に ZIP ファイルが含まれている場合です。ZIPファイルはこれ以上の圧縮は期待できないことから、これをさらに ZIP ファイルの中に格納する場合、無圧縮になります。すると子ZIPファイルのマーカーを親ZIPのマーカーと混同してしまうのです。 この誤認識を判定するには、もう整合性の正しさを確認するしかありません。具体的には、このマーカーが正しいと過程した場合に抽出できる "data descriptor" の圧縮後ファイルサイズが実際にデータ領域のサイズ、もしくはデータ領域のサイズ-4バイトであるはずです。この判定処理をマーカー検出の中に入れてみたところ、期待どおり展開できるようになりました。3

大丈夫だ、問題ない

展開されたファイルを目視で見る限りは大丈夫そうです。でも、ちゃんとチェックサムで確認しておくべきです。"compress/flate" で伸長されるデータをディスクに出力するのと同時にチェックサムをとりましょう。

伸長処理は "compress/flate" のコンストラクflate.NewReader(伸長前のデータのio.Reader) で OK です。戻り値が伸長後のデータを得る io.ReadCloser になっています(つまり要CloseなReader)。これで読みだした値をディスクにセーブするには io.Copy で全データをコピーすればよいのですが、これを io.TeeReader で横取りしましょう。

rc := flate.NewReader(b) // b は前節で切り出した "data descriptor" 直前までのデータを読み取る io.Reader
fd, err := os.Create(fname)
h := crc32.NewIEEE()
_, err = io.Copy(fd, io.TeeReader(rc, h))
crc32 = h.Sum32()

これを"local file header"、もしくは "data descriptor" に記載の CRC32 フィールドと比較するだけです。ね、簡単でしょ?

必要要件じゃないけど、unzip の派生と主張するからには実装しておきたい機能

このあたりまで実装したのが v0.1.0となります。 あとに残された仕様は

  • ZIP64対応
  • 暗号化ZIP

といったところです。これらは必須ではありませんが、「壊れたZIPでも展開できる」という触れ込みであれば、一応「タイトルに偽りなし」のためにも実装しておきたいところです。

ZIP64

こちらは簡単でした。拡張データフィールドは

2バイト ID
2バイト サイズ(n)
nバイト データ本体

の繰り返しになっていて、ID=1 のフィールドがZIP64フィールドです。 ZIP64 の場合、データ本体の最初の8バイトが圧縮前のデータサイズ、次の8バイトに圧縮後のデータサイズが並んでいます4。 気になるのは "data descriptor" がある時に、4GB を越えるサイズのファイルを格納する場合です。確認したのですが、その場合の仕様はどうも見つかりません。4GB を越えるファイルに大して後付けでサイズ情報を付けても遅いよ!という話でしょうか

暗号化対応

ZIPは一応複数の暗号化の実装をサポートしているようですが、実質「Traditional PKWARE Encryption」だけに対応していればよさそうです。ここのアルゴリズムは正直よく分からなったので

の書き方をほとんど真似させていただく形になりました。

"local file header" の汎用フラグビットの第0ビットがセットされている時、ファイルデータが暗号化されています。この場合、ファイルデータは次のような構造になっています。

最初の12バイト 暗号化ヘッダ
13バイト目以降 データ本体

復号化処理自体は1バイト目から全部やるけれども、1~12バイトのところはパスワードがあっているかどうかの検証だけに使います(あっていればそのまま破棄)。データとして使う場所は13バイト目以降で、これを Deflate 処理にかけます。

パスワードがあっているかの検証ですが、実はやらなくても復号化した結果が Deflate 処理でエラーになって間違っていること自体は分かるのですが、そうするとパスワードが間違っていた時に入力しなおすための処理のロールバックが面倒なので、早いうちにやり直した方がやはりスマートです。

よく分からなかったのが、肝心のパスワードの検証方法。仕様書では

After the header is decrypted, the last 1 or 2 bytes in Buffer SHOULD be the high-order word/byte of the CRC for the file being decrypted, stored in Intel low-byte/high-byte order. Versions of PKZIP prior to 2.0 used a 2 byte CRC check; a 1 byte CRC check is used on versions after 2.0. This can be used to test if the password supplied is correct or not.

とあり、要は12バイトを復号化した時の最後の1~2バイト部分が CRC と比較する…とあります。 この「CRC」は "local file header" の CRC32 のことだろうなと思ったのですが… どうしても一致しません。

仕方がないので、カンニングお手本の処理系 kdungs/zip を見たところ

まじか… 騙されたと思って、uncozip の復号化部分で、最終更新時刻と比較させるようにしたら

  • Info-ZIP で作成したパスワード付きZIPファイルに大して、正しいパスワードで開封しようとした時
    → 最終更新時刻の unsigned short 表現と一致する : OK
  • Info-ZIP で作成したパスワード付きZIPファイルに大して、誤ったパスワードで開封しようとした時
    → 最終更新時刻の unsigned short 表現と一致しない : OK

うまく動くじゃん

いったいどういうことなんでしょうねぇ。仕様書がおかしいのか、フォーマットのバージョンでも変わったのか… まぁ、とりあえず、これでヨシとしましょう → v0.4.0

その他、一応実装したこと

この他は、もうやってもやらなくてもどちらでもよいという感じですが、せっかくなので可能な範囲で実装しておいたものです。

タイムスタンプ

"local file header" にも最終更新日時は記録されていますが、DOS時代のものであるため

  • 最小記録単位が2秒
  • 現地時刻が使われているけれども、タイムゾーン情報がない

という問題があります。現代的な日時情報は拡張フィールド内に記録されています。こちらにはファイルの作成日時・最終更新日時・最終アクセス日時が 1970年1月1日0時0分0秒からの経過秒数5が格納されています。

var secondsSince1970 uint32
err = binary.Read(r, binary.LittleEndian, &secondsSince1970)
if err != nil {
  return
}
tm = time.Unix(int64(secondsSince1970), 0)

そんなビット数で大丈夫か?6

どうしても出来なかったこと - ファイルのパーミッション

Linuxの動作チェックをしているとき、実行属性が消えちゃうので、これはまずいなと思いました。あと、DOSの READONLY/ARCHIVE などの属性も復元したいところです。ファイルのパーミッション情報が格納されているのは 拡張データフィールドに入ってるかと思ったのですが、確認したところ "central directory" の中にしかないようです。

仕様的にむりー("central directory" が欠けた ZIPファイルに対して使うツールなんだから)

まとめ

他、Linux でも ShiftJIS なファイル名を展開できるように -decode オプションをつけたりいろいろしたんですが、キリがないのでこのあたりにしておきます。


  1. ウェブサーフィンしながらダウンロードするからだよ、常識的に考えて
  2. 実際はデータディスクリプタを使ってて、かつ ZIP の中に ZIP が入っている時なので、まぁ実際稀なんですが
  3. ただ、自分の趣味で標準入力に対応したかったため、io.Reader しばりとなり、なかなかたいへんでした。
  4. それ意外のデータは "local file header" で参照する場合は、正直どうでもよいので、詳しくは http://menyukko.ifdef.jp/cauldron/dtzipformat.html#academy
  5. いわゆる UNIX時間
  6. 2038年問題