🤐

deterministicなZIPファイルをつくる

13 min read

転載元の「deterministicなZIPファイルをつくる」はCC-BY 4.0(https://creativecommons.org/licenses/by/4.0/)でライセンスされているため、この記事についても同じライセンスが適用されます。

概要

先日、なんとなくあまねけ!ミラーサイトを作りました[1]。さらに、なんとなくサイトのアーカイブも配布してしまおうと思い、各サイトからZIPファイルをダウンロードできるようにしました。

このアーカイブからミラーサイトを生成するというユースケースを考え、これらのZIPファイルのSHA256ハッシュを配置することで、アーカイブの更新を検知できるようにしています。しかし、配置されたハッシュを確認したところ、各CIで生成されるZIPファイルが環境ごと、あるいは同CIでの実行ごとに変化していることに気付きました。

ZIPファイルは生成時に意識すべきフォーマット上の特性が多く、ZIPファイル生成がnondeterministicになってしまうという投稿[2][3]や、deterministicなZIPファイルを得るためのテクニックに関する投稿[4][5]をいくつか見つけることができました。

そして、以下の点に注意すれば、ほとんどの環境で同じバイナリのZIPファイルを生成できることが分かりました。

  1. タイムスタンプを揃える
  2. 拡張フィールドを捨てる
  3. 格納順を揃える

これらの特性を意識して、同じファイル群に対してdeterministicなZIPファイルを生成できるスクリプトdeterministic-zip.shを作成しました。

以下、詳細です。

ZIPファイルの構造

ZIP (ファイルフォーマット) - Wikipediaによれば、ZIPファイルは以下のような構造でファイルおよびメタデータを格納しています。

  1. ファイルエントリ: ファイルの実体とメタデータ(エントリごとに繰り返し)
    1. ローカルファイルヘッダ
    2. ファイルデータ
  2. セントラルディレクトリエントリ: ファイルエントリの位置とメタデータ(エントリごとに繰り返し)
  3. セントラルディレクトリ終端レコード: セントラルディレクトリの位置とメタデータ

nondeterministicなZIPファイル

ZIPファイル生成においてdeterministicであるとは、異なる環境や異なる実行時刻で同じファイル群に対して何度ZIPファイルを生成しても、それらがバイナリとして等しくなることを指しています。この記事では、SHA-256ハッシュが等しければdeterministicだとみなすことにしましょう。

#!/bin/bash
sha256sum ./archive1.zip ./archive2.zip
2c8bb67800202dc213f0fad5d7604c1a930a270937e880e2c3fab17e185d3cb8  ./archive1.zip
2c8bb67800202dc213f0fad5d7604c1a930a270937e880e2c3fab17e185d3cb8  ./archive2.zip

同じファイル群から異なるバイナリ――nondeterministicなZIPファイルが生成される原因としては、以下のようなものが考えられます。

ファイルの内容が異なる

ファイル名に実行時刻を含んでいたり、毎回異なるランダムなトークンを含むファイル群に対しては、当然ですが常に異なるZIPファイルが生成されます。これは、deterministicかどうかの議論以前に、そもそも想定通りの動作といえます。

ファイルの属性が異なる

ファイルエントリやセントラルディレクトリエントリに書き込まれるメタデータのうち、ZIPファイルの生成時に注意しなければならない属性は以下の通りです。

  • タイムスタンプ
  • パーミッション
  • 拡張フィールド

ファイルの内容が同じでも、これらの属性に差があれば異なるZIPファイルが生成されます。

このうち、拡張フィールドはzipコマンドの -X オプションによって設定を省略できます。しかし、タイムスタンプは拡張フィールドではないため、常にファイルの日時で設定されます。タイムスタンプを無視する必要がある場合は、touchコマンドなどで揃えておく必要があります。

#!/bin/bash
# タイムスタンプを 1980-01-01 00:00 に揃える
# (ZIPのタイムスタンプは1980-01-01 00:00から始まる)
find ./target_dir -exec touch -t 198001010000 '{}' +

# 拡張フィールドを捨ててアーカイブする
zip -r -X archive.zip ./target_dir

タイムスタンプやパーミッションを忠実に再現する必要がある場合は、これらの属性が等しい限りにおいてdeterministicなZIPファイルが生成することができます。

ファイルの格納順が異なる

ZIPファイルの中で格納する場所が明確に決まっているのは、セントラルディレクトリ終端レコードのみです。ファイルエントリは、セントラルディレクトリエントリの前ならどのような順番で格納してもかまいません。すなわち zip -r などのコマンドによって格納順が変わる可能性があります。

格納順を厳密に定義する必要があれば、zipコマンドの -@ オプションによってファイルリストを渡せます[6]。これにより、ファイルリストの上から順に格納されることを 期待 できます。

#!/bin/bash
# 格納順を揃えてアーカイブする
find ./target_dir -print0 | tr '\0' '\n' \
  | LC_ALL=C sort \
  | zip -@ archive.zip

また、セントラルディレクトリエントリはファイルエントリと同じ順番で並んでいる必要はありません[7]。ファイルエントリの順番が同じでも、セントラルディレクトリエントリの順番が異なれば、生成されるZIPファイルは変化します。

その他

セントラルディレクトリエントリやセントラルディレクトリ終端レコードでは、ファイルやアーカイブ全体に対するコメントを埋め込めるため、これらに差があれば異なるZIPファイルといえます。

#!/bin/bash

cd "$(mktemp -d)"

mkdir -p ./output/
echo '111' > ./output/amane_1.txt
echo '222' > ./output/amane_2.txt
find ./output -exec touch -t 198001010000 '{}' +

# archive1.zip
find ./output -print0 | tr '\0' '\n' | LC_ALL=C sort | zip -@ -X ./archive1.zip

# archive2.zip
find ./output -print0 | tr '\0' '\n' | LC_ALL=C sort | zip -@ -X ./archive2.zip
echo 'comment.' | zip -z ./archive2.zip

# comparison
sha256sum ./archive1.zip ./archive2.zip
diff -up <(zipinfo ./archive1.zip) <(zipinfo ./archive2.zip)
a3f71de17bbb6841b2e148c2587e768567521ec7219f27073262dd745be03046  ./archive1.zip
70c6b9c3d540eec1c1b358ce10d3cf318214ee59342742575fa7169e2a7acd5f  ./archive2.zip
--- /dev/fd/63  2021-06-03 11:17:02.820000000 +0900
+++ /dev/fd/62  2021-06-03 11:17:02.820000000 +0900
@@ -1,5 +1,5 @@
-Archive:  ./archive1.zip
-Zip file size: 344 bytes, number of entries: 3
+Archive:  ./archive2.zip
+Zip file size: 352 bytes, number of entries: 3
 drwxr-xr-x  3.0 unx        0 b- stor 80-Jan-01 00:00 output/
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane_1.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane_2.txt

今日から使えるスクリプト

以下のようなスクリプトを用いると、ここまで述べた特性を意識した方法でアーカイブを生成できます。

以下のように使用すると、どの環境でも ba81afb46018e2b69210ce7fd2409c53540f0df8a0c8c03a82b73850b62af40c というSHA256ハッシュのZIPファイルが生成されるはずです。

#!/bin/bash

cd "$(mktemp -d)"

mkdir -p ./output/
echo -n '111' > ./output/amane_1.txt
echo -n '222' > ./output/amane_2.txt

./deterministic-zip.sh ./archive.zip ./output
sha256sum archive.zip

このスクリプトは、zipコマンドがファイルリストと異なる順番でファイルエントリを格納したり、セントラルディレクトリエントリとファイルエントリを異なる順番で格納したりする場合には正しく動作しません。

また、スクリプト内の各コマンドの挙動によっては正しく動作しない可能性があります(後述)。ただし、こちらはzipinfoコマンドで差分を検出できます。

現在、あまねけ!の各ミラーサイトで配布されているZIPファイル(約26MB)はこのコマンドで生成されています。ただし、Vercelはデプロイ用のコンテナにzipコマンドがなく、Cloudflare Pagesは25MB以上のファイルを配信できない[8]ので、これらのサイトには配置していません。

以下から、2つのサイトで配布されているZIPファイルのハッシュ値が等しいことを確認できます。

今日から使えるライブラリ

同様の操作を行うライブラリが各言語で実装されているようです。用途によってはこちらの方が使いやすいかもしれません。

deterministic-zip - npmは自前でZIPファイルの生成処理を行っており、腕力を感じます。残りの2つはZIPファイルの生成処理を別のライブラリに委譲しており、ファイルエントリやセントラルディレクトリエントリの順番に関して、今回作ったスクリプトと同じ 期待 をしているようです。

故障かな?と思ったら……

よく気を付けてZIPファイルを生成しても、異なるバイナリが生成されてしまう場合があります。その場合は、zipinfoの結果を比較して、ファイルの格納順と属性に差がないか確認しましょう。

#!/bin/bash
diff -up <(zipinfo archive1.zip) <(zipinfo archive2.zip)

結果をソートしてから比較しては いけません。同じファイルが異なる順で出力される場合は、ファイルの格納順が異なっています。

zipinfoの結果に差がなければ、実際に展開して差分を見てみましょう。

#!/bin/bash
unzip -d archive1 archive1.zip
unzip -d archive2 archive2.zip
diff -upr archive1 archive2

これらにも差がなければ、ぜひコメントから教えてください。

sortの動作に差がありませんか?

GNU coreutilsのsortは、LC_ALL=C の下では各入力を1バイトずつ比較します[10]。sortの実装が異なっていたり、ロケールの指定が誤っているなどの原因によって比較順に差があれば、常に異なるZIPファイルができあがります。

#!/bin/bash

cd "$(mktemp -d)"

mkdir -p ./output/amane
echo 'aaa' > ./output/amane/amane.txt
echo 'bbb' > ./output/amane.txt
echo '000' > ./output/amane0.txt
echo '111' > ./output/amane_1.txt
echo '222' > ./output/amane_2.txt
echo '333' > ./output/amane-3.txt
find ./output -exec touch -t 198001010000 '{}' +

# archive1.zip with LC_ALL=C
find ./output -print0 | tr '\0' '\n' | LC_ALL=C sort | zip -@ -X ./archive1.zip

# archive2.zip with LC_ALL=en_US.UTF-8
find ./output -print0 | tr '\0' '\n' | LC_ALL=en_US.UTF-8 sort | zip -@ -X ./archive2.zip

# comparison
sha256sum ./archive1.zip ./archive2.zip
diff -up <(zipinfo ./archive1.zip) <(zipinfo ./archive2.zip)
f3c6aa100250a1d5011fe87fc764fd41f808465f2f2f33d0e4b8724ff94f1664  ./archive1.zip
ffb0e28e26cd7f01441cdb87f8fa876571d1c2c7e4c68ef08b2db8a7e20aea11  ./archive2.zip
--- /dev/fd/63  2021-06-03 00:58:38.240000000 +0900
+++ /dev/fd/62  2021-06-03 00:58:38.240000000 +0900
@@ -1,11 +1,11 @@
-Archive:  ./archive1.zip
+Archive:  ./archive2.zip
 Zip file size: 912 bytes, number of entries: 8
 drwxr-xr-x  3.0 unx        0 b- stor 80-Jan-01 00:00 output/
 drwxr-xr-x  3.0 unx        0 b- stor 80-Jan-01 00:00 output/amane/
--rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane-3.txt
--rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane.txt
--rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane/amane.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane0.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane_1.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane_2.txt
+-rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane-3.txt
+-rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane/amane.txt
+-rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane.txt
 8 files, 24 bytes uncompressed, 24 bytes compressed:  0.0%

既存のアーカイブに対してファイルを追加・更新・削除していませんか?

既存のアーカイブに対して新たな格納順でファイル群を渡しても、格納順は変更されずに内容のみ更新される可能性があります。また、ファイルの削除時にセントラルディレクトリエントリのみ削除されたり、ファイルエントリとセントラルディレクトリエントリの格納順に差異ができれば最終的なZIPファイルは異なります。

リソースの制限やシステム上の要請がなければ、常に フレッシュな ZIPファイルを生成することを勧めます。

#!/bin/bash

cd "$(mktemp -d)"

mkdir -p ./output/amane
echo 'aaa' > ./output/amane/amane.txt
echo 'bbb' > ./output/amane.txt
echo '000' > ./output/amane0.txt
echo '111' > ./output/amane_1.txt
echo '222' > ./output/amane_2.txt
echo '333' > ./output/amane-3.txt
find ./output -exec touch -t 198001010000 '{}' +

# archive1.zip (create)
find ./output -print0 | tr '\0' '\n' | LC_ALL=en_US.UTF-8 sort | zip -@ -X ./archive1.zip

# archive1.zip (update) and archive2.zip (create)
find ./output -print0 | tr '\0' '\n' | LC_ALL=C sort | zip -@ -X ./archive1.zip
find ./output -print0 | tr '\0' '\n' | LC_ALL=C sort | zip -@ -X ./archive2.zip

# comparison
sha256sum ./archive1.zip ./archive2.zip
diff -up <(zipinfo ./archive1.zip) <(zipinfo ./archive2.zip)
ffb0e28e26cd7f01441cdb87f8fa876571d1c2c7e4c68ef08b2db8a7e20aea11  ./archive1.zip
f3c6aa100250a1d5011fe87fc764fd41f808465f2f2f33d0e4b8724ff94f1664  ./archive2.zip
--- /dev/fd/63  2021-06-03 01:03:57.450000000 +0900
+++ /dev/fd/62  2021-06-03 01:03:57.450000000 +0900
@@ -1,11 +1,11 @@
-Archive:  ./archive1.zip
+Archive:  ./archive2.zip
 Zip file size: 912 bytes, number of entries: 8
 drwxr-xr-x  3.0 unx        0 b- stor 80-Jan-01 00:00 output/
 drwxr-xr-x  3.0 unx        0 b- stor 80-Jan-01 00:00 output/amane/
+-rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane-3.txt
+-rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane.txt
+-rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane/amane.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane0.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane_1.txt
 -rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane_2.txt
--rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane-3.txt
--rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane/amane.txt
--rw-r--r--  3.0 unx        4 t- stor 80-Jan-01 00:00 output/amane.txt
 8 files, 24 bytes uncompressed, 24 bytes compressed:  0.0%

その他

以下のような特性を持つアーカイブは、いつでもnondeterministicなZIPファイルと似た現象が起こりえます。

  • ファイルの格納順を自由に指定できる
  • ファイル名やファイルの内容以外の属性も格納できる

例えば、Web Bundlesは複数のレスポンスをまとめて配信できるフォーマットであり、レスポンスヘッダや格納順などに注意する必要がありそうです。

脚注
  1. リンクがばらけないようにcanonical属性でhttps://ama.ne.jp/...に集約しています。 ↩︎

  2. bash - zip non-deterministic result in linux - Stack Overflow ↩︎

  3. php - Zip files contain same files but have different hashes? - Stack Overflow ↩︎

  4. 内容物が同一なのにハッシュ値の異なる ZIP ファイルが出来ないようにするには(あるいは、AWS Lambda へ同一コードを update することを防ぐには) - Qiita ↩︎

  5. Building Deterministic Zip Files with Built-In Commands | by Pat Wilson | Medium ↩︎

  6. zip(1): package/compress files - Linux man page ↩︎

  7. 新しいZIPファイルを生成している限りは、セントラルディレクトリエントリとファイルエントリが同じ順番で格納されていることを 期待 できますが、もちろん最終的には実装によって決まります。 ↩︎

  8. Limits · Cloudflare Pages docs ↩︎

  9. 現在のオリジナルサイトです。 ↩︎

  10. sort(1): sort lines of text files - Linux man pageを参照。ASCIIの文字コードは、..., -, ., /, 0, 1, 2, _, ...という順に並んでいます。 ↩︎

Discussion

ログインするとコメントできます