DevKitLab Logo DevKitLab
ハッシュ / チェックサム / 文字コード / デバッグ

同じファイルなのにハッシュ値が違うのはなぜ?

あるファイルのハッシュを計算し、同僚が同じファイルだと確信しているコピーを計算すると、ダイジェストが一致しない。ハッシュは、同じバイト列に対して必ず同じ結果を返す決定的な仕組みです。だから値が変わったのは、バイトが変わったからです――音もなく、目に見えない形で。この記事では、文字コード、BOM、改行、末尾の改行、テキストとバイナリのモード、構造化データ、詰め直したアーカイブという常連の原因を順に見ていき、最後に両者をそろえ直す正規化の方法まで示します。

あるファイルのハッシュを計算する。同僚が、同じファイルだと確信しているコピーを計算する。二つのダイジェストが一致しない。あるいは、今日ハッシュを計算し、ビルドサーバーを一周させてもう一度計算すると、値が変わっている。何も編集していない。だから最初に浮かぶ疑いは、いちばん不安なものだ。ハッシュ関数が不安定なのか。

そうではない。ハッシュは、コンピュータの世界でもっとも信頼できる仕組みの一つだ。値が変わったのは、バイトが変わったからだ――音もなく、目に見えない形で。この記事は、「同じファイル」が同じバイトでなくなる、そのあらゆる道筋と、それらをそろえ直す方法についての話だ。

核心の一点:ハッシュは決定的だ

暗号学的ハッシュは、与えた正確なバイトだけで決まる純粋な関数だ。同じバイトを入れれば、同じダイジェストが出る――どのマシンでも、どの言語でも、何度実行しても、永遠に。乱数も、時計も、マシン固有のシードもない。あるバイト列の SHA-256 は、宇宙の定数のようなものだ。

以下はすべて、両側が同じアルゴリズムを使い、比べているのがダイジェストそのものである、という前提に立つ。SHA-256 対 SHA-256 であって、SHA-256 対 MD5 でも、hex 対 base64 でもない。その前提のもとで、結論は身も蓋もない。二つのハッシュが違えば、二つのバイト列は違う。それだけだ。「同じファイルなのにハッシュが違う」というものは存在しない。手元にあるのは、あなたが同じファイルだと思い込んでいる、二つの異なるバイト列だ。嘘をついているのは「同じ」という言葉であって、ハッシュではない。

だから問いは「なぜハッシュが変わったのか」ではない。「自分が見ていない隙に、何がバイトを変えたのか」だ。この記事の残りでは、その常連の原因を順に見ていく。その多くは、エディタでファイルを開いても目には見えないものだ。

後半は一言添えておきたい。ここでつまずく人が多いからだ。違っているのがダイジェストそのものであって、その印字のされ方だけではない、と確かめてほしい。AB…ab… は同じ値の大文字・小文字違いで、同じダイジェストは hex でも base64 でも書ける。それが今の状況なら、バイトはまったく同一で、何も問題はない――その罠とその仲間はチェックサムが一致しないのはなぜにある。ここから先は、アルゴリズムは同じで、ダイジェストが本当に違う、という前提で話す。

三十秒で、実際に起きるところを見る

分類に入る前に、効果を直接見てみよう。同じ二行のテキストを、まず Unix の改行で、次に Windows の改行で。画面上ではまったく同じものだ。

printf 'a\nb\n'     | sha256sum   # 4 バイト
# 911169ddaaf146aff539f58c26c489af3b892dff0fe283c1c264c65ae5aa59a2

printf 'a\r\nb\r\n' | sha256sum   # 6 バイト
# 58055bdcc73787eb88c78d36f0b4939e9c5dc1c3ad17e25cc85a6833cf1a0cab

目には見えない 2 バイトが増えただけで、ダイジェストは原形をとどめない。hello の前にバイト順マークを置いても同じことが起きる。

printf 'hello'             | sha256sum
# 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

printf '\xef\xbb\xbfhello' | sha256sum
# 7489ebbcc2a00056ddaaaac190bce473e5c03696ea1bd8ed83cf59a174283862

自分でも走らせてみてほしい。見えない 3 バイトがダイジェストを書き換えるところを一度見てしまえば、この記事の残りはもう驚くような話ではなくなる。

まず確かめる:計算しているのは、思っているとおりのバイトか

微妙な原因を疑う前に、いちばん素朴な可能性を除外する――あなたが計算したのは、意図したファイルとは別のものだった。

  • 別のファイルなのに、名前は同じ。別のビルド、別のバージョン、別のフォルダにあるコピー。ファイル名は嘘をつく。フルパスとサイズを確かめよう。
  • ファイルはまだ書き込み中だった。ダウンロードやエクスポートの途中で計算し、一秒後にツールがさらにバイトを書き込んだ。
  • ターゲットではなく、シンボリックリンクやショートカット。リンクそのものを計算したか、別のマシンでは別のファイルに解決されるリンクを計算した。
  • ファイルではなく、ディレクトリ。「フォルダのハッシュ」は完全にツール次第だ。どのファイルを含め、どの順で、メタデータを畳み込むかどうか。同じ「ディレクトリ」でも、二つのツールは二つの答えを返す。

まず、まったく同じ対象を指していることを確かめる。多くの場合、サイズだけで決着がつく。サイズが違えばバイトが違うということで、複雑な原因を探すのは、そこでやめてよい。

テキスト:「同じテキスト」は多くの異なるバイト列だ

この種の謎の多くは、ここに潜んでいる。エディタで二つのファイルを開くと、一字一句、まったく同じに見える。ただし、ハッシュが読むのは文字ではなくバイトだ。同じ文字でも、保存されるバイト列は大きく異なりうる。

文字コード。文字 A は UTF-8 で 1 バイト、UTF-16 で 2 バイトだ。アクセント付きや非ラテン文字なら、差はもっと開く。UTF-8 から UTF-16 へ、あるいは Windows-1252 や Shift-JIS のようなレガシーなコードページへ保存し直したファイルは、見える文字は同じでもバイト列が違う。file コマンドが、たいていどちらの側がどの文字コードかを教えてくれる。

バイト順マーク(BOM)。一部のエディタやエクスポート――昔ながらの Windows のメモ帳、Excel の CSV 書き出し――は、ファイルの先頭に 3 バイトの UTF-8 BOM(EF BB BF)を付け足す。画面上では見えない。それがすべてのハッシュを変える。一方にはあり、他方にはない。ダイジェストは決してそろわない。

改行。プラットフォームをまたぐ場合に最もよくある原因の一つだ。Unix は行を LF\n)で終え、Windows は CRLF\r\n)を使う。Windows ではどの行も 1 バイト増えるので、100 行のファイルは目に見える違いが一つもないまま 100 バイトずれる。Git ではこれがよく起こる。core.autocrlf を有効にすると、チェックアウトとコミットで改行を書き換えるので、同じファイルが Windows のクローンと Linux のクローンとで、作業ツリー上で本当に違うものになる。

末尾の改行。ファイルの末尾に最後の改行を置くかどうかで、エディタやツールの意見は割れる。echo "x" は一つ付け、printf x は付けない。

printf x     | sha256sum   # 1 バイト
printf 'x\n' | sha256sum   # 2 バイト――まったく別のダイジェスト

末尾の \n が一つ増えれば 1 バイト増え、SHA-256 のような暗号学的ハッシュでは、雪崩効果がダイジェスト全体を見分けのつかないものにする。

テキストモードとバイナリモード。これはさらに気づきにくい。ディスク上のファイルは最初から最後まで変わっていないからだ。ハッシュツールの中には、ファイルを「テキストモード」で読み、読み取りの最中に改行を変換するものがある。つまりハッシュに届くバイトが、ディスク上のバイトと同じではなくなる。GNU の md5sumsha256sum には -b(バイナリ)と -t(テキスト)がある。Unix では両者の挙動は同じだが、Windows 版ではテキストモードが実際に効き、CRLF を黙って正規化してしまう。同じ罠は言語の API にもある。バイナリではなくテキストモードでファイルを開き、読み取った内容をハッシュすれば、渡っているのは変換され再エンコードされたストリームだ。ハッシュを取るときは、必ずバイナリモードで開く。

見えない文字と正規化。行末の空白。空白に置き換わったタブ。ウェブページから貼り付いた、ゼロ幅文字やノーブレークスペース。そして微妙なもの――Unicode 正規化だ。文字 é は一つのコードポイント(U+00E9)にも、e に結合アクセント(U+0301)が続いた形にもなる。見た目は同一で、人には「同じ」だが、バイトは異なる。

ここで境界を一つ引いておきたい。通常のファイルハッシュが対象にするのはファイルの中身だけで、ファイル名はそこに含まれない。だからファイル名を変えても内容のハッシュは変わらないし、ファイル名の正規化も内容のハッシュには影響しない。正規化が効いてくるのは、名前そのものが入力になるときだけだ。パス文字列、ディレクトリの一覧、ファイル名のリスト、あるいはアーカイブのメタデータをハッシュする場合である。知っておく価値はある。一部の macOS のファイルシステムやツールはファイル名に Unicode 正規化をかけ、Linux はたいてい元のバイトをそのまま保つので、各ファイルの中身がすべて同じでも、パスやファイル名の一覧はシステムをまたぐと食い違いうるからだ。

構造化データ:意味は同じ、バイトは違う

見た目が安定していて実はそうでないのは、テキストファイルだけではない。JSON をハッシュするとき、対象になっているのはデータそのものではなく、そのある一つのシリアライズ結果だ。ライブラリを変える、言語を変える、データベースを一周させる――再シリアライズが起きれば、意味はまったく同じでもバイトの違う結果になりうる。

printf '{"a":1,"b":2}'    | sha256sum   # 43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777
printf '{"b":2,"a":1}'    | sha256sum   # 3fb75453225c732a76b7899ea2096dda1455189c89817239732182f73fe5a09f
printf '{"a": 1, "b": 2}' | sha256sum   # d8497d9d82770a70729261095aa98f7ef5154d7af499f8037b6ca250296785a6

三つのダイジェスト、データは一つ。二つ目はキーの順序が違うだけ、三つ目は空白が増えただけだ。シリアライザが変われば差はさらに増える。非 ASCII を \uXXXX にエスケープするか生のまま出すか、末尾に改行を置くか、浮動小数点をどう出力するか、そもそもキーをソートするかどうか。

対処はハッシュを強くすることではなく、ハッシュの前に正規化することだ。一つのシリアライズ形式を取り決め、両側でそれを生成する。キーをソートし、意味のない空白を落とし、数値とエスケープの規則を固定する。JSON にはまさにそのための標準がある――JSON Canonicalization Scheme、RFC 8785 だ。たいていのエコシステムにライブラリもある。正規形をハッシュすれば、データについて合意している二つのシステムは、ダイジェストでも合意する。

アーカイブ:中身は同じ、容器が違う

zip や tar には、コンテナ固有の差も加わる。二つのアーカイブは、中のファイルが 1 バイト単位で同一でも、ハッシュが違いうる。容器がファイル以上のものを記録するからだ。

  • タイムスタンプ。多くの形式は各エントリの更新時刻を保存する。一分後に詰め直せば、アーカイブのバイトが変わる。
  • ファイルの順序。エントリを加えた順序も、アーカイブの一部だ。ディレクトリを違う順で歩く二回の実行は、違うバイトを生む。
  • 圧縮設定。レベルが違えば、あるいは zlib/gzip のバージョンが違えば、同じ入力から違う圧縮バイトが出る。
  • メタデータ。パーミッション、所有者とグループの ID、そして――gzip なら――ヘッダに焼き込まれた元のファイル名と更新時刻。

これは思っている以上に多くの人を捕まえる。アーカイブだと意識していない形式の多くが、実は ZIP コンテナだからだ。.docx.xlsx.pptx.jar.epub がそうだ。文書を開き、何も変えずに保存し直すだけで、ファイルのハッシュは変わる。中身のパーツが一つも変わっていなくても、zip 内部のタイムスタンプが新しくなり、エントリの順序も変わりうるからだ。

これは再現可能なビルドが難しい重要な理由の一つであり――ツールチェーンのバージョンや環境、ビルドパスも差を生む――ツールがノイズを剥ぐフラグを備えた理由でもある。gzip -n は名前と時刻を落とし、GNU tar なら tar --sort=name --mtime='UTC 1970-01-01' … で順序と時刻を固定できる(これらは GNU のオプションで、macOS 付属の BSD tar はすべてに対応しているわけではない)。ビルドを比べるためにアーカイブをハッシュしているなら、代わりに展開した中身をハッシュする。そのレシピは後述する。

クイックリファレンス

見た目は同じでもバイトが違う理由見つけ方
文字コード(UTF-8 対 UTF-16、レガシーなコードページ)file yourfile が文字コードを示す
片方に UTF-8 BOM先頭 3 バイトが ef bb bf
LFCRLF の改行cat -A yourfile が行末に ^M を出す
片方の末尾に改行最後のバイトが 0a かどうか
末尾やゼロ幅の空白16 進ダンプで見える
テキストモードが改行を変換している同じファイルを明示的にバイナリで読んで計算し直す
再シリアライズされた JSON(キー順・空白・エスケープ)両側を正規化してから比べる
詰め直したアーカイブ(時刻・順序・レベル)サイズが違う。展開してファイルを比べる

実際に差を見つける

ハッシュを取り直すのはやめて、バイトを比べ始めよう。

以下のコマンドは Linux か macOS のシェルを前提としており、いくつかはプラットフォームで異なる。sha256sum は GNU のもので、macOS では shasum -a 256 を使う。cat -A は GNU 版だけの機能で、macOS の BSD cat では -e-t を使う。diff <(…) は bash か zsh のプロセス置換を必要とする。Windows では fc /b が 2 つのファイルをバイト単位で比較し、PowerShell の Format-Hex がバイトをダンプしてくれる。

  1. まずサイズを比べるls -l、または wc -c。ただし、サイズ差が何を示し何を示さないかには注意がいる。分かるのは長さの正味の変化であって、変わったバイトの数ではない。1000 バイトを別の 1000 バイトに置き換えれば、サイズはまったく同じままだ。それでも差の大きさは手がかりになる。数バイトなら BOM か末尾改行、行数に比例して開くなら CRLF だ。
  2. バイトを見るxxd yourfile | head、あるいは diff <(xxd a) <(xxd b) で二つのダンプを比べる。最初に食い違うオフセットは、バイト単位の差がどこから始まるかを絞り込んでくれる。ただしそれは出発点であって、そのまま根本原因とは限らない。文字コードが変わればそれ以降すべてが変わるし、CRLF は行ごとに繰り返し現れ、アーカイブの最初の差はたいてい単なるタイムスタンプだ。
  3. 見えないものを見えるようにするcat -A は改行と末尾空白を示し、先頭 3 バイトをのぞけば BOM を捕まえられる。file は文字コードを推測してくれるが、BOM のない UTF-8 や一部のレガシーなコードページについては、あくまで推測であって断定ではない。

途中でダイジェストを計算し直したくなったら――片側を直して確かめる、別のアルゴリズムを試す――ファイルを ハッシュ生成ツール に渡すとよい。一度に一つのアルゴリズムを、十六進で計算してくれる。ただしを見つけるのはサイズと 16 進ダンプであって、もう一度ハッシュを取ることではない。

再発を止める:正規化してダイジェストをそろえる

バイトを見つけるのは仕事の半分だ。両者がずれ続けるなら、構造的にダイジェストが一致するよう正規化してしまう。

コマンドの前に一つ。sha256sum は GNU のもので、素の macOS には入っていない。macOS での相当品は shasum -a 256 であり、この置き換えは以下のすべての例に当てはまる。

改行。ハッシュの前に CRLFLF に変換する。dos2unix があればそれで済むが、macOS には既定で入っていない。tr は POSIX なので、どこでも動く。

tr -d '\r' < winfile.txt | sha256sum       # Linux
tr -d '\r' < winfile.txt | shasum -a 256   # macOS

先ほどのファイルなら、これは 911169dd… を返す。まさに LF 版のダイジェストだ。(tr -d '\r' は行末のものだけでなく、すべての復帰文字を落とす。整形のとれたテキストファイルなら、その二つは同じ集合だ。)Git リポジトリで根本から止めるなら、* text=auto eol=lf を書いた .gitattributes をコミットし、各自の core.autocrlf に頼るのをやめて作業ツリーをそろえる。

UTF-8 の BOM。UTF-8 の BOM は先頭の 3 バイトと決まっているので、その 3 バイトを読み飛ばすのがいちばん簡単で、正規表現のエスケープも要らない。ほかの文字コードでは違う。UTF-16 の BOM は 2 バイト、UTF-32 は 4 バイトだ。以下のコマンドは、UTF-8 の BOM があると確認済みであることを前提にしている。BOM のないファイルに対して実行すれば、本物の 3 バイトを削り落としてしまう。

tail -c +4 utf8-bomfile.txt | sha256sum         # Linux
tail -c +4 utf8-bomfile.txt | shasum -a 256     # macOS

これは 2cf24dba… を返す。BOM を外した hello のダイジェストそのものだ。

文字コード。まず両側を一つの文字コードにそろえる。

iconv -f UTF-16 -t UTF-8 input.txt | sha256sum

ディレクトリとアーカイブ。容器をハッシュしない。中身をハッシュし、そのハッシュをソートした一覧をもう一度ハッシュする。これでタイムスタンプ、詰める順序、圧縮設定をまとめて外せる。記録されるパスが相対パスになるよう、ディレクトリの内側で実行する。

# 非決定的:容器がタイムスタンプとエントリ順を抱えている
tar -cf - mydir | sha256sum

# 決定的:中身と相対パス、順序は sort で固定する
( cd mydir && find . -type f -exec sha256sum {} + | sort | sha256sum )

このダイジェストが何を含むのかは、正確に押さえておきたい。sha256sum は各ハッシュの隣にパスを出力するので、パスもまた入力の一部になるからだ。これがハッシュしているのは、ファイルの中身とその相対パスである。ディレクトリのタイムスタンプや詰める順序には影響されないが、ファイルの名前が変わったり移動したりすれば変わる。空のディレクトリは対象外で、シンボリックリンクやパーミッション、所有者については何も捉えない。ディレクトリの外側で実行すれば(find mydir …)、フォルダ自身の名前もハッシュに入るので、中身が同じでもフォルダ名が違う二つのツリーは一致しない。「この二つのツリーは、同じ場所に同じファイルがそろったか」を知りたいのなら、これがちょうどよい。パーミッションやシンボリックリンクまで必要なら、そのための専用ツールを使う。

チェックリスト

  1. 同じ対象か。同じパス、同じサイズ、ディレクトリでなく、まだ書き込み中でもない。
  2. 本当にダイジェストが違うのか。それとも hex の大文字・小文字違い、あるいは hex 対 base64 か――それは差ではない。詳しくは、上でリンクした基礎記事を参照してほしい。
  3. テキストをハッシュしているか。文字コード、BOM、LFCRLF、末尾改行、見えない空白、あるいは Unicode 正規化を疑う。あわせて、ツールやコードがバイナリモードで読んでいるかも確認する。
  4. 構造化データをハッシュしているか。JSON のような形式には、正しいシリアライズ結果が何通りもある。まず正規化し、それからハッシュする。
  5. アーカイブをハッシュしているか。タイムスタンプ、ファイル順序、圧縮、メタデータを疑う。実体が zip の .docx.xlsx.jar も含めてだ。容器ではなく中身をハッシュする。
  6. まだ詰まっているか。サイズを比べ、次に 16 進ダンプと diff。最初に食い違うバイトは調査範囲を絞る手がかりであり、根本原因を直接示すとは限らない。

全体を貫くのは、冒頭のあの事実だ。ハッシュは決定的なので、ハッシュが違えばバイトが違う証拠になる。「同じファイルなのに違うハッシュ」は、けっしてハッシュの問題ではない。ファイルが音もなく別のバイト列になっただけだ――改行一つ、文字コード一つ、BOM 一つ、再シリアライズ一回、詰め直し一回――そしてハッシュは、まさに自分の仕事をこなし、そうでないふりを拒んだ。変わったバイトを見つけ、そして二度と変わらないよう正規化しよう。