同じコミットから作った zip なのに SHA-256 が変わる — プラグイン配布のピン留めを壊さない正規化
HTTPS 経由の zip からプラグインを導入できるようになり、SHA-256 のピン留めも選べるようになりました。同一内容の zip を2回作って測ったところ、22,669 バイト中 50 バイトだけが異なりハッシュが毎回変わりました。原因の測定と、20回連続で同一ハッシュになる正規化手順をまとめます。
# 同一内容のツリーを2秒あけて2つ用意する(git clone を2回した状態に相当)cp -r src co1 && sleep 2 && cp -r src co2( cd co1 && zip -qr ../co1.zip . )( cd co2 && zip -qr ../co2.zip . )sha256sum co1.zip co2.zip
結果はこうなりました。
アーカイブ
SHA-256(先頭16桁)
サイズ
co1.zip
e550b24486bb82f9
22,669 バイト
co2.zip
c35df325dd913655
22,669 バイト
差分を数えます。
a = open('co1.zip', 'rb').read()b = open('co2.zip', 'rb').read()diff = [i for i, (x, y) in enumerate(zip(a, b)) if x != y]print(len(diff), "/", len(a)) # -> 50 / 22669runs = []for i in diff: # 連続領域にまとめる if runs and i == runs[-1][1] + 1: runs[-1][1] = i else: runs.append([i, i])print(len(runs), sorted({r[1] - r[0] + 1 for r in runs})) # -> 50 [1]
find ord -exec touch -d @1000000000 {} +( cd ord && find . -type f | LC_ALL=C sort | zip -qX ../ord_sorted.zip -@ )( cd ord && find . -type f | LC_ALL=C sort -r | zip -qX ../ord_rev.zip -@ )
同様に、ピン留めの外側にあるものも意識しておきたいところです。プラグインが実行時に npx で何かを取ってくるなら、そのバージョンは zip のハッシュとは無関係に動きます。アーカイブの同一性は、実行時の依存の同一性を意味しません。プラグインが暗黙に前提としている環境については、プラグインの可搬性で見落としやすい4つの前提でも触れています。
受け取った zip は、ピン留めする前に測れます
ここまでは自分で作る側の話です。実際の運用では、他の人が作った zip をピン留めする場面のほうが多いかもしれません。そのとき知りたいのは中身そのものではなく、「この配布物は、次の版でも同じ手順で作り直せる形になっているか」です。
#!/usr/bin/env python3"""zip のピン留め適性を検査する。中身ではなく「毎回同じバイト列になるか」を脅かす要素だけを見る。"""import sys, zipfile, structKNOWN = {0x5455: "UT(拡張タイムスタンプ)", 0x7875: "ux(UID/GID)", 0x000a: "NTFS時刻", 0x0001: "Zip64", 0x9901: "AES"}def parse_extra(blob): """拡張フィールドを (ID, データ) に分解する。壊れていたらそこで打ち切る""" out, i = [], 0 while i + 4 <= len(blob): hid, size = struct.unpack_from("<HH", blob, i) body = blob[i + 4: i + 4 + size] if len(body) != size: break out.append((hid, body)) i += 4 + size return outdef uid_gid(body): """ux(0x7875) から UID/GID を取り出す。version=1 以外は読まない""" if not body or body[0] != 1: return None i, vals = 1, [] for _ in range(2): if i >= len(body): return None n = body[i]; i += 1 if i + n > len(body): return None vals.append(int.from_bytes(body[i:i + n], "little")); i += n return tuple(vals)def inspect(path): with zipfile.ZipFile(path) as z: infos = z.infolist() names = [i.filename for i in infos] stamps = {i.date_time for i in infos} extra_bytes = sum(len(i.extra) for i in infos) ids, owners = {}, set() for i in infos: for hid, body in parse_extra(i.extra): ids[hid] = ids.get(hid, 0) + 1 if hid == 0x7875: og = uid_gid(body) if og: owners.add(og) ordered = names == sorted(names) print(f"# {path}") print(f" エントリ数 : {len(infos)}(うちディレクトリ {sum(1 for n in names if n.endswith('/'))})") print(f" 異なる DOS 時刻 : {len(stamps)} 種類") print(f" 拡張フィールド合計 : {extra_bytes} バイト") for hid, cnt in sorted(ids.items()): print(f" - 0x{hid:04x} {KNOWN.get(hid, '不明'):<22} {cnt} エントリ") print(f" UID/GID : {sorted(owners) if owners else 'なし'}") print(f" 圧縮メソッド : {sorted({i.compress_type for i in infos})}") print(f" 投入順がバイト順 : {ordered}") risky = (len(stamps) > 1) or (0x5455 in ids) or (0x7875 in ids) or (not ordered) print(f" 判定 : {'⚠ 再パックでハッシュが動きます' if risky else '✅ 正規化済み'}") return 1 if risky else 0if __name__ == "__main__": sys.exit(max(inspect(p) for p in sys.argv[1:]))
検査用に小さなプラグイン構成(ファイル 6 個・ディレクトリ 5 個)を作り、素の zip -qr で固めたものと、pack.sh を通したものに掛けました。出力はそのまま載せます。
いちばん見ておきたいのは、素のアーカイブでも「異なる DOS 時刻」が 1 種類だったところです。ひと続きの作業で作ったファイルは同じ2秒窓に収まるので、DOS 時刻の種類を数えても危険は見えません。前の節で「連続実行の一致は保証にならない」と書いたことが、検査の側にもそのまま跳ね返ってきます。だからこの検査は、時刻の種類ではなく UT の有無・ux の有無・投入順を判定の根拠にしています。