title: deterministicなZIPファイルをつくる
author: Amane Katagiri
summary: 非公式ミラーサイトのために
date: 2021-06-03T19:32:00+09:00
category: tech
license: CC BY 4.0
rating: sfw
ai_summary: 同一のファイル群から常に同じバイナリ（ハッシュ値）のZIPファイルを生成する「deterministic ZIP」の手法を解説した記事です。ZIPが環境ごとに異なる原因としてタイムスタンプ、拡張フィールド、格納順の3点を挙げ、これらを統一して再現性を確保するシェルスクリプトや注意点を提案しています。CI環境でのハッシュ一致や、差分確認のためのzipinfo活用法についても触れています。
ai_keywords: ZIPファイル, ハッシュ値, 再現性, Deterministic, タイムスタンプ, 格納順, シェルスクリプト, zipコマンド, メタデータ, CI/CD

[TOC]

# 概要

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

[^canonical]: リンクがばらけないようにcanonical属性でhttps://ama.ne.jp/...に集約しています。

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

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

[^ndzipa]: [bash - zip non-deterministic result in linux - Stack Overflow](https://stackoverflow.com/questions/62524167/zip-non-deterministic-result-in-linux)
[^ndzipb]: [php - Zip files contain same files but have different hashes? - Stack Overflow](https://stackoverflow.com/questions/11603615/zip-files-contain-same-files-but-have-different-hashes)
[^ndzip1]: [内容物が同一なのにハッシュ値の異なる ZIP ファイルが出来ないようにするには（あるいは、AWS Lambda へ同一コードを update することを防ぐには） - Qiita](https://qiita.com/knaka/items/c8673dfb4ed1b83d3585)
[^ndzip2]: [Building Deterministic Zip Files with Built-In Commands | by Pat Wilson | Medium](https://medium.com/@pat_wilson/building-deterministic-zip-files-with-built-in-commands-741275116a19)

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

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

これらの特性を意識して、同じファイル群に対してdeterministicなZIPファイルを生成できるスクリプト[deterministic-zip.sh](https://gist.github.com/amane-katagiri/66a8ef1770103f878f9b09e4415a2a37)を作成しました。

以下、詳細です。

# ZIPファイルの構造

[ZIP (ファイルフォーマット) - Wikipedia](https://ja.wikipedia.org/wiki/ZIP_(%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88)#%E6%A7%8B%E9%80%A0)によれば、ZIPファイルは以下のような構造でファイルおよびメタデータを格納しています。

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

# nondeterministicなZIPファイル

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

```sh
#!/bin/bash
sha256sum ./archive1.zip ./archive2.zip
```

```text
2c8bb67800202dc213f0fad5d7604c1a930a270937e880e2c3fab17e185d3cb8  ./archive1.zip
2c8bb67800202dc213f0fad5d7604c1a930a270937e880e2c3fab17e185d3cb8  ./archive2.zip
```

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

## ファイルの内容が異なる

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

## ファイルの属性が異なる

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

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

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

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

```sh
#!/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コマンドの `-@` オプションによってファイルリストを渡せます[^filelist]。これにより、ファイルリストの上から順に格納されることを *期待* できます。

[^filelist]: [zip(1): package/compress files - Linux man page](https://linux.die.net/man/1/zip)

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

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

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

## その他

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

```sh
#!/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)
```

```text
a3f71de17bbb6841b2e148c2587e768567521ec7219f27073262dd745be03046  ./archive1.zip
70c6b9c3d540eec1c1b358ce10d3cf318214ee59342742575fa7169e2a7acd5f  ./archive2.zip
```

```diff
--- /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
```

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

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

```sh
#!/bin/sh

ARCHIVE="$1"
TARGET="$2"

find "$TARGET" -exec touch -t 198001010000 '{}' +
find "$TARGET" -print0 | tr '\0' '\n' | LC_ALL=C sort | zip -@ -X "$ARCHIVE"
```

[deterministic-zip.sh](https://gist.github.com/amane-katagiri/66a8ef1770103f878f9b09e4415a2a37)

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

```sh
#!/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](https://vercel.com/)はデプロイ用のコンテナにzipコマンドがなく、[Cloudflare Pages](https://pages.cloudflare.com/)は25MB以上のファイルを配信できない[^25mb]ので、これらのサイトには配置していません。

[^25mb]: [Limits · Cloudflare Pages docs](https://developers.cloudflare.com/pages/platform/limits)

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

* ミラー1[^mirror1]: [ama.ne.jp/...sha256sum.txt](https://ama.ne.jp/appendices/archive/amanejp.zip.sha256sum.txt)
* ミラー2: amanejp.netlify.app/...sha256sum.txt

[^mirror1]: 現在のオリジナルサイトです。

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

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

* [deterministic-zip - crates.io: Rust Package Registry](https://crates.io/crates/deterministic-zip)
* [deterministic-zip · PyPI](https://pypi.org/project/deterministic-zip/)
* [deterministic-zip - npm](https://www.npmjs.com/package/deterministic-zip)

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

# 故障かな？と思ったら……

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

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

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

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

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

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

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

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

[^ascii]: [sort(1): sort lines of text files - Linux man page](https://linux.die.net/man/1/sort)を参照。ASCIIの文字コードは、..., `-`, `.`, `/`, `0`, `1`, `2`, `_`, ...という順に並んでいます。

```sh
#!/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)
```

```text
f3c6aa100250a1d5011fe87fc764fd41f808465f2f2f33d0e4b8724ff94f1664  ./archive1.zip
ffb0e28e26cd7f01441cdb87f8fa876571d1c2c7e4c68ef08b2db8a7e20aea11  ./archive2.zip
```

```diff
--- /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ファイルを生成することを勧めます。

```sh
#!/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)
```

```text
ffb0e28e26cd7f01441cdb87f8fa876571d1c2c7e4c68ef08b2db8a7e20aea11  ./archive1.zip
f3c6aa100250a1d5011fe87fc764fd41f808465f2f2f33d0e4b8724ff94f1664  ./archive2.zip
```

```diff
--- /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](https://wicg.github.io/webpackage/draft-yasskin-wpack-bundled-exchanges.html)は複数のレスポンスをまとめて配信できるフォーマットであり、レスポンスヘッダや格納順などに注意する必要がありそうです。
