café 和 café 不是一个文件名

你在 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 里解出来的资源包。

怎么办

四条,按性价比排:

  1. 比较文件名之前先规范化。Python 用 unicodedata.normalize("NFC", name),两边都转再比;JS 用 name.normalize("NFC")。别拿 listdir() 的原样结果去和用户输入直接 ==。
  2. 判存在用 os.path.exists() / stat(),不要用「在不在列表里」。前者走内核查找(会规范化),后者比字节。
  3. 打包和解压的入口做一次规范化 + 冲突检测:解压前把每个 entry 的名字 normalize,发现两个条目规范化后相同就直接报错,别让第二个静默覆盖。这步十几行,能挡住上面「两个条目变一个文件」。
  4. 跨平台同步时,别指望 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 或内核源码,所以「比较时做规范化」是推断。换文件系统结论可能不同,别直接套。

相关推荐
独隅2 天前
WebStorm macOS 安装部署与高效调优完全指南
ide·macos·webstorm
独隅3 天前
WebStorm macOS 安装调优与实战效能全景
ide·macos·webstorm
2601_967987373 天前
安装 Redis 或 Redis Stack
linux·windows·redis·macos·安装
高调的涵涵3 天前
1GB 文件,df 说没占地方
macos
奶油话梅糖3 天前
苹果的内核跑上了安卓手机:2,631 Star 的 HakiOS 让 XNU 在 Adreno GPU 上画出 118 帧
android·macos·智能手机
独隅4 天前
WebStorm macOS 安装调优与实战效果全景
ide·macos·webstorm
木子欢儿4 天前
国内 macOS/Linux 安装 rust
linux·运维·服务器·macos·rust
一个人旅程~6 天前
关于imac27inch因改短用户名导致混合硬盘和系统崩溃的问题分析与故障解决
经验分享·笔记·macos·新浪微博
volunteer10246 天前
从仅剩 6 GB 到狂腾 70 GB:我的 256G Mac mini 深度清理与内存排查实战
macos