返回 2026-07-10
🛠 工具 / 开源

拆箱:ZigUnboxed: Zig

nesbitt.io·2026-07-09

文章深入分析了 Zig 的包管理器,涵盖四个核心维度:机制原理、分类方式、治理结构以及威胁模型。从包管理器的技术实现到安全治理,全面审视了 Zig 生态系统的包管理设计。

Andrew Nesbitt

这是系列文章的第一篇,该系列将基于一组固定的标题逐一分析各个包管理器,以便直接进行对比。这些标题源自早前的文章:客户端与注册中心分类、治理文章以及威胁模型。

自 2023 年 8 月发布的 0.11 版本起,Zig 的包管理器已内置于 zig 二进制程序中,无需单独的工具,也没有中央注册中心。build.zig.zon 文件以带有内容哈希的 URL 形式列出依赖项,而 zig build 会获取所有内容并将其编译在一起。该语言和工具均由 Zig Software Foundation(一家 501(c)(3) 非营利组织)运营。

工作原理

Zig 项目根目录下有一个 build.zig 程序,用于描述目标、编译标志以及各依赖项的链接位置,同时还有一个 build.zig.zon 数据文件用于列出元数据和依赖项。尽管名称相似,build.zig.zon 是无需运行任何代码即可解析的静态数据,而 build.zig 则是会被编译并执行的任意 Zig 代码。某项操作具体触及这两者中的哪一个,决定了其大部分的安全特性。

.{
    .name = .example,
    .version = "0.3.1",
    .fingerprint = 0x6a8091f57c7f07ff,
    .minimum_zig_version = "0.16.0",
    .dependencies = .{
        .known_folders = .{
            .url = "https://github.com/ziglibs/known-folders/archive/refs/tags/1.1.0.tar.gz",
            .hash = "known_folders-1.1.0-Fy-PJtnVAAC1Qq48Hf6_4er0Ku98mFvx99UUwo9-mrJd",
        },
        .tracy = .{
            .url = "git+https://github.com/wolfpld/tracy#v0.11.1",
            .hash = "N-V-__8AAKw3UgOhKDsrn8hRlOoGmVBl5x91fMi0WQwVaokf",
            .lazy = true,
        },
    },
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
        "LICENSE",
    },
}

.zon 扩展名代表 Zig Object Notation,它是 Zig 语法的一个子集,仅限于字面量和匿名结构体,不包含表达式、导入或函数调用。包指纹是一个 64 位整数,由一个随机的 32 位 ID 与名称校验和组合而成,在创建包时生成一次。即使经历重命名或仓库迁移,该指纹也保持不变,因此它是独立于当前提供服务的 URL 的包标识。

每个依赖项要么是一对 .url 和 .hash,要么是一个本地 .path。哈希值是在应用依赖项自身清单中的 .paths 包含列表后,对解压出的文件计算得出的,因此包作者可以决定哪些文件计入其标识。文档明确指出哈希即标识,而 URL 只是获取它的途径之一:“包并非来自 url,而是来自哈希。url 只是众多可能的镜像之一。”

哈希字符串以 $name-$version- 开头,后跟 URL 安全的 base64 编码,其中封装了指纹 ID、解压后大小以及 200 位截断的 SHA-256,这样 zig-pkg/ 目录列表无需查找表也能保持可读性。像上文的 tracy 这样没有自身 build.zig.zon 的依赖项被称为“裸”包,会使用占位符 N-V- 和哨兵 ID 代替。.lazy = true 会将获取操作推迟到该依赖项在 build.zig 中被引用时。

获取依赖项的过程是:下载 URL,根据锁定的哈希值进行验证,解压到以该哈希命名的项目本地 zig-pkg/ 目录中,然后递归处理该依赖项自身的 .zon。全局缓存中会保留一份标准化的重新压缩的 tarball,这样第二个使用相同哈希的项目就无需重新下载。单独运行 zig fetch <url> 会下载单个 URL 并打印计算出的哈希值以便粘贴到清单中,此过程不会递归。支持的协议方案包括:用于 tarball 和 git bundle 的 http(s)://,用于在片段中带有 ref 的 git smart protocol 的 git+http(s)://,以及 file:// 和裸路径。

zig build 会将 build.zig 与每个依赖项的 build.zig 一起编译成单个可执行文件并运行它,因此传递依赖项的构建代码会被链接到与你的构建代码相同的程序中,而不是按每个包作为独立进程来调用。在你的构建脚本中调用某个依赖项会运行该依赖项的构建脚本,而该脚本同样可以对其自身的依赖项执行相同操作。

树中的每个 .zon 通过内容哈希锁定其直接依赖,因此没有版本解析步骤,一旦根的哈希固定,整个依赖树就确定了。锁定同一上游不同哈希的两个依赖都会被获取并共存,仅通过哈希进行去重。计划使用 fingerprint 字段进行版本选择,预计类似于 Go 的最小版本选择,但在 0.16 版本中,唯一的跨树检查是相同的 fingerprint 和版本不能对应两个不同的哈希。

0.14 引入了当前的哈希格式和 fingerprint 字段。0.16 将提取的包从全局缓存移动到项目本地的 zig-pkg/ 目录,并添加了 --fork 用于在整个依赖树中覆盖某个包。6 月 30 日,获取逻辑、HTTP 客户端、TLS 和 git 协议从编译器二进制文件中移出,转移到构建系统进程中,并以源码形式发布。

分类

客户端

解析算法。无:每个清单锁定确切的内容哈希,没有版本范围。最接近显式依赖分类,与 Nix 和 Guix 类似,尽管底层没有内容寻址存储,且计划实现最小版本选择。

锁文件。清单即锁文件:build.zig.zon 为每个直接依赖锁定内容哈希,每个依赖也对其自身的依赖做同样的事,没有单独的文件。不能完美契合任何现有分类:像 Go 一样无需锁文件即可复现,但它是通过在清单中锁定哈希,而不是通过确定性解析实现的。

构建钩子。允许:build.zig 是一个在构建时编译并运行的 Zig 程序,每个依赖的 build.zig 都编译进同一个进程,类似于 Cargo 的 build.rs、Gradle 或 Swift 的 Package.swift。

星期二测试。失败,即安装过程可以观察到清单未声明的输入:build.zig 是图灵完备的,并为每个依赖运行,尽管 .zon 本身是纯数据,单独测试会通过。

清单格式。元数据使用自定义格式(.zon),构建逻辑使用宿主语言(build.zig),而分类中的大多数工具将两者放在同一个文件中。由于是定制格式而非 JSON 或 TOML,.zon 在每个读取清单的跨生态系统工具中都需要自己的解析器,包括 ecosyste.ms 和 git-pkgs。

注册表

架构。源码主机作为注册表,与 Go modules、Deno 和 Carthage 类似,没有中央索引,包以 tarball 或 git ref 的形式在作者选择的任意 URL 上提供。

审查模型。无:谁控制 URL,谁就控制该 URL 上提供的内容。

命名空间。基于 URL 进行定位,但身份标识是名称加上自动生成的 64 位 fingerprint,这与命名空间分类中的任何类别都不匹配。

分发模型。仅源码,在客户端编译。

生态系统范围。特定于语言,尽管 Zig 的 C 互操作性和内置的 zig cc 意味着 C 和 C++ 库通常也被打包为 Zig 依赖。

版本保留。委托给源码主机,因此 GitHub release 会一直可用,直到仓库所有者删除它或账户消失,并且没有撤回机制。

规模。没有中央索引,因此没有权威的计数,尽管 Zigistry 会抓取 GitHub 上带有 build.zig.zon 的仓库并为其发现的内容建立索引。

镜像。原则上很简单,因为客户端接受任何 URL 并通过哈希进行验证,尽管每个 .zon 为每个依赖指定一个 URL,所以如果该 URL 失效,依赖者必须编辑其清单。

治理

Zig 软件基金会负责管理该语言和工具,其资金来自捐赠,但包托管和命名权不在其职权范围内,因为它没有可供运营的注册中心。

名称所有权依赖于指纹,清单文档指出,维护中项目的分支应重新生成指纹,并将保留上游值的分支称为“恶意的,试图夺取原项目身份的控制权”。没有任何机制强制执行这一点,也没有仲裁者可以申诉。可用性、账户恢复、DMCA 下架和滥用处理都由 URL 指向的源代码托管方负责,而在实践中这绝大多数情况下是 GitHub。移除恶意版本只能由仓库所有者或源代码托管方完成,而且双方都无法向已经固定其哈希值的下游 .zon 文件发出信号。包管理器本身的更改需通过 Zig issue 跟踪器进行。

ZSF 的 Loris Cro 在 2022 年写道:“我们不打算创建官方包索引”,因此缺乏治理提供者是一种设计选择,而不是缺陷。

对比

最接近的现有设计是 Go modules 和 JSR 之前的 Deno(当时它直接通过 URL 导入依赖):基于 URL 寻址的源代码,没有发布步骤,没有中央注册中心,通过内容哈希保证完整性。Go 的 go.sum 和校验和数据库是最接近 .zon 哈希的等价物,尽管 Go 是根据公共日志进行验证,而不仅仅是依赖项目提交的内容。Swift Package Manager 在客户端方面很接近,具有宿主语言构建清单和 git-URL 依赖项,但它是根据标签解析版本范围,而不是固定单个哈希。Bazel 的 http_archive 接受一个 URL 列表加上一个 sha256,以哈希作为标识,URL 作为可互换的镜像,然后评估一个封闭的 Starlark BUILD 文件,而不是图灵完备的构建程序。

指纹是上述设计都没有的:一种在 URL 更改后依然存在的包标识,而在 Go 中,标识是模块路径,移动仓库会创建一个新模块。--fork 标志使用它将整个依赖树中每次出现的包替换为本地检出,而不管中间的 .zon 文件固定了哪些 URL。它只是一个 CLI 参数,而不是清单字段,发行说明将其称为“适当地短暂”。

没有等效于 Cargo 的 [patch]、Go 的 replace、npm 的 overrides 或 pub 的 dependency_overrides 的 build.zig.zon 字段,也没有用于直接依赖项的 update 子命令。

由于在每个级别都有精确的哈希固定且没有需要重新解析的范围,底层依赖项中的修复只有在路径上的每个中间 .zon 都使用新哈希重新发布后才能到达项目。在此之前,根项目的选项是分支中间件、将打过补丁的 zig-pkg/ 供应商化到源代码控制中,或者在每次构建时传递 --fork。该标志不会被记录在任何地方,因此在提交的清单本来是一个完整锁定的设计中,同一个提交会根据是否传递了该标志而构建出不同的树。一旦最小版本选择落地,在根 .zon 中列出修复版本就足够了,就像在 go.mod 中一样。

.paths 过滤器类似于 package.json 中的 files 数组或 Cargo.toml 中的 include,不同之处在于哈希是在应用过滤器后计算的,而不是在作者上传的 tarball 上计算的,因此包含列表是标识的一部分,而不是打包的便利手段。

威胁模型

客户端

安装时的代码执行。zig build --fetch 不会运行包中的任何内容:它下载每个 URL,验证哈希值,提取并解析每个依赖项的 .zon 作为数据,然后递归执行,而不编译 build.zig。zig build 会将你的 build.zig 和每个依赖项的 build.zig 编译成一个可执行文件并运行它,因此依赖项的构建脚本在你首次构建时会以你的权限执行。没有标志可以禁用此功能,因为构建脚本是依赖项暴露其构件(artifacts)的方式。.lazy = true 的依赖项不会被获取,其 build.zig 也不会被编译,直到为其调用 b.lazyDependency。

安装前的代码执行。由于 .zon 是数据,解析清单并在不受信任的 URL 上运行 zig fetch 除了提取器外都是安全的,但在不受信任的检出(checkout)上运行 zig build 会编译并运行 build.zig,zig build -h 和 --list-steps 也是如此,因为步骤列表来自配置阶段的执行。

设计上的锁文件保证。清单为每个直接依赖项固定了 200 位的 SHA-256 哈希,而每个依赖项又固定了各自的哈希,因此整个依赖树由内容固定,每次安装都是锁定安装。该保证是针对每个项目的,并从首次获取开始:哈希保护项目免受 URL 内容后续更改的影响,但没有独立记录表明在首次写入哈希时 URL 提供了什么内容。Go 的校验和数据库是一个反例,它是一个公共日志,记录了每个模块路径提供的内容,因此被替换的首次获取会与其他人的记录不一致。如果没有等效机制,两个 Zig 项目相隔一周添加相同的 URL 可能会固定不同的哈希,而没有任何机制标记这种差异。普通的 http:// URL 被接受且没有警告,并且已经解包的 zig-pkg/<hash>/ 目录在重用前不会重新计算哈希。

包名标识。name 是一个纯粹的 Zig 标识符,最多 32 字节,区分大小写,仅限 ASCII,因此通常的规范化缺口不适用。去重和 --fork 匹配基于名称加指纹,因此共享名称但具有不同指纹的两个包可以在同一依赖树中共存。

跨多源的解析。每个依赖项条目仅指定一个 URL,因此不存在会出错的源排序,依赖混淆模式也不适用。相关的故障模式是 URL 失效:.zon 记录了作者获取内容的来源,如果该主机消失,则没有自动回退,即使任何提供相同字节的镜像都能满足哈希要求。

注册表

命名空间分配。指纹是自我声明而非分配的,因此分支(fork)可以将上游的值原样写入其自己的清单,并被匹配为同一个包。URL 上的域名抢注(typosquatting)是源主机的问题。

维护者生命周期。维护权即对源仓库的控制权,因此添加维护者意味着添加 GitHub 协作者,而不会向依赖树中拥有该包的任何人发出信号。账户恢复取决于源主机提供的机制,而 .url 中过期的自定义域名很容易被接管,只能通过现有依赖项清单中的哈希固定来缓解。

已发布版本的不可变性。依赖方的 .zon 文件中的哈希值保证了他们能获取锁定的字节,但无法保证其持续可用性,而且 URL 指向的标签或发布版本可以被替换为其他内容,新的依赖方随后会固定这些新内容。Git 标签可以被强制推送,GitHub 发布资产也可以被替换,并且没有透明度日志记录 URL 在特定时间提供了什么内容。

从源码到产物的溯源。产物即源码,因此没有需要证明的构建步骤。哈希值证明了你获取了依赖方固定的内容,但无法证明是谁编写的,也无法证明 URL 的仓库是否与 tarball 匹配,而且可信发布需要一个发布目的地。

最小可行的发布凭证。这由源码托管平台颁发:对于 GitHub,是一个具有仓库推送访问权限的 token 或 SSH 密钥,其作用域、过期时间和 2FA 由 GitHub 端控制,且没有单独的发布界面来保护 Zig 专用的凭证。

影响范围与检测。发布时的异常检测、客户端检查的恶意版本标记以及集中式审计日志都是注册表的功能,而这里没有任何机制能替代它们。被篡改的发布版本传播速度仅取决于依赖方更新其 .hash 字段的速度,这比使用浮动范围的注册表生态系统要慢,但也意味着一旦泄露,就没有单一的地方可以将其拉取下来,而检测工作则落在了那些在源码托管平台上阅读 diff 的人身上。

工具自身的供应链

Zig 编译器自身的 build.zig.zon 声明了零外部依赖,唯一的条目是指向源码树的 .path 引用。它所需的工具链组件被直接打包到发布版的 tarball 中,而不是去获取。自 6 月 30 日的更改以来,HTTP 客户端、TLS 栈、git 协议和解压缩器以 Zig 源码的形式提供,而不是编译进 zig 二进制文件中,开发日志指出它们在 ReleaseSafe 模式下运行,因此网络代码的安全检查保持开启。

总结

最接近的同类产品在注册表方面是 Go modules 和 JSR 之前的 Deno,在客户端方面是 Swift Package Manager,在获取模型方面是 Bazel 的 http_archive。将内容哈希视为身份,而 URL 只是获取它的一个位置,这是正确的方向,而且 .zon 作为纯数据意味着无需执行任何操作即可读取依赖图。编译器声明自身零外部依赖是我希望看到更多的供应链立场,将网络栈移出到可以在不重新构建编译器的情况下打补丁的 ReleaseSafe 源码中,是一个很好的近期更改。

.zon 作为一种定制格式而不是 JSON 或 TOML,意味着我不得不在 ecosyste.ms 和 git-pkgs 中为它编写解析器,其他所有构建跨生态系统工具的人也必须如此。该工具最终未能通过“星期二测试”,因为 build.zig 是一个完整的 Zig 程序,而纯数据的 .zon 本可以让它几乎通过测试。注册表本应提供的治理功能落到了 GitHub 身上,它现在成了 Zig 包可用性、账户恢复和滥用处理的仲裁者,而双方都没有主动选择这种局面。

清单中的任何内容都无法覆盖传递依赖,因此,如果底层包发布了安全修复,而中间包尚未重新发布,可选的方案要么是 fork 这个中间包,要么在每次构建时传递 --fork 参数,而且这个标志不会在仓库中留下任何记录来表明它被传递过。Cargo、Go、npm、pub、Mix 和 Bazel 都为此提供了清单字段,而精确哈希锁定使得这种缺失在这里比在使用版本范围时影响更大。

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