手元のプラグインを社内配布の形にしてみようと思い立ったのは、zip からの導入が使えるようになったと知った直後でした。git も npm も挟まずに、置き場所を1つ決めて、中身は SHA-256 で確かめる。個人開発の小さな配布にはちょうどよい粒度に思えました。
作った zip のハッシュを控えて、少し手を止めて、もう一度同じディレクトリから zip を作り直しました。中身は1文字も変えていません。
ハッシュが違っていました。
バイト数は同じ 22,669。展開してみると、ファイルの内容も構成も完全に一致しています。それでも SHA-256 は別物でした。ピン留めという仕組みを、私は最初の10分で自分から壊していたわけです。
以下は、その差分がどこにあるのかをバイト単位で測り、20回連続で同じハッシュが出る形まで詰めた記録です。すべてサンドボックス上の Linux(Info-ZIP の zip 3.0、Python 3)で実際に走らせた数値を載せています。
50 バイトの正体
まず、独立した2回のチェックアウトを模しました。git はファイルの mtime を保存しません。クローンし直すたびに、全ファイルのタイムスタンプは「そのとき」になります。CI で毎回クリーンチェックアウトしている構成は、まさにこれに当たります。
# 同一内容のツリーを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 / 22669
runs = []
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]
食い違っていたのは 22,669 バイト中の 50 バイト、割合にして 0.22% です。しかも 50 箇所すべてが長さ1の孤立したバイトでした。連続した塊ではありません。
一方、展開後のツリーは完全に同じです。
( cd x1 && find . -type f | LC_ALL = C sort | xargs sha256sum | sha256sum )
# 87f1d6cc4b71348a23ab7237f92d07fc7e1b04d3c993b5054ac67b75225df440
( cd x2 && find . -type f | LC_ALL = C sort | xargs sha256sum | sha256sum )
# 87f1d6cc4b71348a23ab7237f92d07fc7e1b04d3c993b5054ac67b75225df440
正体はタイムスタンプです。このアーカイブにはエントリが 11 個(うちディレクトリ 5 個)あり、それぞれが3種類の時刻を持っていました。
格納場所 形式 1エントリあたり
ローカルファイルヘッダ MS-DOS 時刻・日付 4 バイト
セントラルディレクトリ MS-DOS 時刻・日付 4 バイト
拡張フィールド UT Unix time(32bit) 4 バイト × 2 箇所
UT は Info-ZIP が既定で足す拡張タイムスタンプで、この構成では拡張フィールドの合計が 264 バイトありました。秒単位まで持つので、DOS 時刻より細かく差が出ます。2秒差のビルドで動くのは主に下位バイトだけ。だから「孤立した1バイトが50箇所」という散らばり方になります。
内容ではなく、いつファイルを取り出したかを、zip は律儀に記録している。ハッシュはそれを含めて計算されます。当たり前と言えば当たり前なのですが、ピン留めを設定する側の頭には、まず浮かびません。
間を空けずに2回ビルドすると一致してしまう
ここが今回いちばん厄介だと感じた落とし穴です。
再現性を疑ったとき、多くの人はまず「続けて2回ビルドして、ハッシュを見比べる」はずです。私もそうしました。そして、そのやり方では問題が出ません。
MS-DOS 時刻の粒度は2秒です。同じ2秒窓に収まったビルドは、DOS 時刻も Unix time も同じ値になり得ます。間隔を変えて測りました。
2回のツリー生成間隔 1回目(先頭12桁) 2回目 判定
0 秒 034179b40720 034179b40720 一致
1 秒 fd37aad07691 f83beb9efdb4 不一致
3 秒 bdc8402f83a5 d978834c43c9 不一致
事前の予想は逆でした。「タイムスタンプが入るなら毎回必ず変わるはずだ」と思っていたのです。実際には、素早く2回試すと一致してしまう。手元の確認は通り、翌日の CI で落ちる。再現条件が時間に依存するせいで、原因にたどり着くまでに遠回りをしがちな類の不具合です。
回避策は単純です。再現性を検証するなら、2回のビルドの間に最低3秒は空けるか、後述するように mtime を意図的に撹乱してから比較してください。私はこの撹乱を検証手順そのものに組み込むことを推奨します。連続実行の一致は、何の保証にもなりません。
順序・圧縮レベル・拡張フィールドも効く
タイムスタンプだけが変数ではありません。同じ mtime に揃えたうえで、投入順だけを変えて測りました。
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 -@ )
投入順 SHA-256(先頭16桁) サイズ
バイト順 18e68b4d24fcf025 21,579 バイト
逆順 daf1243d606a69ed 21,579 バイト
サイズは同じで、ハッシュだけが違います。zip -r はファイルシステムが返す順にエントリを並べるため、この順序はディレクトリの内部構造に依存します。同じツリーでも、作成順やファイルシステムが変われば揺れる余地があります。
圧縮の指定も同じです。オプションを変えながら測りました。
コマンド SHA-256(先頭16桁) サイズ
zip -qaa50bf6552d70f39 21,891 バイト
zip -qX18e68b4d24fcf025 21,579 バイト
zip -qX -039c8ec8872fb1d05 28,109 バイト
zip -qX -618e68b4d24fcf025 21,579 バイト
zip -qX -9fc6fbadb81cc38dd 21,579 バイト
読み取れることが3つあります。
-X(拡張フィールドを落とす)で 21,891 → 21,579 バイト。6ファイル分の UT と ux(UID/GID)が消えて 312 バイト縮みました。UID/GID がアーカイブから消えるのは、配布物としても望ましい方向です。
既定の圧縮レベルは -6 です。明示しない -qX と -qX -6 はハッシュまで一致しました。
-6 と -9 はサイズが同じ 21,579 バイトなのに、ハッシュが違います 。deflate の探索方法が変わり、同じ長さでも別のバイト列になったためです。サイズ一致は同一性の代わりになりません。ここは実際に測るまで見落としていました。
20回連続で同じハッシュを出す
必要な操作は結局4つに絞れました。mtime を固定値へ正規化し、投入順をロケール非依存のバイト順に固定し、拡張フィールドを落とし、圧縮レベルを明示する。それだけです。
#!/usr/bin/env bash
# pack.sh — 決定的なプラグイン zip を作る。同じ中身からは常に同じ SHA-256 が出る。
set -euo pipefail
SRC = " ${1 :? usage : pack . sh < plugin-dir > < out . zip > } "
OUT = " ${2 :? } "
[ -f " $SRC /plugin.json" ] || { echo "plugin.json がありません: $SRC " >&2 ; exit 1 ; }
STAGE = "$( mktemp -d )"
trap 'rm -rf "$STAGE"' EXIT
cp -a " $SRC /." " $STAGE /"
# 配布物に混ぜたくないものを先に落とす(.git を含めるとハッシュは毎回変わる)
rm -rf " $STAGE /.git" " $STAGE /node_modules"
find " $STAGE " -name '.DS_Store' -delete
# 1) mtime を固定値へ正規化する。-h でシンボリックリンク自身も対象にする
find " $STAGE " -exec touch -h -d @1000000000 {} +
# 2) 投入順をバイト順に固定する(LC_ALL=C でロケール依存を断つ)
# 3) -X で UID/GID と拡張タイムスタンプを落とす
# 4) 圧縮レベルを明示する(既定は -6。将来の既定変更に巻き込まれないため)
( cd " $STAGE " && find . -type f -print | LC_ALL = C sort | zip -qX -9 " $OLDPWD / $OUT " -@ )
printf '%s %s\n' "$( sha256sum " $OUT " | cut -d ' ' -f1 )" " $OUT "
検証は、毎回 mtime をランダムに撹乱してから走らせました。連続実行で偶然そろう可能性を潰すためです。
for i in $( seq 20 ); do
find det -type f -exec touch -d "@$(( 1700000000 + RANDOM))" {} +
./pack.sh det det.zip
done
20回すべてで fc6fbadb81cc38dd...、21,579 バイト。同一ハッシュ 20/20 でした。
別々のチェックアウトを模した確認も通っています。片方の mtime を 2025年相当、もう片方を 2026年相当に強制的にずらしても、出力は一致しました。
fc6fbadb81cc38dd1ba7d51347c1755ffcf9fc8bd8dc85cb0970f038ee954355 a.zip
fc6fbadb81cc38dd1ba7d51347c1755ffcf9fc8bd8dc85cb0970f038ee954355 b.zip
1文字だけ変えた場合はもちろん別のハッシュになります。summarize.md の末尾に1バイト足しただけで、fc6fbadb81cc38dd は 1be5cbf22e3fea20 へ移りました。検知したいものは、ちゃんと検知される状態です。
取得側と、ピン留めが守ってくれないもの
配る側が決定的になったら、受け取る側も固めます。
#!/usr/bin/env bash
# verify.sh — 取得先を固定し、中身はハッシュで確かめてから使う
set -euo pipefail
URL = " ${1 :? usage : verify . sh < url > < expected-sha256 > } "
WANT = " ${2 :? } "
TMP = "$( mktemp -d )"
trap 'rm -rf "$TMP"' EXIT
curl -fsSL --proto '=https' --tlsv1.2 -o " $TMP /p.zip" " $URL "
GOT = "$( sha256sum " $TMP /p.zip" | cut -d ' ' -f1 )"
if [ " $GOT " != " $WANT " ]; then
echo "SHA-256 不一致" >&2
echo " expected: $WANT " >&2
echo " actual : $GOT " >&2
exit 1
fi
echo "SHA-256 一致: $GOT "
--proto '=https' を付けているのは、リダイレクトで平文へ落とされる経路を塞ぐためです。ハッシュがあるので改ざんは検知できますが、検知できることと、そもそも通さないことは別の話だと考えています。本番運用に載せるなら、この2行は削らないことをお勧めします。
そのうえで、ピン留めが守らない範囲を把握しておく必要があります。ひとつ実測で分かりやすいのは、実装差です。同じ入力・同じタイムスタンプ・同じ順序で、Python の zipfile と Info-ZIP の zip を比べました。
作成ツール SHA-256(先頭16桁) サイズ
Info-ZIP zip -qX -9 fc6fbadb81cc38dd 21,579 バイト
Python zipfile(compresslevel=9) e58348bb23262ce4 21,607 バイト
中身は同一でも、別のハッシュ・別のサイズになります。つまり「ハッシュが合わない」は改ざんの証拠にはならず、パッケージャを取り替えただけかもしれない。ピン留めを運用に載せるなら、パッケージング手順そのものをリポジトリに置いて固定するところまでが一組です。pack.sh を配布物と一緒に管理するのは、そのためでもあります。
同様に、ピン留めの外側にあるものも意識しておきたいところです。プラグインが実行時に npx で何かを取ってくるなら、そのバージョンは zip のハッシュとは無関係に動きます。アーカイブの同一性は、実行時の依存の同一性を意味しません。プラグインが暗黙に前提としている環境については、プラグインの可搬性で見落としやすい4つの前提 でも触れています。
運用に落とす
社内配布で回すなら、最低限これだけは形にしておくと後が楽です。
pack.sh をリポジトリに置き、CI から呼ぶ 。手元の zip を手で作らない。パッケージャを固定した時点で、再現性の議論の半分は終わります。
ハッシュはマニフェストで配る 。plugin.zip の隣に plugin.zip.sha256 を置き、更新時は URL とハッシュを同じコミットで動かします。片方だけ差し替えられる状態を作らないことが肝心です。
URL にバージョンを埋める 。latest のような可変の入口を SHA-256 でピン留めすると、更新のたびに全利用者が失敗します。固定 URL と固定ハッシュを対にして、移行は新しい対を配る形にします。
検証コストは無視してよい 。このVMで 50 MB のファイルの SHA-256 は 224 ms でした。プラグインの zip はふつう桁が2つ小さいので、導入のたびに検証しても体感には出ません。
zip -X を必ず付ける 。再現性のためだけでなく、UID/GID が配布物に残らない実利があります。
未検証のまま残していることも書いておきます。ロケール依存の並び順は、日本語のファイル名を含む構成で確かめようとしましたが、このVMには C/POSIX 系のロケールしか入っておらず(locale -a で4件)、差を再現できませんでした。ただし投入順が変わればハッシュが変わることは上で実測済みです。並び順を生成環境の既定に委ねる合理的な理由はないので、LC_ALL=C は入れておく、という判断にしています。
zip 配布そのものが新しいため、将来ツール側が独自にアーカイブを正規化する可能性も残ります。その場合でも、配布側が決定的であって困ることはありません。
この先の一歩
いま zip 配布を検討しているなら、最初にやるべきは pack.sh を1本置くことです。ピン留めを設定してから再現性に気付くと、失敗するのは利用者の導入時になります。順番を逆にしておくだけで、その事故は起きません。
私自身、ハッシュという言葉の硬さに引きずられて、「同じ入力なら同じ出力」を無条件に信じていました。実際には、入力の定義に時刻とファイルシステムの都合が紛れ込んでいた。測って初めて見える種類の前提でした。
プラグインをどこから取ってくるかという話は、プラグインマーケットプレイスの探し方と評価 にもまとめています。あわせて読んでいただけたら嬉しいです。仕様の最新は Claude Code のドキュメント をご確認ください。
お読みいただきありがとうございました。手元で測った数値がそのまま役に立つ場面があれば幸いです。