返回 2026-07-08
⚙️ 工程

包管理器中的内容寻址Content addressing in package managers

nesbitt.io·2026-07-07

包管理器中的内容寻址机制正逐渐成为解决依赖与版本控制问题的关键方案。人类使用易读的名称来识别包,而系统底层则应完全依赖哈希值来确保内容的唯一性与不可篡改性。这种分离策略不仅提高了软件分发的安全性,还能有效避免因命名冲突或版本更新引发的缓存问题。将哈希作为内容的绝对标识是构建可靠包管理生态的核心。

Andrew Nesbitt

内容寻址通过数据内容的加密哈希来标识数据,而不是通过名称或位置。相同字节的两个副本无论来源如何,都会获得相同的标识符;只要改变一个比特,就会产生完全不同的标识符。而且,由于标识符是从数据本身派生出来的,它同时兼具查找键和完整性校验的功能。

我在包管理领域经常遇到内容寻址,它通常用于解决某一层的问题,我也一直在收集它出现的场景。一个端到端采用内容寻址的包管理器,其注册表索引、包元数据、每个发布的构建产物以及其中的文件,都将全部通过哈希来命名,这样任何内容都可以从任意来源获取并在本地进行验证。这个想法从我关注该领域起就一直存在,下面提到的几个系统在这条路上已经走了很远,但还没有哪个系统能覆盖整个技术栈。本文是对目前现有技术片段的调研:探讨哪些内容被计算了哈希,以及每个哈希的具体用途。

被计算哈希的内容

包内的单个文件。pnpm 维护着一个共享的磁盘存储区(通常每个文件系统一个),其中每个已安装包的每个文件都只保留一份,并以内容哈希命名。每个项目的 node_modules 都是通过硬链接指向该存储区构建而成的,因此十个依赖相同版本 React 的项目会共享每个文件的同一个物理副本。OCI 注册表将每个镜像层存储为由其 sha256 摘要作为键的 blob,并在清单中通过摘要引用这些层。因此,推送一个基础层已存在于注册表中的镜像时,只会传输新的层。现在,通过 ORAS,相同的 blob 存储和清单格式可以承载 Helm charts、WebAssembly 模块和任意文件,这使得 OCI 注册表不仅是一个容器镜像仓库,更是一个通用的内容寻址包存储库。

Bazel 和 Buck 使用的远程执行 API 将构建的输入和输出存储在单个文件 blob 的内容寻址存储区中,而 ostree 为 Flatpak 和 rpm-ostree 底层的整个操作系统树执行了同样的操作。nix store optimise 命令会遍历现有的 Nix 存储区,将不相关包中的重复文件替换为指向单一物理副本的硬链接。

单个分发的构建产物。一个 tarball、gem、wheel、jar 或 crate 对应一个哈希值,这几乎被记录在所有的锁文件格式中:npm 和 Yarn 中采用 SRI 表示法的 integrity 字段、Cargo.lock 的 checksum、go.sum、composer.lock、mix.lock、poetry.lock、uv.lock、pubspec.lock,以及 Gemfile.lock 中 Bundler 的 CHECKSUMS 块。注册表在发布构建产物的同时也会发布相同的哈希值,这样没有任何先前状态的客户端也能有一个用于校验的参考:PyPI 的 #sha256= URL 片段和 PEP 691 哈希对象,Maven 的 .sha1 和 .sha256 附属文件,crates.io 索引的 cksum,npm 的 dist.integrity,Hex 的内外部校验和,以及 conda 在每个通道的 repodata.json 中针对每个包的 sha256。

这一层级同样涵盖了 Homebrew 中针对每个 bottle 的 sha256、Debian Packages 文件中为每个 .deb 列出的哈希值、<script> 标签上的子资源完整性(Subresource Integrity)属性,以及传递给 Nix 的 fetchurl 和其他固定输出推导(fixed-output derivations)的预期哈希值。OCI 镜像清单的摘要也属于这一层级,不同之处在于它通常被用作获取数据的键(例如 alpine@sha256:...),同时也用于在通过标签获取后进行校验。

已解析的依赖图。锁文件(lockfile)作为一个整体,记录了跨多个包选定的一种依赖解析方案:即一个项目在某一时刻解析出的确切制品(artifacts)集合。Nix 的 flake.lock 是最显式的形式,它以传递的方式记录了每个 flake 输入的内容哈希值。Cargo.lock、Gemfile.lock 或 package-lock.json 以各自的格式实现相同的功能,将其提交到 git 仓库后,就通过包含它的提交使得该解析方案本身成为了内容寻址(content-addressed)的,随后 npm ci、bundle install --frozen 和 cargo build --locked 会将其视为权威基准。

单个包的所有版本。在某一时刻对单个包名下完整的发布列表计算哈希值,相当于对该包的发布历史进行了快照。TUF 仓库可以将其表示为限定于单个包的委托目标文件(delegated targets file),列出每个版本及其制品哈希,而该文件自身的哈希则记录在其上层的快照元数据中。gittuf 将 git 引用所经历过的每种状态记录为一个已签名、哈希链式的引用状态日志,对于为发布打标签(tag)的仓库来说,这就是其已发布的每个版本的可验证历史。

Hackage 的只追加(append-only)01-index.tar 累积了每个包的条目,随着时间推移构建出相同的记录。crates.io 的 git 索引为每个 crate 保留一个文件,列出所有已发布的版本,因此该文件的 git blob 哈希值也是这样一种快照,尽管这实际上是索引存在于 git 仓库中所带来的副作用;但自从 sparse 协议在 1.70 版本成为 Cargo 的默认选项后,大多数客户端都通过 HTTP 获取每个 crate 的 JSON 数据,再也见不到 git 形式了。

注册表(registry)中的所有包。在某一时刻对注册表的完整索引计算哈希值,可以固定(pin)其中可用的包和版本的完整集合。Debian 的 Release 文件包含了针对每个组件和架构的 Packages 文件的哈希值,而 Packages 文件又列出了每个 .deb 文件的哈希值,从而形成了一个从单个已签名文件直至存档中每个字节的三级链条;Alpine 的 APKINDEX 和 RPM 的 repomd.xml 也遵循相同的模式。

将索引保存在 git 中的注册表直接从 git 本身获得这一特性:crates.io 索引、Homebrew tap、nixpkgs 或 Guix channel 的 HEAD 提交,就是其包含的每个包定义的内容寻址快照。TUF 通过 snapshot.json 和 timestamp.json 元数据来表达同样的概念,而 Hackage 则在其索引 tarball 之上叠加了 hackage-security 这一 TUF 实现。

关于制品的元数据。签名、证明(attestations)和来源文档(provenance documents)本身就是带有自身哈希值的数据,并且通常包含它们所描述对象的哈希值。最简单的形式是对单个对象进行身份验证的签名:Maven 的 .asc 文件是针对单个制品的分离式签名(detached signatures),Debian 的 Release.gpg 和内联的 InRelease 用于对仓库元数据进行签名,而 RPM 则在包头部(package header)内部携带签名和摘要。

元数据文档也可以通过哈希相互引用,形成它们自己的链,例如 TUF 的 timestamp 元数据用于认证当前的 snapshot 元数据,后者固定了每个 targets 元数据文件的版本及可选的哈希值,而 targets 元数据又记录了目标产物的哈希值,同时 root 元数据单独分发每个角色的可信密钥;OCI 镜像清单通过摘要引用其 config JSON 以及层摘要,而 go.sum 记录了每个模块 go.mod 文件的哈希值,独立于模块 zip 包。Sigstore bundles、npm provenance 和 PyPI PEP 740 attestations 则同时兼具这两部分特征,它们是对产物摘要的签名,随后这些签名本身又通过哈希记录在 Rekor 透明度日志中。

构建的输入。在任何输出产生之前,可以对构建配方(build recipe)及其依赖的所有内容计算哈希值,因此该标识符命名的是对工作的描述,并且在结果产生之前就是已知的。Nix 的输入寻址推导(input-addressed derivations)通过 .drv 文件的哈希值和每个输入的存储路径来计算存储路径,Guix 的工作方式与此相同。

Bazel 和 Buck 根据命令行、环境以及每个输入文件的内容哈希值来计算 action digest,并将其作为远程执行缓存的查找键;Docker BuildKit 根据每条指令加上它所能看到文件的哈希值来为其层缓存建立键;而 ccache 和 sccache 对编译器二进制文件、编译标志和预处理源码进行哈希处理,以决定某个翻译单元是否需要重新编译。Nix 的实验性内容寻址推导(content-addressed derivations)反转了这种关系,并根据构建输出的哈希值推导出存储路径,这使得哈希本身回到了单个产物的级别,同时在其外围保留了以输入为键的工作流。

Software Heritage 对归档的源代码同时跨越了上述大多数层级应用内容寻址:SWHID 可以命名单个文件 (swh:1:cnt:)、目录树 (swh:1:dir:)、修订版本 (swh:1:rev:)、发布版本 (swh:1:rel:) 或代码库中所有引用的快照 (swh:1:snp:),每一项都是基于其下一层级计算出的哈希值。该机制在设计上就与 git 兼容,因此 git commit 的 SHA-1 和对应的 swh:1:rev: 标识符会编码出相同的摘要。

内容相同,哈希不同

将归档文件作为一个不透明的 blob 进行哈希处理虽然简单,但会使结果对那些对内部代码毫无影响的字节变得敏感:gzip 压缩级别和操作系统标志、tar 成员顺序、内嵌的 mtime,甚至构建该文件的机器的时区,因此两个解压内容完全相同的归档文件可能会计算出不同的哈希值。2023 年初对 git archive 的 gzip 输出的一项更改,导致所有固定了 GitHub 源码 tarball 哈希值的工具全都失效,包括 Bazel rules、Homebrew formulae 和 Nix fixed-output fetchers,而对解压后的目录树进行哈希处理的使用者则未受影响;GitHub 在几天内撤销了这项更改。

一种解决方法是跳过容器,直接对文件树进行哈希处理:Go 的 dirhash 会计算每个文件内容的 SHA-256,对生成的“哈希 路径”行进行排序,并将该列表的哈希值记录为 go.sum 中的 h1: 值,因此 zip 的编码、压缩和单文件元数据都不会对其产生影响。仅针对 go.mod 计算的第二个 h1: 值,允许在不获取模块其余部分的情况下验证依赖项解析。Zig 的 .hash 是在解压后的目录树上计算的,该目录树会根据包自身的 .paths 包含列表进行过滤,因此归档编码不会产生影响,而是由作者决定哪些文件有效。

另一种答案是定义一种规范的归档格式并对其进行哈希:Nix 的 NAR 序列化会去除时间戳、规范化权限,并确定性地对条目进行排序,因此相同的目录树总是产生相同的归档字节。Debian 的可复现构建项目和 Python wheel 可复现性方面的工作从构建端着手解决相同的问题,使工具链能够针对相同的源码产生字节一致的输出。诸如 Nix 的 CA derivations 之类的输出寻址方案最能从正确处理此问题中获益,因为相同的输入产生不同的输出字节意味着不同的存储路径,并且独立构建之间无法重用缓存。

使用哪种哈希函数,以及标识符是否记录了该选择,决定了以后迁移的难度。上述大多数方案都在标识符中编码了算法或哈希方案版本:SRI 字符串带有 sha384- 或 sha512- 前缀,OCI 摘要写为 sha256:…,go.sum 行以 h1: 开头,而 IPFS CID 嵌入了一个 multihash 代码,该代码自我描述了哈希函数和摘要长度。SWHID 在前缀中记录了方案版本和对象类型,但目前将摘要本身固定为兼容 git 的 SHA-1。

Git 最初硬编码了 SHA-1,在对象格式中没有任何算法标记,而向 SHA-256 的过渡(于 2017 年提出)花费了近十年的设计和互操作性工作;现在可以创建 SHA-256 仓库,但 SHA-1 仍然是默认值,并且两种对象格式之间的互操作性仍然不完善。npm 较旧的 dist.shasum 字段是纯十六进制的 SHA-1,具有相同的局限性,这就是为什么 packuments 现在同时包含它和 SRI 格式的 dist.integrity 字段的原因。

哈希的用途

验证。将获取的字节与之前记录的哈希进行比较(无论是在解析时写入的 lockfile 中,还是在注册表提供的元数据中),以便在解包之前拒绝被篡改或损坏的下载。这是最广泛的用途,也是大多数 lockfile 格式实现的唯一用途,而且由于该检查位于获取之后,因此它可以(并且在多个客户端中已经)被设为可选或仅供参考。

寻址。将哈希用作客户端获取数据的键,因此请求会指明预期的内容,并且一旦收到错误的响应,无需参考其他任何内容即可检测到。通过摘要拉取 OCI 镜像和解析 IPFS CID 都属于这种情况。与验证不同,寻址很难设为可选,因为请求了某个哈希却接受不匹配字节的客户端,将无法获得它所请求的对象。

去重。将哈希用作存储或传输键,以便相同的内容只保存或发送一次。pnpm 的文件存储和 OCI 的 blob 层都以这种方式在包之间共享内容,前者在一台机器内,后者跨越注册表的每个客户端。

构建缓存。将对计算输入的哈希作为缓存键,因此如果构建步骤的输入与之前的运行相匹配,就可以直接采用该运行的输出,而无需重复工作。Nix derivations 和 Bazel actions 是大多数与包相关的工具借鉴的两种实现。

防篡改证明。将哈希作为节点放置在 Merkle 结构中,使得一个小型的可信根(例如签名树头或 TUF 时间戳文件)就足以验证它传递引用的所有内容。Go 的校验和数据库和 Debian 的 Release 链都允许不可信的镜像仅凭顶部的一个签名来提供完整的归档服务。

信任与传输

寻址和防篡改证据结合在一起带来的一个结果是,字节来源的问题可以与是否信任它们的问题分离开来:一旦你认可的授权方将某个名称绑定到哈希值,这些字节无论从哪里来,都可以在本地进行校验。Go 的模块系统就是围绕这种分离设计的,GOPROXY 可以自由指向任何公共或私有的代理或代理链,而 sum.golang.org 上的校验和数据库会独立确认获取到的数据与该模块版本在其他地方展示的数据是否一致。

Nix 可以从添加为替代服务器的任何二进制缓存中拉取,每个被替换的路径都会根据用户已信任的缓存密钥签名进行验证,而固定输出或内容寻址的路径还会额外对照路径本身编码的内容哈希进行检查。二十多年来,Debian 及其衍生发行版一直运行在第三方镜像上,这完全得益于每个套件中一个已签名的 Release 文件,该文件向下链式关联到存档中的每个 .deb 文件。固定到摘要的 OCI 拉取操作可以由任何注册中心镜像或穿透缓存提供服务,因为无论这些字节来自哪台主机,其清单和每一层都会根据它们的摘要进行校验。Zig 的清单文档直接阐述了这一原则,将 .url 称为 .hash 所标识内容的“众多可能的镜像之一”。

所有这些都假设从名称到哈希的绑定已经在某个受信任的环境中建立,因为内容寻址本身并不能决定一个名称应该指向哪个哈希。允许哪个账户发布 rails 8.0.0,以及该版本会解析为哪个制品,这是一个治理问题,由注册中心的账户系统和掌握凭证的维护者来解答,而哈希只能确认你收到的正是他们发布的内容。

针对软件包供应链的攻击往往针对这种绑定关系,而不是字节本身:账户接管和恶意的维护者权力交接会在现有的受信任名称下发布恶意制品,拼写抢注会注册一个极其相似以至于被混淆的名称,而依赖混淆则会让另一个注册中心在同名包的解析中胜出;在上述每种情况中,恶意制品的哈希值都会像其他正常制品一样被忠实地记录和传播。用 Zooko 的话来说,哈希是安全且全局一致的,但对人类来说却毫无意义;而名称是人类真正能够使用的部分,其可信度完全取决于控制它的人;将二者绑定起来,正是注册中心、维护者账户、签名和透明度日志所要承担的职责。

需要完整排版与评论请前往来源站点阅读。