可复现构建的含义是:另一方依据声明的源码、构建步骤和相关环境,能够逐字节重建指定产物。这个承诺必须说明比较哪些输出;如果交付的可执行文件不同,只比较目标文件相同是不够的。复现结果为源码与分发内容之间的联系提供证据,而源码审阅、来源证明和签名分别回答其他问题。两次相同的恶意构建仍然有害,被污染的工具链也可能稳定复现自己的异常行为。
记录真正影响字节的输入
源码版本与编译器版本只是输入的一部分。依赖、目标架构、SDK、链接器、参数、文件遍历顺序、目录路径、时间戳、区域设置和打包工具,都可能进入最终结果。应固定实际相关的输入,并说明比较边界。容器镜像有助于描述环境,却不会自动消除网络下载、宿主机挂载或变化的时间戳。一个镜像标签也不必然代表永远不变的镜像内容。
时间宏和调试信息是常见差异来源。SOURCE_DATE_EPOCH 为支持该约定的工具提供稳定时间,并不会改写所有程序对当前时间的理解。编译器的前缀映射能够规范化嵌入的源文件路径;链接器还可能独立记录目标文件位置或其他元数据。因此,编译阶段消除了差异,仍应继续检查实际链接输出,不能在中间产物相同时就停止验证。
坏掉的样子
只比较挑选出的某个段,可能掩盖真正发布文件中的不同。应明确可复现性覆盖可执行文件、调试符号、压缩归档还是带签名安装包。如果签名过程加入会变化的数据,就说明并论证可复现负载的边界,同时单独校验签名后的分发包。还应记录 SDK 和链接器的构建标识;仅有编译器版本,无法重建另外两者的输入。不能悄悄删除难以处理的字节,再宣称原始文件完全相等;比较范围本身也是需要审阅的契约。
同一台机器上的两次摘要一致,是这些输入条件下本地重复构建的证据,不等于多个独立构建方已经验证,也不能证明源码与工具正确。反过来,摘要不一致首先意味着需要调查,不能立即断定发生了攻击。应定位差异来自哪个组件,再判断是不是有输入没有纳入环境声明。没有记录 SDK、链接参数或打包规则,可能就足以让相同源码生成不同文件。
快速验证
此前记录的实验使用 arm64 macOS、Apple clang 21.0.0 及其本机链接器;本轮没有重新执行这个平台专用构建。保存为 repro-build.py,安装命令行开发工具后运行 python3 repro-build.py。链接参数属于 Apple 工具链,不应假设 Linux 链接器也接受。脚本创建两个临时构建目录,用三种处理方式编译同一小段 C 程序;目录名差异是刻意保留的输入扰动。
from hashlib import sha256
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import subprocess
source = '#include <stdio.h>\nint main(void) { puts(__FILE__); puts(__DATE__ " " __TIME__); }\n'
with TemporaryDirectory() as directory:
base = Path(directory)
hashes = {mode: {'objects': [], 'executables': []}
for mode in ('unmapped', 'compiler-only', 'complete')}
output = []
for name in ('one', 'two'):
root = base / name
root.mkdir()
src = root / 'main.c'
src.write_text(source)
env = {'PATH': os.environ['PATH'], 'LC_ALL': 'C', 'SOURCE_DATE_EPOCH': '1700000000'}
for mode in hashes:
obj = root / (mode + '.o')
binary = root / mode
flags = [] if mode == 'unmapped' else [f'-ffile-prefix-map={root}=/src']
subprocess.run(['clang', '-g', '-O0', *flags, '-c', str(src), '-o', str(obj)],
cwd=root, env=env, check=True, capture_output=True)
os.utime(obj, (1700000000, 1700000000))
linker = ['-Wl,-reproducible']
if mode == 'complete':
linker.append(f'-Wl,-oso_prefix,{root}/')
subprocess.run(['clang', *linker, str(obj), '-o', str(binary)],
cwd=root, env=env, check=True, capture_output=True)
hashes[mode]['objects'].append(sha256(obj.read_bytes()).hexdigest())
hashes[mode]['executables'].append(sha256(binary.read_bytes()).hexdigest())
if mode == 'complete':
output.append(subprocess.check_output([str(binary)], text=True).splitlines())
equality = {mode: {kind: pair[0] == pair[1] for kind, pair in records.items()}
for mode, records in hashes.items()}
assert equality == {'unmapped': {'objects': False, 'executables': False},
'compiler-only': {'objects': True, 'executables': False},
'complete': {'objects': True, 'executables': True}}
assert output == [['/src/main.c', 'Nov 14 2023 22:13:20']] * 2
print(json.dumps({'sha256': hashes, 'equal_between_directories': equality,
'complete_program_output': output,
'same_host_only': True}))
所有处理都使用时间值 1700000000,并固定目标文件的修改时间。没有路径映射时,目标文件和可执行文件都不同。只做编译器映射后,目标文件摘要已经相等,最终可执行文件仍然不同:链接器调试映射中还保留着各自的目标文件路径。这个中间失败说明,编译阶段通过的检查不能直接为实际发布的二进制文件背书。
三种处理均已开启链接器的可复现模式;完整处理还从它记录的目标文件调试映射里去除构建目录前缀。此时两边的目标文件与可执行文件摘要都相等。执行完整处理后的程序,两次均输出 /src/main.c 和 Nov 14 2023 22:13:20。断言比较的是整个文件,包括生成的 Mach-O 元数据,而不是构建完成后再剪掉差异位置。
这些结果只覆盖本机的一个编译器、SDK、架构和简单程序,没有证明大型安装包、另一种工具链或独立签名发布也能复现。脚本打印每个摘要和相等判断,让中间与最终结果都可检查。读者在不同环境执行时,应先确认工具版本和参数语义,再讨论结果差异;不能把这里出现的某个摘要当成所有平台必须一致的常量。
定位差异后再扩大承诺
生产比较失败时,应保留两份产物及各自的构建输入记录,逐步定位归档、段和元数据中的差异,再控制相应输入并重新构建。调查过程中保持声明的输出集合不变,避免通过减少比较对象来制造成功。本地可重复之后,再由独立环境按照公开步骤重建并比较相应发布产物。这样增加的是另一方能够复现结果的证据,而不是用可复现性取代安全审阅或来源验证。
延伸阅读
来源
- Reproducible Builds: definitions,明确产物范围并逐字节重建的定义,已核验 2026-10-15。
- Reproducible Builds: build path,编译器和调试信息中嵌入路径的影响,已核验 2026-10-15。
- Reproducible Builds: SOURCE_DATE_EPOCH,由支持该约定的工具使用稳定时间戳,已核验 2026-10-15。
- Clang command-line reference,编译阶段的路径映射参数,已核验 2026-10-15。
- Apple ld64 options,可复现模式将调试映射的修改时间清零,已核验 2026-10-15。
- Apple ld64 manual,OSO 调试映射路径前缀移除,已核验 2026-10-15。