同一个文件两次 gzip 结果不同:mtime 与文件名字段

举报
yd_251540181 发表于 2026/11/05 06:42:12 2026/11/05
【摘要】 固定内容的文件连续压缩两次哈希却不同?问题不在压缩算法,而在 gzip 头的 mtime 与 FNAME 字段。本文给出本地实测的对照数据、可重复构建的检查清单,以及去掉文件名侧信道的方法。

同一个文件,两次 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 只解决产物头部的信息泄露,不解决压缩数据本身可能残留的内容特征,敏感材料仍应先脱敏再压缩。以上断言都可以在我本地的隔离目录中复现,代码与输出均来自真实执行,未做任何结果修饰。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。