同一个文件两次 gzip 结果不同:mtime 与文件名字段
同一个文件,两次 gzip 结果不同:mtime 与文件名字段
我在做构建产物的可重复性核对时遇到一个很典型的坑:对一个固定内容的文件连续压缩两次,得到的产物哈希不一样。直觉上会怀疑压缩算法有随机性,但 gzip 的 DEFLATE 是确定性的。真正变化的是文件头里那两个很少被注意的字段。本文把实测过程写清楚,并给出可以直接落地的做法。以下数值来自本地隔离临时目录的真实执行,未连接任何生产数据;本文为原创整理,使用 AI辅助进行实验设计、代码编写与文字检查。
先看文件头,而不是先怀疑压缩器
gzip 的 10 字节固定头结构是:魔数 1f 8b、压缩方法 08、标志字节、4 字节 mtime、压缩级别提示 XFL、操作系统字节 OS。Python 的 gzip.GzipFile 在默认情况下会把当前时间写进 mtime。我做的第一组对照是:同一份内容,间隔约 1.1 秒压缩两次,然后解出 mtime 字段。
default_across_seconds_equal False
mtime_field 1793830418 1793830419 delta 1 equals_now True
os_byte 255 xfl 2
两次的 mtime 相差 1 秒,且都等于当前时间戳。也就是说,只要跨越了秒边界,产物就必然不同。这也解释了一个很容易误判的现象:连续跑两次如果落在同一秒内,结果是相同的,于是会得出“可重复”的错误结论。我第一次写这个实验时就踩了这个坑——第一版输出显示两次结果相等,恰恰是因为两次调用挤在同一秒里。所以验证可重复性必须显式跨过时间边界,或者干脆断言 mtime 字段的值。
把 mtime 固定为 0
把 mtime=0 传进去之后,跨秒对照立刻稳定:
mtime0_across_seconds_equal True sha 9f1af4f01f38120d
B mtime0_twice_equal True header 1f8b08000000000002ff
头部变成 1f8b 08 00 00000000 02 ff,mtime 四个字节全零。注意不要把“mtime 为 0”理解成“时间戳缺失导致的异常”;它是 gzip 规范里合法且常用的取值,解压端不依赖它判断内容新旧。
更隐蔽的一个:文件名字段会把路径写进产物
标志字节的第 3 位(0x08,FNAME)表示头部后面跟着一个以 NUL 结尾的原始文件名。只要给 GzipFile 传了 filename,这个位就会被置上,名字原样写入。而 gzip.open(path, 'wb') 这种最常见的写法会自动把 basename 写进去。实测:
C fname_flag True leaked_name secret.txt
C size_delta 11
D gzip_open_leaks_basename mybuild.tar flag True
这里有两个后果。第一是可重复性:同样的内容、同样的 mtime=0,只要文件名不同,产物哈希就不同(我另外验证了 a.txt 与 b.txt 两种情况下字节流不相等)。第二是信息泄露:我在测试里故意传了 /home/user/private/secret.txt,产物头里出现的是 secret.txt——也就是说 GzipFile 只保留 basename,目录部分没有泄露;但 gzip.open 走的是同一路径,同样会把构建机上的文件名带出去。如果文件名本身包含用户名、项目代号或内部编号,这就成了一条不该存在的侧信道。
我试过三种写法,标志字节和哈希的对照很干净:
no_filename flag 0x0 sha 4656540a9fe4f739 len 29
empty_filename flag 0x0 sha 4656540a9fe4f739 len 29
a_txt flag 0x8 sha 2d2a503f6709d0f3 len 35
也就是说,显式传 filename='' 和不传 filename 的字节流完全相同,FNAME 位是 0;只要给了非空名字,位就变成 0x08,产物也跟着变。要注意 gzip.open() 不接受 filename 参数(传了会报 TypeError: open() got multiple values for argument 'filename'),所以需要纯净头时应该用 gzip.GzipFile(filename='', fileobj=f, mode='wb', mtime=0) 配一个自己打开的文件对象,而不是 gzip.open。另外我确认过它只写 basename:给 /home/user/private/secret.txt 实际落进产物的是 secret.txt,目录部分不泄露。
压缩级别也是哈希的一部分
同一份内容在不同级别下的产物长度和哈希都不同:
E levels {1: (74, '375b3acd4ff5885e'), 6: (69, 'fa5d394eaec07850'), 9: (69, '9f1af4f01f38120d')}
级别 6 与 9 在这份输入上长度相同(都是 69 字节),但哈希不同,说明字节流并不因为“压得更狠但结果一样长”就相同。所以可重复构建的断言必须把压缩级别一起固定,不能只固定 mtime。反过来,如果只比较解压后的内容,那么任何级别都相等——F roundtrip_ok True 就是我顺手加的一条正向对照,避免把“产物不同”误读成“数据损坏”。
可以直接照做的检查清单
第一,产物要跨时间可复现,就必须显式传 mtime=0,并在测试里断言 header[4:8] 全零,而不是只连跑两次比哈希。第二,不要用 gzip.open(path,'wb') 生成需要发布的产物,它会把 basename 写进 FNAME;需要纯净头就手写头部加裸 DEFLATE。第三,把压缩级别、库版本和操作系统字节一起记录进构建元数据,因为这三者任一变化都会改变字节流而不改变内容。第四,验证脚本里一定要有一条“解压后逐字节相等”的正向断言,用来区分“不可复现”和“已损坏”。
本次未验证的边界
以上只在 Linux 上的 Python gzip 模块做了实测,没有覆盖 zlib 的 Z_FIXED 策略、并发多线程压缩,也没有覆盖 pigz、zstd 或其他实现之间的对比;不同实现对 XFL 与 OS 字节的取值并不一致,不能据本文结论外推。另外,去掉 FNAME 只解决产物头部的信息泄露,不解决压缩数据本身可能残留的内容特征,敏感材料仍应先脱敏再压缩。以上断言都可以在我本地的隔离目录中复现,代码与输出均来自真实执行,未做任何结果修饰。
- 点赞
- 收藏
- 关注作者
评论(0)