Upgrade to Pro — share decks privately, control downloads, hide ads and more …

macOSで 「ガ.txt」が壊れる 本当の理由

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for 135yshr 135yshr
February 20, 2026

macOSで 「ガ.txt」が壊れる 本当の理由

前半(スライド3〜8)では、問題の再現から原因の特定までを扱います。macOS と Linux で同じ cat ガ.txt を実行したときの挙動の違いから始まり、NFC/NFD の基本、UAX #15 の4つの正規化形式、NFC アルゴリズムの3段階を説明します。そのうえで、Go の標準ライブラリでは rune に分解しても正規化等価を判定できないことを具体例で示し、golang.org/x/text/unicode/norm パッケージの必要性につなげます。
後半(スライド9〜14)が本資料の核心で、norm.NFC.String() の内部実装を処理フローに沿って追いかけます。quickSpan による Quick Check 判定、NFD 入力の UTF-8 バイト列構造、Trie ルックアップの仕組み(実際のコード名 nfcIndex / lookupValue を使用)、Properties 構造体への展開、reorderBuffer による分解・ソート・再合成という流れです。
終盤(スライド15〜17)では、macOS のファイルシステム(HFS+ / APFS)の正規化戦略、実務で遭遇する具体的な壊れパターンと対策をまとめ、「境界で NFC に統一する」という原則で締めくくります。

Avatar for 135yshr

135yshr

February 20, 2026
Tweet

More Decks by 135yshr

Other Decks in Technology

Transcript

  1. PROBLEM 見た目は同じなのに、ファイルが見つからない 3 / 17 macOS $ cat ガ.txt こんにちは世界

    → 正常に読める Linux CI $ cat ガ.txt cat: ガ.txt: No such file or directory → file not found 原因は文字コードではない。Unicode正規化(NFC / NFD)の違い。
  2. BASICS 同じ文字、異なるバイト列 4 / 17 NFC(合成形 / Precomposed) ガ U+30AC

    1 code point / 3 bytes (UTF-8) NFD(分解形 / Decomposed) カ + ゙ U+30AB U+3099 2 code points / 6 bytes (UTF-8) Canonical Equivalence: 意味的に等価だが、バイト列が異なる
  3. SPEC / UAX #15 4つの正規化形式 5 / 17 Composed(合成) Decomposed(分解)

    Canonical (正準) NFC 意味を保持して合成 Web・DB標準 NFD 意味を保持して分解 macOS (HFS+) Compatibility (互換) NFKC 互換分解+合成 セキュリティ検査 NFKD 互換分解のみ 文字解析 例: fi (U+FB01) → NFKD: f + i Canonical形式では分解されない
  4. ALGORITHM NFC正規化の3段階 6 / 17 1 Decomposition 正準分解 UCDの分解マッピング で文字を分解

    → 2 Reordering CCC順に並び替え 結合文字を昇順で ソート → 3 Composition 正準合成 結合可能なペアを 再合成 CCC = Canonical Combining Class(0〜254の整数値)。0 = Starter(基底文字)、非0 = 結合文字。
  5. WHY x/text ? (1/2) rune に分解しても正規化等価は判定できない 7 / 17 //

    NFC「ガ.txt」 []rune{ 0x30AC, 0x2E, 0x74, 0x78, 0x74 } // 5 runes // NFD「ガ.txt」 []rune{ 0x30AB, 0x3099, 0x2E, 0x74, 0x78, 0x74 } // 6 runes rune列の長さも中身も異なる → == は false == 単純なバイト列比較 → NFC/NFDは常に不一致 strings.EqualFold() 大文字小文字の比較のみ filepath.Match / os.Open バイト列をそのまま使う
  6. WHY x/text ? (2/2) x/text/unicode/norm の登場 8 / 17 golang.org/x/text/unicode/norm

    Go公式の準標準パッケージ x/text 配下 UAX #15 を完全実装 Goで唯一の正規化ライブラリ import "golang.org/x/text/unicode/norm" normalized := norm.NFC.String(input)
  7. GO INTERNALS / Overview norm.NFC.String() を呼ぶと何が起きるか 9 / 17 1

    quickSpan() 正規化済みか高速判定 各文字のQCプロパティを確認 全部 QC=Yes → そのまま return ↓ 2 Properties取得 非正規化セグメントを処理 Trie lookup → uint16 → Properties展開 分解データを取得 ↓ 3 reorderBuffer 分解・ソート・再合成 CCC順ソート → 合成ペア検索 flush()で出力 以降のスライドでは、Step 1 → 2 → 3 の順に内部を深掘りしていく
  8. GO INTERNALS / Step 1 quickSpan — 正規化済みか一瞬で判定 10 /

    17 文字列を先頭からスキャンし、各文字の Quick Check(QC)プロパティを調 べる QC は UAX #15 で定義された「この文字は正規化済みか?」を示すフラグ Yes 正規化済み。次の文字へ進む No 要正規化。スキャン中断 → Step 2 へ Maybe 前後のCCCを確認して詳細検査 99.98% のHTMLが既にNFC ASCII Fast-Path byte < 0x80 ルックアップすら不要 ASCIIは全形式で不変 大半の文字列はquickSpanだけで処理完了 → ナノ秒オーダーで return
  9. GO INTERNALS / Step 1 — UTF-8 NFD → UTF-8:

    quickSpan が見ているバイト列 11 / 17 入力「ガ.txt」は NFD 形式: U+30AB(カ) + U+3099(濁点) + U+002E + U+0074 + ... quickSpan が最初に出会うのは U+30AB(カ) U+30AB の UTF-8 エンコーディング(3バイト): 0xE3 先頭バイト 3バイト文字を示す 0x82 継続バイト1 中間データ 0xAB 継続バイト2 末尾データ Go の norm パッケージはこの UTF-8 バイト列を rune に変換せず、そのまま Trie で検索する →
  10. GO INTERNALS / Step 1 — Trie Trie ルックアップ: UTF-8

    バイトで O(1) 検索 12 / 17 Trie = UTF-8 バイト列をキーにして文字の性質を引ける木構造のテーブル 0xE3 1st byte → nfcIndex[0xE3] → i を取得 ↓ 0x82 2nd byte → nfcIndex[uint32(i)<<6 + 0x82] → i を更新 ↓ 0xAB 3rd byte → lookupValue(uint32(i), 0xAB) → uint16 → Properties nfcIndex(1つの配列を2段階で参照)+ lookupValue(nfcValues or nfcSparse)→ rune変換不要の O(1)
  11. GO INTERNALS / Step 2 Properties — Trie が返した uint16

    を読み解く 13 / 17 uint16 値の分岐 v >= 0x8000 ビットをデコード → qcInfo + CCC を抽出 bit4-3: NFC_QC | bit5: combinesFwd | CCC値 v < 0x8000 分解データ配列へのindex [header][分解バイト列][tccc][lccc] Properties が持つ情報 qcInfo 正規化が必要か( Yes/No/Maybe) CCC 並び替えの優先度(0〜254) 分解データ どう分解するか(バイト列位置) combinesFwd 次の文字と合成できるか
  12. GO INTERNALS / Step 3 reorderBuffer — 分解・ソート・再合成 14 /

    17 1 正準分解 Propertiesの 分解データで 文字を展開 → 2 CCCソート 結合文字を CCC値の 昇順に整列 → 3 再合成 合成可能な ペアを検索 → 統合 → 4 flush() 出力バッファ に書き出し → 次へ Stream-Safe Text 結合文字数を最大30に制限 超過時は CGJ (U+034F) を挿入 設計のポイント バッファ確保は必要な時だけ 大半はquickSpanで完了する
  13. FILESYSTEM macOS: HFS+ vs APFS の正規化戦略 15 / 17 HFS+

    (macOS Sierra以前) エンコーディング: UTF-16 正規化: 書き込み時にNFD変換 Unicode: 3.2 ファイル名はディスク上で 常にNFD形式で保存される APFS (High Sierra以降) エンコーディング: UTF-8 正規化: 比較時にハッシュで判定 Unicode: 9.0 原バイト列を保持しつつ ハッシュで正規化非依存の検索 どちらもNFD前提。Linux (ext4等) は正規化しない → クロスプラットフォームで事故が起きる
  14. PRACTICE 実務で壊れるパターンと対策 16 / 17 Git 日本語ファイル名でdiffが発生 CI file not

    found で突然失敗 DB UNIQUE制約に同じ文字が重複登録 S3 オブジェクトキー不一致 JSON キー比較が false になる 対策: 境界で正規化する import "golang.org/x/text/unicode/norm" safe := norm.NFC.String(input) 適用ポイント: ファイル読み込み直後 API入力のバリデーション層 DB保存前 CI/CDパイプライン入口 原則: NFC に統一
  15. SUMMARY まとめ 17 / 17 01 Unicode正規化は「表示」ではなく「等価性保証」の問題 02 Go の

    norm パッケージは Trie + Quick Check で高速処理 03 99.98% は正規化済み → quickSpan の Fast Path が支配的 04 macOS は NFD、Linux は無変換 → 境界で NFC に統一せよ Ref: golang.org/x/text/unicode/norm Ref: unicode.org/reports/tr15 | github.com/135yshr/nfd-nfc-candidates-go