TL;DR
macOSとWindowsはUnicodeの正規化形式が違う。見た目が完全に同じでもバイト列が異なり、パスワードが通らない、ファイルが見つからない、検索がヒットしない等の怪奇現象が発生する。
発端:「パスワード合ってるのに入れない」
先日、こんな現象に遭遇した。
Windowsでパスワードをコピペ → 通る
MacからリモートデスクトップでWindowsを操作してコピペ → 通らない
diffを取っても差分ゼロ。目視でも完全に一致。なのに認証失敗。
お前は何を言っているんだ状態である。
犯人:Unicode正規化形式(NFC vs NFD)
Unicodeには「同じ文字を表現する複数の方法」が存在する。
例:「が」という文字
| 正規化形式 | 表現方法 | コードポイント |
|---|---|---|
| NFC (Composed) | 「が」として1文字で保持 | U+304C |
| NFD (Decomposed) | 「か」+「濁点」に分解して保持 |
U+304B + U+3099
|
そしてOSごとのデフォルトが異なる:
| OS | 採用形式 |
|---|---|
| Windows | NFC(合成) |
| macOS | NFD(分解) |
| Linux | NFCが多い(ディストリ依存) |
見た目は同じ。しかしバイト列は違う。
これが全ての元凶である。
被害者リスト:お前もか案件
1. パスワード認証(冒頭の例)
# macOSでコピーした「パスワード」
U+30D1 U+30B9 U+30EF U+30FC U+30C8 U+3099 ← 濁点分離
# Windowsで期待される「パスワード」
U+30D1 U+30B9 U+30EF U+30FC U+30C9 ← 合成済み
認証システム「知らない文字列ですね」
お前さぁ...
2. ファイル名が見つからない
# macOSで作成したファイル
データ.csv # NFD形式
# Windowsで検索
> dir データ.csv
ファイルが見つかりません
# でもエクスプローラーには見えてる
Git、Dropbox、Google Drive、OneDrive、全員被害者。
3. Git差分が永遠に消えない
$ git status
modified: ドキュメント.md
$ git diff
# 何も表示されない
$ git checkout -- ドキュメント.md
$ git status
modified: ドキュメント.md # まだいる
お前は成仏しろ。
対処法:
git config --global core.precomposeunicode true
4. データベース検索がヒットしない
-- macOSのアプリから登録
INSERT INTO users (name) VALUES ('田中');
-- Windowsのバッチで検索
SELECT * FROM users WHERE name = '田中';
-- 0件
「田」も「中」もNFD/NFCで表現が変わりうる。
照合順序(Collation)の設定を見直せ。
5. CSVインポートで文字化け
# macOSのExcelで出力したCSV → WindowsでインポートしたらNGなやつ
鈴木,東京,営業部 # 「鈴」がNFD
↓
鈴木,東京,営業部 # 別の「鈴」として認識、重複レコード爆誕
マスタデータが汚染される最悪のパターン。
6. APIの署名検証が通らない
# macOSで生成した署名
payload = "ユーザー名=佐藤"
signature = hmac_sha256(secret, payload)
# Windowsのサーバーで検証
expected = hmac_sha256(secret, payload) # 同じ文字列のはず
assert signature == expected # False
マジか。
7. ZIP解凍で文字化け(またはファイル名衝突)
# macOSで作成したZIP
├── 報告書.docx # NFD
└── 報告書.docx # NFC(別ファイル扱い)
# Windowsで解凍
> 同名ファイルが存在します。上書きしますか?
いや、別ファイルなんですけど...
8. 正規表現がマッチしない
// macOSのブラウザで入力された値
const input = "ガンダム"; // NFD
// バリデーション
const pattern = /^[ァ-ヴー]+$/;
console.log(pattern.test(input)); // false
// 「ガ」が「カ」+「濁点」に分解されてるので範囲外
日本語正規表現、難しすぎる問題。
確認方法:バイト列を見ろ
PowerShell(Windows)
# クリップボードの中身を16進ダンプ
[System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard)) | Format-Hex
# 文字列のコードポイントを確認
"が".ToCharArray() | ForEach-Object { "U+{0:X4}" -f [int]$_ }
Python(どこでも)
import unicodedata
text = "が"
print(f"Original: {[hex(ord(c)) for c in text]}")
print(f"NFC: {[hex(ord(c)) for c in unicodedata.normalize('NFC', text)]}")
print(f"NFD: {[hex(ord(c)) for c in unicodedata.normalize('NFD', text)]}")
bash(macOS/Linux)
echo -n "が" | xxd
対処法:正規化を統一しろ
原則:入力時にNFCに正規化
import unicodedata
def sanitize_input(text: str) -> str:
"""入力文字列をNFCに正規化"""
return unicodedata.normalize('NFC', text)
# 使用例
password = sanitize_input(request.form['password'])
username = sanitize_input(request.form['username'])
データベース:照合順序を確認
-- PostgreSQL
SHOW lc_collate;
-- MySQL
SHOW VARIABLES LIKE 'collation%';
Git:設定を追加
# macOSユーザーは必須
git config --global core.precomposeunicode true
ファイル名:ASCII縛りが最強
# Bad
レポート_2024年12月.xlsx
# Good
report_202412.xlsx
日本語ファイル名は甘え。(暴論)
豆知識:なぜmacOSはNFDなのか
歴史的経緯がある。
macOS(旧Mac OS X)のファイルシステムHFS+は、ファイル名の正規化にNFDを採用した。理由は「検索時に濁点の有無を無視してマッチさせたかった」から。
つまり「はな」で検索したら「ばな」も「ぱな」もヒットさせたい、というAppleの親切心が生んだ副作用。
ありがた迷惑とはこのことである。
ちなみにAPFS(現行のファイルシステム)では正規化しなくなったが、互換性のためNFD前提のコードが残っている。
まとめ
| 問題 | 対策 |
|---|---|
| パスワードが通らない | ASCII文字のみ使用 or 入力時にNFC正規化 |
| ファイルが見つからない | ファイル名はASCII推奨 |
| Git差分が消えない | core.precomposeunicode true |
| DB検索がヒットしない | 照合順序確認 + 入力時正規化 |
| API署名が通らない | ペイロードをNFC正規化してから署名 |
結論
見た目が同じでも、バイト列は嘘をつかない。
macOSとWindowsを行き来する環境では、Unicode正規化は避けて通れない。特に日本語を扱うシステムでは、入力の入り口でNFCに正規化することを強く推奨する。
困ったら unicodedata.normalize('NFC', text) 。これだけ覚えて帰ってくれ。
参考文献
- Unicode Normalization Forms - Unicode Standard Annex #15
- Apple Developer Documentation - File System Programming Guide
- Python unicodedata module
この記事が役に立ったら、同僚にも教えてあげてください。あなたの隣で「なぜかファイルが見つからない」と唸っている人を救えるかもしれません。