配布ZIPの中身は同じなのにハッシュが変わる。デジタル商品2ファイルの見本で試した

配布用ZIPを作り直しただけなのに、SHA-256が変わりました。中のテキストは1文字も変えていません。
AIで説明文やテンプレートの下書きを作り、ZIPで売る場面を想定した検証です。商品を修正していないなら同じファイルとして扱えるはずだ、と私は最初に考えました。でも、ZIPのハッシュだけで「中身が変わった」と判定すると、販売後の差し替え確認を誤ります。そこで、実際の販売物ではなくguide.txtとlicense.txtの合成見本2件を使い、どこで値が変わるかを切り分けました。AIツールでこの見本を生成したわけではありません。
最初に外した前提
SHA-256はファイルのバイト列を指紋のように区別する値です。ただし、ZIPそのものの値と、中に入れた各ファイルの値は別物。私はこの区別をせずに、ZIPの値が違えば説明文も変わったと判断しかけました。
Pythonのzipfile.ZipInfoは、ZIP内の項目に更新日時を持たせます。ZipFile.writestr()へ名前だけを渡す場合、Pythonの公式文書には日時を現在時刻に設定するとあります。また、再現可能なビルドの資料は、アーカイブ内の日時やファイル順が結果に影響すると説明しています。今回は環境の時計に依存しないよう、比較用の日時を明示しました。
2秒ずらす、順番を逆にする
次のスクリプトはファイルを保存せず、メモリ上でZIPを組み立てます。商品データを外部に送信しません。ZIP_STOREDを指定して圧縮の差を除き、同じ2件の内容で日時と格納順だけを変えます。h()の表示はSHA-256の先頭16桁で、全文ではありません。
実行したスクリプトと出力を開く
import io, zipfile, hashlib
items = {'guide.txt': '見本商品\n', 'license.txt': '個人利用\n'}
def archive(ts, order, fixed=False):
b = io.BytesIO()
with zipfile.ZipFile(b, 'w') as z:
for name in order:
info = zipfile.ZipInfo(name, (2020, 1, 1, 0, 0, 0) if fixed else ts)
info.compress_type = zipfile.ZIP_STORED
info.create_system = 3
info.external_attr = 0o100644 << 16
z.writestr(info, items[name].encode())
return b.getvalue()
order = ['guide.txt', 'license.txt']
a = archive((2026, 10, 9, 20, 0, 0), order)
b = archive((2026, 10, 9, 20, 0, 2), order)
c = archive((2026, 10, 9, 20, 0, 0), order[::-1])
d = archive(None, sorted(items), True)
e = archive(None, sorted(items), True)
h = lambda b: hashlib.sha256(b).hexdigest()[:16]
print('python', __import__('sys').version.split()[0])
print('contents', [(n, h(v.encode())) for n, v in sorted(items.items())])
print('different timestamp', h(a), h(b), a == b)
print('different order', h(a), h(c), a == c)
print('normalized', h(d), h(e), d == e)
print('zip bytes', len(a), len(d))
print('member names', zipfile.ZipFile(io.BytesIO(d)).namelist())
print('testzip', zipfile.ZipFile(io.BytesIO(d)).testzip())
Python 3.13.5で実行した出力です。
python 3.13.5
contents [('guide.txt', 'def0e3bcefaf9eed'), ('license.txt', '18f2f4de3dd1d533')]
different timestamp cc4ab321bd61d553 84822d3b7aeb15f6 False
different order cc4ab321bd61d553 96cf50bc86c3949b False
normalized ae5cdea4ba03fe91 ae5cdea4ba03fe91 True
zip bytes 240 240
member names ['guide.txt', 'license.txt']
testzip None
内容2件の値は固定でも、日時を2秒ずらしたZIPは一致しません。順番を逆にしたZIPも違います。日時を固定し、名前順に格納した2回だけが一致しました。testzip Noneは、この見本で読み取り検査が異常な項目を返さなかった、という意味です。販売前の品質や安全性を保証する値ではありません。
改訂をどう判定するか
私なら配布前に、ZIP全体のハッシュに加えて、展開前に各項目の名前と内容のハッシュを記録します。本文が同じなのにZIPだけ変わったら、まず項目の日時・順序・権限などのメタデータを確認します。中身の変更を説明したいなら、ZIPの値だけをお客さん向けの改訂履歴にしない方がよさそうです。
ただ、この見本は2ファイルを収めた非圧縮ZIPを同じPython環境で作っただけ。圧縮方式、ツールの版、OS、ZIPの追加フィールドを変えた場合まで同一になるとは示していません。実際の商品で再現性が必要なら、配布先の環境でも作り直して照合します。私は「同じ原稿ならZIPも同じ」という近道を捨てました。比較対象を先に決めます。
出典
- Python公式文書: zipfile(
ZipInfoの日時、writestr()、testzip()の仕様) - Reproducible Builds: Archive metadata(日時、格納順、ZIP追加属性と再現性)


