最难的 tarball?
2026 年 11 月 7 日
这里有个稀奇的东西:一个归档,它可以用单次 GNU tar 调用构建出来,却会根据你使用的工具不同,以 8 种截然不同的方式被解压。
$ printf hello > file
$ touch empty
$ tar --format=pax --pax-option='delete=?time,size:=512' \
--transform='s,^\./file$,café,' \
-cf hardest.tar file ./file empty
它依次从 file、./file、empty 三个文件创建归档 hardest.tar,其中 file 出现两次,以便 GNU tar 把第二个存成硬链接,而 empty 是空的。利用第二条目的唯一路径,这个硬链接被重命名为 café。这会触发 tar 创建一个 pax 头,以 UTF-8 存储文件名。我们往 pax 头里加一条 size=512 记录,覆盖硬链接默认的 size=0。512 这个值并非巧合:它恰好是一个 tar 头的大小。
从视觉上看,这个归档具有如下结构,并且基于硬链接能否拥有大小,存在两种基本解读。
现在,POSIX 说硬链接的数据块在 pax 归档中“可以被包含”,但其含义并不完全清楚。那么硬链接之后的这 512 字节就是它的数据,而不是我们归档时那个 empty 的头?一个硬链接怎么会有与其前面对应文件不同的内容?
解压它
用知名的 tar 工具和库解压这个归档,产生了迄今为止我遇到过的最丰富、最令人惊讶的结果。以下结果是用写作时所有软件包的最新版本产生的。
GNU tar、BusyBox、Python、Go 和 Perl
这一组原则上很合理:它们忽略硬链接上的大小,解压出 file、它的硬链接 café,以及 empty。
bsdtar
这是 macOS 的系统 tar 工具,在硬链接大小这一概念上更贴近 POSIX 的精神,它把这 512 字节写入硬链接。最终结果是 file 现在包含 empty 的 tar 头。
7-Zip、node-tar、tar-fs,以及 Rust 的 tar 和 astral-tokio-tar
这一组同意 bsdtar 的看法,认为这 512 字节是硬链接的数据,但并不把它们写入硬链接。相反,它们向后跳过这些数据,于是你只剩下 file 和 café,没有 empty。
Java 的 Commons Compress
从这里开始就需要更多想象力了。因为 café 含有数据,所以会创建一个单独的文件,其数据是 empty 的头,而 file 保留其原始内容。
PHP 的 PharData
把 café 变成一个单独的、空的文件,同时还解压出 empty。为什么不呢?
RubyGems
完全不提取 café,但作为弥补,它在 file 和 empty 旁边创建了一个空目录 PaxHeaders。
R 的思路完全不同:它把 empty 解压成 512 个空字节。它错误地把大小应用到了 empty 而不是硬链接上,而这 512 字节来自归档结束标记。
附加:Haskell
Haskell 的 tar 本会和 GNU tar 完全一样,只可惜它有字符串解码问题,会把文件 café 在磁盘上变成 café(在 2026 年,这不是你浏览器的渲染怪癖)。
.NET、Julia 和 uv 的 tar-codec
这些实现拒绝这个 tarball。我认为这是你能做的最好的事,即便从技术上讲它并不符合 POSIX。
更多内容
这个 tarball 是我在测试套件 haampie/tar-minefield 中收集并分类的 134 个刁钻 tarball 之一,该套件自上一篇文章以来一直在开发。它的网站 https://stoppels.ch/tar 有一张大表格,展示各实现如何处理所有这些情况,以及每个测试用例的详细信息。本文这个 tarball 的一个变体也作为一个测试用例被收录。
如果你想讨论这个,请给我发邮件:firstname[at]lastname.ch。