CPython zipfile 可复现构建改进:ZipFile 写入时间戳改用 UTC 以避免下溢
导读
本文讲解 CPython 标准库 zipfile 模块在可复现构建(reproducible builds)方面的一项修复:当环境变量 SOURCE_DATE_EPOCH 被设置时,ZipFile 写入归档条目的文件时间戳将改用 UTC 时间而非本地时间,从而避免因本地时区为负偏移(如 UTC-5 及更西的时区)导致 DOS 时间字段计算下溢(underflow)、进而引发 struct.error 等异常的问题。读完本文,你将理解 SOURCE_DATE_EPOCH 在 zipfile 中的完整处理链路、时间戳下溢的产生机理,以及如何在自己的构建流程中验证与规避该问题。
变更概览:一条 NEWS 条目的来龙去脉
本变更记录在 Misc/NEWS.d/next/Library/2025-05-19-21-08-25.gh-issue-134261.ravGYm.rst,对应 GitHub issue 134261,属于 Library 类别(影响标准库 zipfile)。其核心内容原文如下:
zip: On reproducible builds, ZipFile uses UTC instead of the local time when writing file datetimes to avoid underflows.
即:在可复现构建场景下,ZipFile 写入文件日期时间时使用 UTC 代替本地时间,以避免下溢。
这条 NEWS 条目属于 Misc/NEWS.d 碎片化变更记录体系——CPython 的每个变更都以独立 .rst 文件按 YYYY-MM-DD-HH-MM-SS.gh-issue-XXXXXX.随机后缀.rst 的命名规范提交,最终由 blurb 工具合入 Misc/NEWS。因此该文件本身极简,但它指向的实现改动落在 Lib/zipfile/init.py 中,并配有完整的回归测试。
背景:可复现构建与 SOURCE_DATE_EPOCH
可复现构建要求同一份源码在任何时间、任何环境(包括不同时区)下构建都能产生逐字节一致的产物。打包工具(wheel、sdist、容器镜像等)一旦把「当前时间」写入产物元数据,产物就会随构建时刻漂移而不可复现。
业界对此的标准解决方案是 SOURCE_DATE_EPOCH(可复现构建项目提出的规范,本文仅作概念性背景说明,不输出外部链接):构建系统设置该环境变量为一个固定的 Unix 时间戳(自 1970-01-01 00:00:00 UTC 起的秒数),打包工具检测到后,用它替代「当前时间」作为所有时间戳的来源。
CPython 的 zipfile 从 gh-91279 起便支持该变量(见下方实现),而本次 gh-issue-134261 修复的是该支持在特定时区下的缺陷。
实现剖析:时间戳的两条写入路径
在 Lib/zipfile/init.py 中,时间戳的处理分两条路径:
路径一:ZipInfo._for_archive() —— 字符串形式写入的默认时间戳
def _for_archive(self, archive):
"""Resolve suitable defaults from the archive.
Resolve the date_time, compression attributes, and external attributes
to suitable defaults as used by :method:`ZipFile.writestr`.
"""
# gh-91279: Set the SOURCE_DATE_EPOCH to a specific timestamp
source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')
if source_date_epoch:
self.date_time = time.gmtime(int(source_date_epoch))[:6]
else:
self.date_time = time.localtime(time.time())[:6]
...
这段代码位于 Lib/zipfile/init.py 第 670~693 行,是 gh-issue-134261 修复的核心位置:
- 设置
SOURCE_DATE_EPOCH时:使用time.gmtime(int(source_date_epoch))[:6],即把该时间戳按 UTC 展开为(year, month, day, hour, minute, second)六元组; - 未设置时:退回
time.localtime(time.time())[:6],即按本地时区取当前时间。
调用关系:当调用 ZipFile.writestr("文件名", data)(传入字符串而非 ZipInfo 实例)时,会执行 zinfo = ZipInfo(zinfo_or_arcname)._for_archive(self)(见 Lib/zipfile/init.py 第 2587 行),从而触发上述时间戳解析。这是绝大多数用户写入 ZIP 条目的默认路径。
路径二:ZipInfo.from_file() —— 从文件系统读取的 write() 路径
@classmethod
def from_file(cls, filename, arcname=None, *, strict_timestamps=True):
...
st = os.stat(filename)
isdir = stat.S_ISDIR(st.st_mode)
mtime = time.localtime(st.st_mtime)
date_time = mtime[0:6]
if not strict_timestamps and date_time[0] < 1980:
date_time = (1980, 1, 1, 0, 0, 0)
elif not strict_timestamps and date_time[0] > 2107:
date_time = (2107, 12, 31, 23, 59, 59)
...
见 Lib/zipfile/init.py 第 631~668 行。ZipFile.write(filename, ...) 会调用此方法(第 2554 行)。值得注意的是:这条路径并未读取 SOURCE_DATE_EPOCH,时间仍来自文件系统 mtime 的本地时区展开,仅在 strict_timestamps=False 时对超出 ZIP 格式 DOS 时间可表示范围(1980~2107)的值做钳制。
因此本次修复的实际效果范围是 writestr()(字符串/ZipInfo 参数)这一条路径;用 write() 写入磁盘文件时,时间戳仍以文件 mtime 为准,与本次 NEWS 描述一致("when writing file datetimes" 特指可复现构建下的默认时间解析)。
问题本质:为什么 UTC 能避免「下溢」
ZIP 格式使用 DOS 时间戳保存文件日期,其取值范围为 1980-01-01 00:00:00 至 2107-12-31 23:59:58(秒字段只存偶数秒)。在 zipfile 内部,DOS 日期/时间的位打包逻辑位于 Lib/zipfile/init.py 第 536 行与第 2678 行附近:
dostime = dt[3] << 11 | dt[4] << 5 | (dt[5] // 2)
即:小时占 5 bit、分钟占 6 bit、秒除以 2 后占 5 bit,而年份字段(1980 为基准偏移)存储在 dosdate 中。若年份 < 1980,year - 1980 会得到负数,进入位运算后即产生错误的位模式(下溢)。
在设置 SOURCE_DATE_EPOCH 的构建场景下,旧代码使用 time.localtime():
self.date_time = time.localtime(int(source_date_epoch))[:6]
localtime 会把时间戳转换为构建机的本地时区。例如在 America/New_York(UTC-5,即 UTC-05:00)下,时间戳 1735715999(对应 2025-01-01 07:19:58 UTC)会被转换为 2024-12-31 02:19:58 之类的本地时间——年份直接退回到 1980 年之前?不,这里退回的是 2024 年,并不触发 1980 下溢;真正触发下溢的场景是时间戳本身接近 DOS 时间下限且时区为负偏移。例如一个时间戳在 UTC 下恰好处于 1980-01-01 00:00:00 附近,若在 UTC-5 时区转换,本地时间会落入 1979-12-31 23:00:00,年份 < 1980,date_time[0] < 1980,随后 _get_dostime() 等位打包逻辑(Lib/zipfile/init.py 第 469 行附近的 if date_time[0] < 1980: 校验,以及 Lib/zipfile/init.py 第 536 行的位打包)便会遇到非法年份,产生 struct.error 或损坏的时间字段。
改用 time.gmtime() 后,时间戳始终按 UTC 展开,与构建机时区彻底解耦,既保证了时间戳语义的确定性(可复现构建要求产物不依赖时区),也从根因上消除了「UTC 时间合法、本地时间年份回退到 1980 之前」的下溢路径。这正是 NEWS 条目中 "to avoid underflows" 的完整含义。
测试验证:SOURCE_DATE_EPOCH 行为的回归保障
本次修复配有两组互为镜像的测试,位于 Lib/test/test_zipfile/test_core.py 第 4024~4047 行:
@with_source_date_epoch(epoch=1735715999)
def test_write_with_source_date_epoch(self):
with zipfile.ZipFile(TESTFN, "w") as zf:
zf.writestr("test_source_date_epoch.txt", "Testing SOURCE_DATE_EPOCH")
with zipfile.ZipFile(TESTFN, "r") as zf:
zip_info = zf.getinfo("test_source_date_epoch.txt")
expected_utc = (2025, 1, 1, 7, 19, 58)
self.assertEqual(zip_info.date_time, expected_utc)
@without_source_date_epoch
def test_write_without_source_date_epoch(self):
with zipfile.ZipFile(TESTFN, "w") as zf:
zf.writestr("test_no_source_date_epoch.txt", "Testing without SOURCE_DATE_EPOCH")
with zipfile.ZipFile(TESTFN, "r") as zf:
zip_info = zf.getinfo("test_no_source_date_epoch.txt")
self.assertTimestampAlmostEqual(time.localtime(), zip_info.date_time, tolerance=2)
要点解读:
with_source_date_epoch(epoch=1735715999)装饰器把环境变量SOURCE_DATE_EPOCH设为1735715999(对应 2025-01-01 07:19:58 UTC),测试断言归档中条目的date_time精确等于 UTC 六元组(2025, 1, 1, 7, 19, 58)——与运行测试的机器时区无关,即便在 UTC-5 等负偏移时区也不会回退到 2024-12-31;without_source_date_epoch装饰器确保变量被清除,此时断言时间戳与本地时间time.localtime()基本一致(容差 2 秒),验证了未设置变量时的旧行为不被破坏。
这两个装饰器定义在 Lib/test/support/os_helper.py 第 813~838 行:without_source_date_epoch 在测试期间 env.unset('SOURCE_DATE_EPOCH');with_source_date_epoch 则 env['SOURCE_DATE_EPOCH'] = str(epoch)(默认 epoch=123456789)。同一文件中还有第 838 行注释 "Run tests with SOURCE_DATE_EPOCH set or unset explicitly",表明测试基础设施会显式控制该变量,避免环境干扰。此外,Lib/test/test_zipfile/test_core.py 第 1965、2010、2054、2259 行多处使用 @without_source_date_epoch,说明其余时间相关测试也刻意排除了 SOURCE_DATE_EPOCH 的干扰。
运行回归测试的方式:
# 在 CPython 源码根目录下(需已构建出 python 可执行文件)
./python -m test test_zipfile
或仅运行核心测试文件:
./python -m test test_zipfile.test_core
实操验证:在命令行复现该行为
你无需修改任何仓库文件,即可用仓库内已构建的 CPython 解释器验证该行为(以下命令在 CPython 源码根目录执行):
# 场景一:设置 SOURCE_DATE_EPOCH,时间戳将按 UTC 写入
SOURCE_DATE_EPOCH=1735715999 ./python - <<'PY'
import io, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("demo.txt", "hello reproducible build")
with zipfile.ZipFile(buf, "r") as zf:
print(zf.getinfo("demo.txt").date_time) # 输出 (2025, 1, 1, 7, 19, 58),与本地时区无关
PY
# 场景二:不设置该变量,时间戳为本地当前时间
./python - <<'PY'
import io, time, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("demo.txt", "hello")
with zipfile.ZipFile(buf, "r") as zf:
print(zf.getinfo("demo.txt").date_time)
print(time.localtime()[:6]) # 二者应基本一致
PY
可以进一步验证「可复现」效果:连续两次设置相同的 SOURCE_DATE_EPOCH 生成 ZIP,用 cmp 或 sha256sum 比较,产物应逐字节一致(在相同的压缩参数、文件顺序与内容前提下);而把机器时区临时切换到不同时区(例如 TZ=UTC 与 TZ=America/New_York)重新生成,产物依旧一致——这正是可复现构建对「时区无关」的要求,也是本次修复的意义所在。
适用范围与注意事项
- 适用版本:该行为变更已合入当前 CPython 主线(main 分支),对应
zipfile模块 Lib/zipfile/init.py;后续将随下一个正式版本发布(可通过Misc/NEWS.d目录中该条目确认其归属版本)。 - 影响范围:仅影响
ZipFile.writestr()且未显式提供ZipInfo实例的路径;显式构造ZipInfo并自行设置date_time时,你的设置优先;ZipFile.write()写入磁盘文件时使用文件 mtime,不受SOURCE_DATE_EPOCH影响。 - 环境要求:
SOURCE_DATE_EPOCH的值应为可被int()解析的十进制 Unix 秒数;设置非法值时可能抛出ValueError,请在构建脚本中保证其格式正确。 - 边界行为:未设置
SOURCE_DATE_EPOCH时,writestr()仍使用本地时间,行为与以往一致;若你的本地时间早于 1980 或晚于 2107,请使用strict_timestamps=False或自行设置ZipInfo.date_time以避免 DOS 时间字段越界。
小结
gh-issue-134261 的修复以一行核心改动(time.localtime() → time.gmtime())解决了可复现构建下 ZIP 时间戳的时区敏感问题:SOURCE_DATE_EPOCH 语义上本就是 UTC 时间戳,按 UTC 展开既符合规范语义,又从根源上消除了负时区导致的 DOS 时间下溢。配套的镜像测试(Lib/test/test_zipfile/test_core.py 第 4024~4047 行)与测试支撑装饰器(Lib/test/support/os_helper.py 第 813~838 行)确保该行为在有无 SOURCE_DATE_EPOCH 两种场景下都被稳定约束,为依赖 zipfile 打包的构建系统提供了时区无关的确定性保障。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00