跨文件系统保留文件名、时间与元数据语义

Node.js 暴露了文件系统的许多能力,但不同文件系统并不遵循同一套规则。做同步、备份、归档或文件管理工具时,可靠的起点是保留读到的数据,再按照实际目标文件系统的语义比较它们,而不是把所有路径和时间先改造成一种“通用格式”。

本文译自 Node.js 文档贡献者的 How to Work with Different Filesystems,依据 2026-10-05 读取的全文整理。原文中的历史平台例子、字符串长度和归一化措辞在下文作了明确编者注。文中的能力探测与文件操作未执行。

文件系统适配流程:从各挂载点读取原始名称和元数据,完整保留,在临时比较视图中按目标规则比较,并在写入前检测冲突。
原创技术示意图:保留原始数据与用于比较的临时视图应分开;绘制:未完纪编辑整理。

先确认正在访问的文件系统怎样工作

文件系统之间的差异包括:大小写敏感性、是否保留大小写、是否保留 Unicode 形式、时间戳精度、扩展属性、inode、Unix 权限和备用数据流。大小写不敏感与大小写保留是两个维度:一个文件系统完全可以把 Report.txt 和 report.txt 视为同一路径,同时保存用户输入的大小写。

不要凭 process.platform 推断这些行为。例如,程序运行在 Darwin 上,并不能证明当前卷大小写不敏感;原文以 HFS+ 和 HFSX 的区别说明这一点。程序运行在 Linux 上,也不能证明正在访问的外接磁盘、U 盘或网络共享支持 Unix 权限和 inode。一个工作目录树内还可能包含挂载在不同路径上的多个文件系统。

操作系统未必提供一个能直接回答所有问题的接口。与其维护一张必然不完整的“文件系统名称—行为”清单,不如在相应挂载点探测实际行为。容易探测的特征有时能帮助判断难以直接探测的特征,但这种关联不能替代需要保证的属性本身。

编者注:探测必须限定在可丢弃的专用临时目录内,避免拿现有文件做更名、覆盖或时间修改实验。本稿没有探测本机、用户目录或真实业务卷。网络文件系统、挂载选项和目录级设置都可能改变行为,缓存能力时也应考虑它们的变化。

不要以“最低共同能力”为内部数据模型

一种看似可移植的办法,是把文件名全部转成大写,把 Unicode 全部转为 NFC,把所有时间戳截到一秒精度。原文称它为“最低共同能力”方法。这样得到的程序只能安全适配每一项行为都恰好相同的文件系统。

在能力更丰富的卷上,这种处理会把原本不同的文件名或时间合并起来。多个依赖操作接连发生后,冲突可能变成数据丢失或损坏,错误也会很难追踪。如果以后要支持仅有两秒、甚至一天时间精度的文件系统,又要把整个程序继续降级吗?Unicode 规范及相关实现曾经发生变化,把某个归一化算法当成永久存储规则也会留下类似问题。

只调用自认为“到处通用”的系统调用并不保证可移植性。它可能隐藏不了底层差异,却又提前丢掉了可以保留的数据。

采用能力超集:内部保真,边界适配

让程序的内部表示能够保留所支持平台的能力超集:大小写及其原始形式、Unicode 形式、Unix 权限、高精度时间戳、扩展属性等。跨平台备份程序在具有相应能力的系统间,应正确传递文件创建时间和权限;经过不支持这些字段的系统时,也不应轻易毁掉已有信息。

版本订正:原文用“Linux 不支持创建时间、Windows 不支持 Unix 权限”作平台对照,这不应当作为当前系统的绝对判断。现代 Linux 的内核、文件系统和 Node.js 实现可能提供创建时间;权限如何映射也取决于目标接口和卷。应以实际能力、返回值和可写支持为准。Node.js 的 Stat time values 文档说明了创建时间不可用时的回退行为;读得到一个字段,也不意味着可以按同样语义把它写回。

如果内部保存了原始大小写,面对大小写不敏感的卷时仍可增加相应比较规则;如果一开始就扔掉了大小写,之后就无法正确处理保留大小写的卷。Unicode 和时间精度也一样:先保留,必要时才在比较中降低精度。

文件系统给出的名字是大小写混合的,就原样保存;给出的 Unicode 是 NFC、NFD、NFKC、NFKD 或混合形式,也不要擅自改写。返回毫秒或纳秒级时间,就保留可获得的精度。遇到不支持某能力的目标时,明确表示能力缺失,而不要假设“写入什么就一定读回什么”。

比如,不保留大小写的文件系统可能把新建的 abc 列成 ABC。而在保留大小写的文件系统上,即使查找不区分大小写,检测重命名时仍应看见 abc 到 ABC 的变化。

保留大小写,与保留 Unicode 形式

创建 test/abc 后,fs.readdir('test') 在某些文件系统上返回 ['ABC'],并不表示 Node.js 出错:目录枚举反映了文件系统实际保存的名字。一些文件系统会把名字统一成大写或小写。

Unicode 也有类似现象。视觉相同的文字可以由不同码点序列组成,进而得到不同 UTF-8 字节。不要因为 ASCII 中的直觉,就认为所有字符都占一个字节,或者看起来相同的 UTF-8 名称必有相同字节表示。

原文以 café 为例。NFC 采用预组合的 é,NFD 采用 e 加组合重音:

形式 码点表达 UTF-8 十六进制 JavaScript length UTF-8 字节数
NFC caf + U+00E9 63 61 66 c3 a9 4 5
NFD cafe + U+0301 63 61 66 65 cc 81 5 6

与原文的差异:原页把上表的 5 和 6 写成了 string.length,混淆了 UTF-8 字节数与 JavaScript 字符串长度。JavaScript 的 length 计数 UTF-16 代码单元;这里分别是 4 和 5。下面是仅用于解释表示差异的修正版,注释数字来自静态推导,并非本次运行结果:

const nfc = 'caf\u00e9';
const nfd = 'cafe\u0301';

nfc.length;                    // 4 个 UTF-16 代码单元
nfd.length;                    // 5 个 UTF-16 代码单元
Buffer.byteLength(nfc, 'utf8');  // 5 个 UTF-8 字节
Buffer.byteLength(nfd, 'utf8');  // 6 个 UTF-8 字节

const sameCanonicalText =
  nfc.normalize('NFC') === nfd.normalize('NFC');
// 在支持 Unicode 归一化的实现中为 true;不代表两个路径相同。

原文指出 HFS+ 会将文件名规范化为一种通常接近 NFD 的形式。因此,在它上面读到的名字未必与创建时的字节相同。不要假设 HFS+、NTFS 和 ext4 行为相同,也不要用永久重写文件名的方式掩盖这种差异。

编者注:这里的“保留原始字节”是设计目标,不意味着在所有平台上把路径随意转成 UTF-8 字符串就能无损往返。Node.js 的部分文件 API 支持 Buffer 路径或 Buffer 编码的目录名;应按具体平台 API 处理无法按预期编码解释的名称。字符串规范化也不能处理任意字节序列。参见 Buffer paths。

“比较时等价”不等于“保存时改成一样”

Unicode 形式不敏感和 Unicode 形式保留是不同特征。为了实现形式不敏感而把保存、传输的文件名永久变成 NFD,会失去原始表示。更好的办法是保存原名,只在确实需要比较规范等价性时生成临时比较值。

Node.js 的 String.prototype.normalize() 可产生 NFC 或 NFD 形式。对于规范等价的判断,可以比较 a.normalize('NFC') === b.normalize('NFC'),也可以统一使用 NFD。原文建议不要把用于比较的结果作为实际文件名持久化。反复比较时可缓存规范化结果,以避免重复计算,但缓存不能替代原始数据。

语义订正:规范等价不是任意“看起来一样”的判定;同形异义字符不一定规范等价。更不能把这个布尔值直接当成覆盖、去重或重命名许可。NTFS 和很多 Linux 文件系统能够保留 NFC、NFD 和混合形式;在区分这些名称的目标上,必须保留它们各自的身份,并做写入冲突检测。

原文还提醒,normalize() 依赖 Node.js 的 ICU 构建支持。在不含 ICU 的构建中它可能成为无操作;官方发行包通常包含完整 ICU。部署定制构建时应按 Node.js 国际化支持文档核实,而不是把开发机行为当作所有运行环境的保证。

时间戳精度也是用户数据

你把修改时间设为 1444291759414 毫秒,读回来却可能是 1444291759000,甚至 1444291758000。这分别反映一秒和两秒量级的存储精度,不必然是 Node.js 的错误。有些 FAT 文件系统的访问时间精度可粗至一天。

问题不在于所有时间都必须达到最高精度,而在于程序不应预先抹去实际存在的精度。在纳秒级卷上按两秒比较,会漏掉真实变化;对只有两秒精度的目标反复要求毫秒级相等,又可能导致不断重复同步。比较规则必须与实际保存能力相符,并保留源端原始时间以便审计或恢复。

归一化不能成为改写用户数据的理由

文件名和时间戳本身就是用户数据。正如文件工具不应无端把所有内容变成大写,或把既有文件的 CRLF 全部改为 LF,也不应为了内部方便而统一改写名称、Unicode 形式和时间精度。

归一化结果可以看作一种有损比较键:它能表达某一类等价关系,却不能替代原值。程序可以决定新建数据采用 NFC、某种大小写风格或某种时间精度;但这一选择不能变成改写既有用户数据的规则。

最后,比较函数本身也有边界。大小写不敏感不等于调用一次 toLowerCase()。原文提到对某些语言字符,toUpperCase() 的行为可能更合适,但它仍不能替代文件系统内置的大小写折叠表。HFS+ 使用的分解规则也可能与当前 Unicode NFD 有细微差别。需要精确路径身份时,以目标文件系统的实际行为为准;任何“比较相等”都必须与冲突处理一起设计。

来源、许可与核查说明

原作:Node.js 文档贡献者;版权归 OpenJS Foundation 与 Node.js contributors。来源为上文所链接的官方指南;全文翻译转载授权由委托方于 2026-10-05 确认。本文保留来源归属,加入了明确标识的版本订正、UTF-16/UTF-8 长度修正及工程边界说明;原创图不使用原站商标或截图。

审查范围为源文和代码的静态阅读。未更名、遍历或改写真实文件,未测试任何卷的比较、时间或权限语义,也未宣称这些原则足以排除文件工具的所有漏洞。

原始版权与许可

Node.js 网站原始许可来源:官方 LICENSE。本篇为中文翻译与编辑整理。

MIT License

Copyright Node.js Website WG contributors. All rights reserved.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容