返回 2026-04-28
🛠 工具 / 开源

软件包安装的心理阶段模型The stages of package installation

nesbitt.io·2026-04-27

作者用心理学中的‘否认-愤怒-讨价还价-抑郁-接受’五阶段模型类比软件包安装过程中的常见情绪反应,并幽默地添加了‘postinstall’(安装后)作为第六阶段。这一创意框架生动描绘了开发者面对依赖冲突、版本不兼容或构建失败时的典型心理历程。虽然带有调侃意味,但也反映出开源生态中日益复杂的依赖管理确实给用户带来了显著认知负担。

Andrew Nesbitt

假设有人一直在用晚上时间从零开始重新实现几个包管理器的部分功能,目的并非发布任何东西,而是作为测试平台,方便替换不同的解析器、索引格式和注册表 API,看看实际会发生什么变化。

这样的人首先想要的是将安装命令清晰地分解为若干阶段,每个阶段都有明确的输入和输出,这样每个阶段都可以独立替换,并且能清楚看出哪些部分需要网络访问,哪些部分会运行不受信任的代码,哪些部分是纯粹基于已有数据的函数式处理。

1. 获取元数据

包管理器与一个或多个注册表通信,以发现有哪些包存在:包名、每个版本的可选列表,以及每个版本对其他包的依赖约束。这可能是一个通过单次请求拉取的大索引文件,比如 Packagist 的 packages.json、RubyGems 的紧凑索引,或 crates.io 的索引克隆;也可能是按需从 npm 注册表或 PyPI 的 JSON API 等来源获取的每个包对应的文档。

无论哪种方式,该阶段唯一需要的就是向已知主机集合发起出站网络请求,唯一产生的就是描述所有可能包的结构化数据。此处与安全相关的决策是向哪些注册表发出查询以及查询顺序,因为依赖混淆攻击正是通过让公共注册表对原本预期为内部名称的包作出响应来实现的。

2. 解析(Resolving)

解析器接收用户顶层需求以及第一阶段发现的约束条件,计算出同时满足所有约束的一组具体的 name@version 配对。根据生态系统不同,这可能是完整的 SAT 或 PubGrub 求解器,也可能是更简单的贪心遍历,或是 Go 的最小版本选择算法,但无论如何,它都是对内存中已有数据进行纯计算,其输出本质上就是一个锁文件,无论是否实际写入磁盘。在此模型中,解析过程无需网络,也不执行第三方代码。

3. 下载

在选定一组具体版本后,包管理器会下载实际的构件:tarball、zip、wheel、gem、crate、bottle 等。每个构件都应来自由元数据确定的 URL,并且应通过与元数据中的校验和比对来验证,从而确保下载阶段不会被外部因素引导至非预期的位置——前提是索引已被提前获取。实际上本地缓存通常已包含大部分内容,此阶段退化为对磁盘上内容的哈希检查。与第一阶段类似,它只需要网络访问,除此之外别无他需;而在缓存已预热的情况下,甚至都不需要网络。

4. 解压

下载的归档文件被提取并按语言运行时期望的布局放置在磁盘上:可能是扁平的 site-packages 目录、嵌套的 node_modules、使用符号链接指向项目的 ~/.pnpm 或 /nix/store 下的内容寻址存储结构,也可能是将 vendor 树签入仓库。这里涉及相当多的关于提升(hoisting)、去重和同级依赖(peer dependencies)的细节,但权限范围很小:只需目标目录的写权限,既不需要网络,也不需要 shell 环境。

这并没有阻止它成为 CVE 的可靠来源,因为归档格式的表达能力足够强,以至于提取一个归档文件更接近于解释一段小程序,而不是简单地复制文件。一个 tar 条目可以命名包含 ../../ 的路径,或者设置一个指向 ~/.ssh 的符号链接,然后在下一个条目中通过该链接写入文件。协议规定这里不会运行代码,严格来说确实没有运行任何代码,但归档文件仍然决定了字节数据在目标目录之外的具体位置。

5. 构建阶段

有些软件包以源代码形式发布,必须在与主机工具链匹配的环境中编译后才能使用,最常见的是用 C、C++ 或 Rust 编写的原生扩展,它们作为桥梁与主机语言交互。这是第一个预期会运行软件包内部代码的阶段,因为软件包需要告诉构建系统要编译什么以及如何编译,无论是通过 build.rs、setup.py、binding.gyp 还是 extconf.rb。理想情况下,构建过程只需要编译器和解压后的源代码树,不需要其他额外依赖。

6. 安装后阶段

软件包最后一次有机会执行自己的钩子程序来完成那些无法仅通过“将这些文件放置在此处并编译那些”来表达的工作:生成特定机器的配置、向某些主机设施注册自身、修补 shebang 行、打印资助信息。这是第二个预期会运行任意软件包代码的地方,与构建阶段一起,几乎所有“恶意软件包在安装时窃取凭据”的事件都发生在这里,这也是为什么存在 --ignore-scripts 及其等效选项的原因。模型仍倾向于这些钩子在无网络访问的情况下运行,因为到此时,软件包所需的所有内容应该已经存在于磁盘上。

按照这种方式划分权限边界足够清晰,便于强制执行。第一阶段和第三阶段需要网络访问,但从不执行软件包中的任何代码;第二阶段和第四阶段两者都不需要;只有第五和第六阶段会运行由软件包提供的代码,即使在这些阶段,也不应需要主动向外发起网络连接。

可以在第四阶段之后划清界限,将后续所有操作置于沙箱环境中;或者在管道的任何连接点进行裁剪,即可轻松实现离线安装、气隙镜像、预取后构建的 CI 缓存,以及“在此处解析,在彼处安装”的工作流程,例如在 Docker 构建中将下载与安装分离。

模型与实际实现之间的差异

目前几乎没有广泛使用的软件包管理器完全按照这种方式工作,而那些最接近这一设计的软件包管理器通常是在十多年逐步修复的过程中被“拖拽”到这个方向上的,而非从一开始就为此而设计。你最希望限制的两个特权——网络访问和任意代码执行——却泄漏到了模型认为不应存在的阶段。

解析阶段最常出现问题,因为解析器需要为它考虑的每个版本获取准确的依赖元数据,而这些元数据往往在没有真正执行任务的情况下无法获得。在 pip 的大部分历史中,确定 sdist 依赖项的唯一可靠方法就是下载它、解压它并运行其中的 setup.py——一个任意的 Python 程序,可以导入任何模块、检查主机环境,并在运行时计算其依赖列表。

第一至第五阶段坍缩为一个递归的复杂结构,解析器可能会执行数十个它最终会拒绝的不受信任的 setup.py 文件。Python 多年来一直在努力通过静态元数据标准和 wheel 格式摆脱这种困境,但由于仍然存在大量仅提供 sdist 的软件包,未经警告的冷启动 pip install 仍可能落入这种路径。

Ruby 也有类似但程度较轻的问题:.gemspec 是 Ruby 代码,而 Bundler 在解析 git 依赖时会克隆仓库并执行该文件以确定其依赖项,因此看似声明式的清单实际上是一个小型程序,拥有完整的语言能力。Homebrew 配方和 Portage ebuild 从未否认这一点:清单格式与宿主语言本就是同一事物,静态表示形式只是后来才添加的。我曾写过关于“星期二测试”的内容:如果一个包能根据星期几声明不同的依赖项,那就说明你无法在不运行它的情况下解析它。

即使在元数据真正静态的情况下,阶段一和二也几乎总是交错进行而非顺序执行。npm 的解析器(像大多数与大型注册表交互的工具一样)会在约束求解过程中按需获取每个包的元数据,因为一开始就拉取所有传递可达包的完整版本列表会非常浪费,所以整个解析过程网络始终处于活动状态。

Go 直接将版本控制视为注册表:无需发布任何内容,导入路径即为其位置,解析模块版本最终意味着从 git 标签中读取 go.mod 文件,这使得一个完整的 git 客户端及其所有传输和凭证机制被引入本应是元数据获取的过程。现在默认前置的模块代理更多是一个缓存和审计日志,而非真正意义上的注册表,即使设置 GOPROXY=direct,工具链仍会直接访问仓库。

构建和后安装阶段则反向泄漏:将网络访问带入原本应脱离网络的阶段。Cargo 的 build.rs 名义上是构建步骤,大多数仅探测系统库或生成代码,但也可能打开套接字下载预编译二进制文件或供应商头文件。

npm 生态系统严重依赖此机制:许多包含原生组件的包在 postinstall 中并不编译任何内容,而是从 GitHub release 下载与主机平台匹配的预构建工件,因此该包的“真正”下载发生在包管理器认为安装完成之后,来自锁文件中未见的 URL,且解析器也不知道其校验和。

node-gyp 会在首次运行时从 nodejs.org 获取 Node 头文件,各种 prebuild-install 风格的辅助工具也各自约定二进制文件的位置以及是否验证,形成隐藏在第一个依赖图之后的第二个未文档化的依赖关系图。

对工具链的影响

工具链作者最想了解的安装问题——它会通过网络接触哪些主机、在任何人检查之前会运行哪些代码、能否在不依赖公共互联网的情况下从镜像重现——在大多数生态系统中都没有明确答案。

你无法承诺在某个时刻后绝不拨号外出,因为三层深的 postinstall 可能从 S3 存储桶拉取二进制文件;也无法承诺在执行前不运行包代码,因为解析器可能已评估过清单。SBOM 生成器、供应链扫描器和沙箱包装器最终都只能从外部重构包管理器内部未提供的阶段边界。

大多数生态系统都在逐步将早期阶段推向静态化:通过声明式清单(无需运行时求值)、紧凑索引风格的注册表 API(仅提供依赖元数据而不传输完整构件)以及锁定文件(为包括安装后脚本原本会自行获取的内容在内的所有内容固定校验和)来实现。

我应该提一下 Nix 和 Guix,否则 Mastodon 上肯定会有人指出。其表达式语言求值会生成一组派生文件,每个文件都是按内容哈希、构建环境和构建命令记录的输入的静态快照,只有当这些文件存在后才会执行任何操作。构建过程在切断网络的沙箱中运行。若某个包需要下载内容,则必须将其定义为具有预先声明期望哈希的固定输出派生,从而将所有下载操作推回到模型所指定的阶段。由于存储库不可变且沙箱退出后不再执行任何操作,因此不存在安装后钩子。

剩下的漏洞出现在前端:表达式语言是一种真正的编程语言,而 import-from-derivation 允许求值触发构建以计算下一步要评估的内容,因此足够执着的包仍可能跨越解析与构建之间的边界。对于从语言注册表拉取的任何内容,Nix 构建步骤通常只是在沙箱内运行该语言自身的包管理器来完成重新打包。这些阶段被限制而非重新实现;保证来自沙箱切断网络并固定输入,而内部工具与其他地方使用的相同,模糊了它们的界限。

大多数包管理器并未将这些阶段暴露给其他工具调用。确实存在的集成点往往是偶然形成的:其他工具学会解析的锁定文件格式、它们学会遍历的 node_modules 或 site-packages 布局、恰好重定向缓存的环境变量,或是代理缓存介入的注册表协议。因此,所有上层工具都携带了对其所依赖包管理器的部分重新实现,需手动保持同步,一旦上游更改其从未承诺稳定的内容就会崩溃。

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