同一tar包为何解出八种不同结果
一个由GNU tar正常生成的归档,在不同解压实现中出现文件缺失、静默损坏、补零或拒绝处理等差异。文章通过复现实验追溯POSIX规范中的歧义,并讨论工程应对方式。
AI 深度解读
同一份 tar 包,GNU tar 解出 3 个文件,bsdtar 解出 2 个,还把原文件内容改坏了;另一些工具则直接拒绝。问题出在硬链接声明的 512 字节载荷:POSIX 允许它存在,却没说清它应该做什么。
这篇实验值得程序员读,尤其是处理 CI 产物、用户上传和软件包的人。它揭示了一个容易漏掉的风险:扫描工具看到的文件树,可能和安装工具最终落盘的文件树不同,而且两边都能成功退出。
一个正常命令,怎样造出两种解释
这个案例由 Harmen Stoppels 在 11 月 7 日公布,是 tar-minefield 测试集里的 F35,名称为 pax-size-on-hardlink。它没有手工拼字节,而是用 GNU tar 生成。
下面的命令把同一个文件以两个路径加入归档,再给第二个条目设置特殊元数据:
printf hello > file
touch empty
tar --format=pax --pax-option='delete=?time,size:=512' \
--transform='s,^\./file,café,' \
-cf hardest.tar file ./file empty
关键在 size:=512,但它需要和其他参数一起看。
file 和 ./file 指向同一个 inode,也就是同一个底层文件。GNU tar 因此把第二个条目存成硬链接,typeflag 为 1,目标是 file。--transform 又把这个条目改名为 café,非 ASCII 名称促使工具写入 pax 扩展头,也就是 x 块。
delete=?time 删除原本会生成的 atime、mtime、ctime 记录,size:=512 则把硬链接通常为零的大小覆盖成 512。
tar 的头部固定占一个 512 字节块,文件内容也按块补齐。这个归档里,硬链接头部后面恰好就是 empty 的头部。于是,同一块字节出现了两种身份:可以是硬链接的载荷,也可以是下一个文件的头部。
512 恰好让两条解析路径都能继续往下走,没有立即触发格式错误。
作者机器上的归档总长为 10,240 字节,size=512 位于 pax 数据块内的 0x611 偏移处。归档没有截断、错位或校验和错误,争议发生在字节的含义上。
标准允许载荷,却没定义如何处理
POSIX.1-2024 对 pax 和旧 ustar 格式的描述,留下了一个解释空间。
在 pax 部分,标准允许 typeflag 1 的硬链接携带数据块;在 ustar 部分,标准又规定类型 1、2、5 不存储数据记录。这个归档带有 pax 扩展头,应当按 pax 的许可解释。
但许可到此为止。硬链接后面的数据究竟应该覆盖目标内容、作为独立内容保存,还是直接忽略,标准没有交代。
作者在 Windows 11 上,用 Git for Windows 自带的 GNU tar 1.35 构造归档,再用 GNU tar、系统自带的 bsdtar 3.8.4,以及 Python 3.14.9 实测。
GNU tar 通过 tar --force-local -xf hardest.tar -C out_gnu 解出了完整的 3 个文件:file 内容为 5 字节的 hello,café 是它的真实硬链接,empty 为空。然而,同一个程序执行 tar -tf 时只列出 file 和 café,链接大小显示为 512。列目录和解压竟然采用了不同解释。
bsdtar 通过 "C:/Windows/System32/tar.exe" -xf hardest.tar -C out_bsd 解压时,把下一块当成链接载荷写入 café。由于它与 file 共享内容,两个名字读出来都变成了 512 字节的 empty 头部,真正的 empty 则消失了。
Python 的表现可以同时从元数据和落盘结果观察:
import tarfile
with tarfile.open("hardest.tar") as t:
for m in t.getmembers():
print(m.name, m.type, m.size, m.linkname)
t.extractall("out_py", filter="data")
这里要看 m.size:元数据遍历能看到链接声明的 512,但解压仍恢复了完整的 3 个文件。filter="data" 没有让这项歧义自动消失。
三组实测都以退出码 0 结束。成功退出只能说明工具完成了自己的解释,不能证明文件内容符合预期。
不同工具交付的是不同文件树
原文其余结果来自 Stoppels 的跨实现调查,不能全部算作作者在 Windows 上亲自复现。
GNU tar、BusyBox、Python tarfile、Go archive/tar 和 Perl Archive::Tar 都忽略链接大小,解出 3 个文件。
7-Zip、node-tar、tar-fs,以及 Rust 的 tar 和 astral-tokio-tar 则承认这 512 字节属于载荷,但只跳过、不写入。结果是 file 保住了,empty 静默消失。对 Node 项目来说,这一组尤其相关。
其他工具还走出了不同分支:Java Commons Compress 把 café 变成包含头部数据的独立普通文件;PHP PharData 把它变成独立空文件,同时解出 empty;RubyGems 不解出 café,还留下 PaxHeaders 目录;R 的内部 untar 给 empty 多补了从结束标记读取的 512 个零字节;Haskell tar 文件结构接近 GNU tar,但把 café 解成乱码。
.NET、Julia Tar.jl 和 uv 的 tar-codec 选择拒绝。后两者的错误直接指向硬链接大小非零,其中 tar-codec 明确指出 HardLink 不能携带大小为 512 的载荷。
拒绝并不完全符合 pax 对载荷的许可,却可能符合软件安装器的安全约定。恢复工具希望尽量救出内容,包安装器希望遇到歧义就停止。两者服务的需求不同,不能只按兼容格式的数量评价。
我的判断
我赞成原文对软件分发流程的判断:遇到解释不一致的输入,停止处理通常比静默猜测更容易控制后果。尤其当扫描器和安装器使用不同解析库时,扫描通过的内容未必就是最终安装的内容。
但这不是已经证实的供应链漏洞。原文没有给出完整利用链,也没有证明某个具体安装流程能被绕过。它证明的是解析分歧,以及成功退出时仍可能发生的内容损坏。
测试集的规模也需要放在正确位置理解。tar-minefield 当时收录了 134 个歧义归档;在来自 Spack、nixpkgs、Guix、PyPI、crates.io、RubyGems 和 live-bootstrap 的 82,586 个不同发布包中,带有这种 pax 硬链接大小记录的数量为零。这个结果支持将其视为边界测试样本,不能外推成所有来源都不存在这种输入。
相比换用严格解析器,保留宽容解析并增加警告,更适合备份恢复和取证。但警告必须进入处理结果,不能只藏在没人看的日志里。
内容哈希比退出码更能验证解压结果,但完整文件树还包括路径、类型和链接关系。
原文没有给出严格策略的误拒绝率,也没有提供各实现的性能对照。关于该选哪种解析器,仍然是基于用途的工程判断。
能拿来做什么
- 处理 Node 上传包或构建产物时,检查扫描、解压、部署分别使用什么库。如果流程涉及 node-tar、tar-fs 或系统 tar,就把 F35 加入兼容性验证,观察缺失文件、内容变化和链接关系。
- 给 CI 产物记录解压器名称、版本及关键参数。复现一次部署,需要知道归档由谁解析;GNU tar 自己的列表和解压结果都可能不同,不能只保存
tar -tf的输出。 - 使用 Python
tarfile时,保留合适的提取过滤器,并在元数据阶段检查硬链接是否声明非零size。示例已经表明,这类异常可以在落盘前识别,再按业务用途决定拒绝还是报告。 - 验证不可信归档时,比较预期文件树与实际结果中的路径、文件类型、内容哈希和链接目标。让第二个工具再解一遍可以帮助发现分歧,但不能直接把其中一份结果当作正确答案。