你在 Mac 上建了两个「看起来一模一样」的文件名,系统只留下一个;你把一个含这两个名字的压缩包解压,两个条目也只出来一个,另一个被静默覆盖。全程没有报错。
这不是玄学,是三个可以当场核对的事实:
- 「café」有两种 Unicode 写法:NFC 是 5 个字节
63 61 66 c3 a9,NFD 是 6 个字节63 61 66 65 cc 81,多出来的一个字节是把 é 拆成 e + 声调符。 - 【实测】在 APFS 上先后用这两种字节建同名文件,目录里只有一个条目,两次写入落在同一个 inode。
- 【实测】一个同时含这两种名字的 tar 包,两个条目解压到 macOS 只得到 1 个文件,内容是被后写的那个。
下面每条都有原始输出,跑在 macOS 26.2.1 / APFS / Python 3.9.6 上,脚本和日志在 juejin/evidence/unicode/。Linux 我没机器实测,文末单独标出哪句是推的。
先把两个名字摊开
python
import unicodedata
nfc = unicodedata.normalize("NFC", "café.txt")
nfd = unicodedata.normalize("NFD", "café.txt")
print(nfc == nfd) # False
print(len(nfc), len(nfd)) # 8 9,字符数都不同
print(nfc.encode().hex(" "), nfd.encode().hex(" "))
输出:
python
False
8 9
63 61 66 c3 a9 2e 74 78 74 63 61 66 65 cc 81 2e 74 78 74
NFC 是「合成」:é 一个码位。NFD 是「分解」:e 加 U+0301 COMBINING ACUTE ACCENT。终端里两个都渲染成 café,眼睛分不出,字节差一位。
顺带一句:len() 数的是码位,不是字节。同一个名字,len 是 8 和 9,encode() 后是 9 和 10,两个数都对。
建两次,只得到一个文件
【实测】先建 NFC,再建 NFD,写不同内容:
python
open(os.path.join(base, nfc), "w").write("first\n")
open(os.path.join(base, nfd), "w").write("second\n")
结果:
bash
after creating NFC: ['café.txt']
after creating NFD: ['63 61 66 c3 a9 2e 74 78 74'] count 1
content now: 'second\n' 'second\n'
samefile True
stat inodes 55855840 55855840
第二次 open() 没新建,打开第一个并覆盖了内容。目录项还是一个,inode 相同。
反过来先建 NFD 再建 NFC,结论一样,只是目录里留下的是 NFD 那串字节:
less
listed bytes after NFD-first then NFC: ['63 61 66 65 cc 81 2e 74 78 74'] count 1
也就是说:APFS 在比较文件名时忽略 NFC/NFD 的差别,但存下来的是你第一次给的那串字节,之后不再改。机制说法是 HFS+ 强制存 NFD、APFS 保留原样但在查找和比较时做规范化------这条我没读文件系统源码,是从上面的实测结果反推的,标成推测。
三个程序,三种看法
最容易踩的是:同一台机器上,stat 认、listdir 不认、find 不认。
【实测】目录里只有 NFD 那串字节,然后分别用 NFC 的名字去问:
scss
exists(nfc) True exists(nfd) True ← 内核查找会规范化
nfc in os.listdir() False ← 目录读出来是原始字节
find -name <NFC> → 0 命中
find -name <NFD> → 1 命中
find -iname <NFC> → 0 命中
test -f <NFC> → rc 0(成功)
os.path.exists() 走的是查找路径,内核把 NFC 规范化成 NFD 再比,所以为真;os.listdir() 返回的是目录里真实存的字节,所以为假。find -name 比的是读出来的名字,不规范化,所以 0 命中,加 -iname 也没用-------iname 只折叠大小写,不折叠 Unicode 规范化。
这类 bug 最难查:文件明明在,test -f 也过,但拿 listdir 做白名单、拿 find 做清理、拿字符串相等做缓存键,全都对不上,日志里只写「文件不存在」。
压缩包:两个条目,一个文件
【实测】用 Python 直接造一个 tar 和 zip,里面放两个条目,名字分别是 NFC 和 NFD 字节,内容故意写成 NFC-PAYLOAD 和 NFD-PAYLOAD:
less
tar entries: [('63 61 66 c3 a9 2e 74 78 74', 12), ('63 61 66 65 cc 81 2e 74 78 74', 12)]
zip entries: ['63 61 66 c3 a9 2e 74 78 74', '63 61 66 65 cc 81 2e 74 78 74']
tar extracted count: 1 names: ['63 61 66 65 cc 81 2e 74 78 74']
tar content: [b'NFD-PAYLOAD\n']
zip extracted count: 1 names: ['63 61 66 c3 a9 2e 74 78 74']
zip content: [b'NFD-PAYLOAD\n']
tar 和 zip 两个条目都完整地在包里(这是对的,归档格式存原始字节),但解压到 APFS 时第二个覆盖了第一个,没有任何提示 。tar 留下的是 NFD 形式的目录项、zip 留下的是 NFC 形式的目录项,内容都是后写的 NFD-PAYLOAD。
这在真实场景很常见:CI 在 Linux 打包,产物里有两个只差规范化的文件名(Linux 上它就是两个文件),下载到 Mac 解压,少一个,还不报错。我没测 Linux 端「确实是两个文件」,这是按 ext4 不做规范化推的,算未验证。
同一类问题在目录对比上也一样:
less
diff -r a b → rc 1
Only in a: café.txt
Only in b: café.txt
两个文件内容相同,diff 判成两边各有一个对方没有的文件。rsync -a 则相反:退出码 0,不报错,目标目录里仍然只有一个条目------它把 NFD 写到了已经存在的 NFC 上,静默合并。
git 有两套开关
git 在 macOS 上默认开着 core.precomposeunicode=true,会把路径规范化成 NFC 再存进索引。
【实测】磁盘上是 NFD 字节,提交后树里存的是 NFC:
yaml
default precomposeunicode: true
HEAD tree name bytes: 63 61 66 c3 a9 2e 74 78 74 00
关掉这个开关再走一遍,事情就变得难看:
ruby
$ git config core.precomposeunicode false
tree (precompose=false): 63 61 66 65 cc 81 2e 74 78 74 00
$ git status --porcelain
?? "caf\303\251.txt"
$ git add -A && git ls-files
2 条:63 61 66 65 cc 81 2e 74 78 74 / 63 61 66 c3 a9 2e 74 78 74
索引里出现了两个条目,指向磁盘上同一个物理文件(inode 相同)。commit 里看到的是「新增一个文件」,但 ls 只有一个。之后任何按路径操作的脚本------git checkout、git clean、按索引逐个读文件------都会对同一个文件做两次。
这不是 git 的 bug:索引按字节记,文件系统在查找时又把两种写法当一个东西,两边都「对」,冲突落在中间。
为什么这么难发现
ASCII 时代留下的直觉是:文件名就是字符串,字符串相等就是文件相等。英文环境下这成立,所以它藏在单测之外------assert os.path.exists(path) 会过,assert path in os.listdir(dir) 在 Linux CI 上会过,在 Mac 上挂在某个特定输入的分支里。
会踩到的输入很具体:用户上传的文件名(Finder、部分 iOS 导出、Office 存盘会产出 NFD)、带组合符的韩文和日文、é ü ñ 这类拉丁重音、从 zip 里解出来的资源包。
怎么办
四条,按性价比排:
- 比较文件名之前先规范化。Python 用
unicodedata.normalize("NFC", name),两边都转再比;JS 用name.normalize("NFC")。别拿listdir()的原样结果去和用户输入直接==。 - 判存在用
os.path.exists()/stat(),不要用「在不在列表里」。前者走内核查找(会规范化),后者比字节。 - 打包和解压的入口做一次规范化 + 冲突检测:解压前把每个 entry 的名字 normalize,发现两个条目规范化后相同就直接报错,别让第二个静默覆盖。这步十几行,能挡住上面「两个条目变一个文件」。
- 跨平台同步时,别指望
rsync或diff帮你发现。它们在本机上要么静默合并、要么报成两个不同的文件,都不会告诉你「这俩其实是同一个」。
一段可以直接用的检查:
python
import os, unicodedata
def scan(root):
seen = {}
for name in os.listdir(root):
key = unicodedata.normalize("NFC", name)
if key in seen and seen[key] != name:
print("冲突:", seen[key].encode().hex(" "), name.encode().hex(" "))
seen.setdefault(key, name)
边界
数字都来自这一台机器:macOS 26.2.1、APFS、Python 3.9.6,各跑 1 次,目录和压缩包是本地生成的测试数据。没做的事讲清楚:没在 Linux 上跑同组测试,没测 HFS+、exFAT、SMB 网络盘,没读 APFS 或内核源码,所以「比较时做规范化」是推断。换文件系统结论可能不同,别直接套。