返回 2026-06-30
⚙️ 工程

解绑标准库Unbundling the standard library

nesbitt.io·2026-06-29

文章探讨了现代编程语言中“标准库解绑”的发展趋势,即不再默认提供庞大的内置模块。传统上,编程语言通常会自带“电池”(batteries included)以提供开箱即用的全面功能,但现在的趋势是将这些模块分离,需要单独获取。这种转变旨在减少核心运行时的臃肿,提高灵活性,并允许各个组件进行独立更新。作者以幽默的口吻指出,曾经的标配现在变成了需要去特定“货架”单独购买的选配组件。

Andrew Nesbitt

我收到了一个指向 Dan Burton 的 composition 的拉取请求链接,这是一个微型的 Haskell 包,其最大的卖点就是不依赖任何东西,甚至连 base 都不依赖。即将发布的 GHC 10.2 打破了这一点:内置名称通过一个真实的模块 GHC.Essentials 来解析,而该模块位于 base 中,因此从 10.2 开始,每个包都会隐式带上 base 依赖,无论 cabal 文件中是否声明了它。该 PR 添加了一个标志来退出这种机制。Dan 关闭了这个 PR,并发布了像其他所有包一样依赖 base 的 2.0 版本,理由是“零依赖”的说法一直都是一种委婉的虚构,掩盖了编译器内置标识符的真实来源。

“零依赖”的说法始终是一个关于编译器与其标准库之间界限划分的问题,而在 GHC 10.2 中,这条界限移动了一个模块的距离。这促使我去梳理其他生态系统中的相同边界,但我没有预料到的是,这个答案竟然会如此深刻地改变漏洞通告的形式。

通告格式

我发现最清晰的案例是 Ruby Bug #20516:Ruby 3.3.2 在 CVE-2024-35176 的修复发布几周后才推出,却仍然捆绑了存在漏洞的 rexml 3.2.6,因为该修复是作为 gem 版本发布的,而 Ruby tarball 中的副本并没有相应地更新。针对该 CVE 的通告是提交给 gem rexml 的,但在 3.3.2 安装环境中存在漏洞的副本是由于 Ruby 发行版将其放置在那里,而不是因为任何项目解析了它,并且从 Gemfile.lock 生成的 SBOM 根本不会列出它;这种脱节的存在是因为 rexml 同时跨越了这条界限的两边。

七年前,在 Ruby 的 gem化扩展到 WEBrick 之前,CVE-2017-10784 是针对三个 Ruby 版本范围提交的,并通过三个协调的小版本发布进行了修复,通告文本写道:“所有运行受影响版本的用户应立即升级。”而 2024 年的 REXML CVE(CVE-2024-35176 以及今年另外五个)每一个都是针对带有 gem 版本范围的 pkg:gem/rexml 提交的,每一个都是通过 gem 版本发布修复的,用户无需等待 Ruby 的小版本发布即可应用,尽管在维护的每个 Ruby 分支上仍然需要更新其捆绑的副本。

Python 的 urllib.parse 中的 CVE-2023-24329 列出了涵盖 3.7 到 3.11 的五个独立的 CPython 版本范围,修复方式是发布了五个小版本;Node 的 http 解析器中的 CVE-2022-35256 涉及三个 Node 发布分支和三个小版本。Go 的漏洞数据库中有一个直接名为 stdlib 的伪模块,以便能够用 OSV 模式来表达工具链的 CVE,并且 govulncheck 会将这些条目与编译该二进制文件的 Go 版本进行匹配,而不是与 go.sum 中的任何内容进行匹配。

捆绑与打包

在大多数生态系统中,标准库位于包管理器的底层:被捆绑在编译器或运行时中,没有注册表页面,没有独立的版本号,也没有锁定文件条目。Python 的标准库在 CPython tarball 中发布,而 urllib 没有规范的 PyPI 包;Rust 的 std 和 core 是 sysroot crate,它们的名字在 crates.io 上会报 404 错误;Go 模块规范将第一段中没有点的任何导入路径视为标准库,并且从不在网络上解析它;dart:core 是一个 URI scheme 而不是一个 pub 包;java.base 位于 JDK 镜像中,而 fs 和 http 被编译进 Node 二进制文件中。

另一小部分语言将标准库放入了包管理器中,配有注册表页面,并且版本会出现在依赖图中。Haskell 的 base 位于 Hackage 上,kotlin-stdlib 和 scala-library 是 Maven Central 上的制品,会出现在 Gradle 锁文件中,FSharp.Core 是 NuGet 上一个隐式的 <PackageReference>,elm/core 是每个 elm.json 中必不可少的一行,而 Deno 的 @std/* 则发布在 JSR 上,二进制文件中不再内置任何等价物。

第三类语言则两者并行运行,同一个模块既可以通过运行时分发获取,也可以用相同的名称从注册表获取。Perl 的双生命模块同时随 perl tarball 发布并存在于 CPAN 上;Ruby 将库分为三个层级——非 gem 化库、默认 gem 和内置 gem;R 将基础包与可从 CRAN 更新的推荐包分开;Julia 则将标准库以固定 UUID 的包形式打包,解析器可以从 General 注册表中满足这些依赖。

迁移

十年来,模块一直在 Ruby 的各层级间从标准库逐步迁移到默认 gem 再到内置 gem,这一进程在 3.4 版本(csv、base64、bigdecimal、drb 等共十二个迁移为内置 gem)和 4.0 版本(ostruct、logger、irb、rdoc)中显著加快。PEP 594 在 CPython 3.13 中移除了十九个模块,JEP 320 则在 JDK 11 中移除了 Java EE 和 CORBA 模块,替代品发布在 Maven Central 上。

dart:html 系列已被弃用,改为使用 pub 上的 package:web;Julia 的部分内置标准库从 1.9 版本起可以从注册表升级。在 GHC 9.10 中,base 被拆分出一个新的 ghc-internal 包,这是让 base 能够独立于编译器版本重新安装的第一步,Dan 的组合 PR 正是对这项工作的回应。Racket 在 2014 年的一次发布中,将其整个发行版拆解为大约两百个目录包,构建在一个最小核心之上。

ASP.NET Core 位于 .NET 基础类库的上一层,是我发现的唯一一个从注册表包回归到运行时的案例:1.0 版本以普通 NuGet 包的形式发布,每个 Web 项目都需要单独还原;到 2.1 版本时,它已被重组为随运行时一起安装的共享框架;从 3.0 版本开始,包引用被替换为单个隐式的 <FrameworkReference>。包模型产生了庞大的还原依赖图、缓慢的安装过程,以及包版本之间的菱形冲突,应用开发者对此束手无策,因为整个框架一次性进入了依赖解析器,而非逐个模块处理。

公开的原因

迁移文档中最常给出的理由是解耦发布节奏:标准库中某个模块的修复不应等待下一个语言版本发布,也不应因为某个模块需要补丁就被迫推出一个新语言版本。Julia 1.9 的博文直言不讳地指出标准库一直被固定在随二进制文件发布的版本上,DelimitedFiles 被设为第一个可升级的标准库,这样修复就能从注册表发布,同时该库仍保留在发行版中。Dart 向 package:web 的迁移之所以存在,是因为旧的 dart:html 绑定是手工维护的,长期落后于浏览器 API,而新包则由 Web IDL 自动生成,可以在 IDL 变更时随时发布。可重装的 base 旨在解决 Haskell 长期以来的一个困扰:对 base 设置版本上限实际上是对你所能使用的 GHC 版本的限制。

perl 源码树中的 cpan/ 和 dist/ 目录的存在,是为了让双生命周期(dual-life)模块能够在下一个 perl 版本发布之前先在 CPAN 上发布。Ruby 的默认 gem(default gems)只是换了名字的相同理念,对 json 或 psych 的修复会以 gem 更新的形式到达用户手中,而不是通过三次协调的修订版发布。

第二个明示的原因是将代码移出核心发行版,这样核心团队就不再对其负责。PEP 594 的标题是“移除废弃的电池”,其理由涉及没有维护者的模块、过时的协议以及 20 世纪 90 年代的设计决策;PyPI 上那些复活了 telnetlib 和 cgi 的 standard-* 包属于第三方项目,而不是 CPython 的交付物。JEP 320 的理由是 Java EE 和 CORBA API 从未真正属于 SE,并且独立的 Maven 构件已经存在多年,而 Nim 有一个专门的 graveyard 组织,用于存放从其标准库中移除的模块。

Ruby 从默认 gem 升级为内置 gem 是由维护工作的交接而非发布节奏驱动的:默认 gem 的名义维护者仍然是 ruby-core,而内置 gem 只是一个碰巧包含在安装中的普通 gem,如果升级后没有人维护该内置 gem,那就不再是 ruby-core 的问题了。

迁移文档将发布节奏和维护负担作为理由,但并未提及安全公告格式,这可能是因为其收益归于扫描器作者和 SBOM 消费者,而不是执行迁移工作的语言团队。对于我反复提及的 SBOM 和 CRA 问题来说,CVE 从针对运行时版本的纯文本描述转变为带有版本范围的 purl 才是关键所在,而模块同时在运行时和注册表中提供的过渡状态正是其出问题的地方。

双生命周期模块

rexml 的漏洞是一次发布流程的失误,但双重提供(dual-shipping)在设计上也会产生同样的问题。.NET 的 System.Text.Json 中的 CVE-2024-30105 拥有一份微软安全公告,其中并排列出了 NuGet 包范围(>= 7.0.0, <= 8.0.3)和运行时范围(“.NET 8.0.6 或更早版本”),因为该库既是一个带外(out-of-band)NuGet 包,也是共享框架的一部分,而其修复方案实际上是“升级你获取该库的任何一个来源”。

Go 的 GO-2023-1571 公告同时列出了 stdlib 伪模块和 golang.org/x/net,因为两者包含相同的 HPACK 代码。JavaFX 在 Java 11 中被从 JDK 移除,现在以 pkg:maven/org.openjfx/* 的形式发布在 Maven Central 上,因此,对于 Java 11+ 用户,CVE-2024-20925 通过升级 Maven 依赖项来修复;而对于仍然内置该库的 Java 8 用户,则通过 JDK 更新来修复同一个 bug,并且这两条路径都出现在同一份安全公告中。任何消费这些公告的工具都需要针对每个生态系统、每个模块记录在加载时究竟是运行时副本优先还是注册表副本优先,而这种映射关系并不属于任何常见的安全公告格式。

Perl 实行双重发布已有相当长的时间,并为此配备了相应的工具:Module::CoreList 记录了追溯到 5.000 版本的每个 perl 发行版中,每个双生命周期(dual-life)模块的具体版本;%upstream 哈希记录了每个模块的规范副本是由 blead 还是 CPAN 维护;而 corelist 命令则可以在 shell 中查询这两者。这是一份机器可读的记录,说明了特定 perl 版本内置了哪个版本的模块,以及修复补丁最先合入到哪里;至于实际加载的是哪个副本,仍然是一个运行时内省问题,但发行版与内置版本之间的映射,正是其他生态系统留作隐式的部分。Ruby 拥有 stdgems.org,它以 JSON 格式发布了等效的映射,不过这是一个第三方项目,而非工具链的一部分;而对于 Python、Java、Julia 和 Dart,我找不到任何类似的东西。

能够让扫描器在过渡状态下安全运行的记录,在各处都有大致相同的字段:针对每个运行时发行版和每个模块,记录其内置版本、对应的注册表坐标、哪一方是上游规范源、注册表副本是否可以原地替换内置副本,以及当两者同时安装时工具链会解析为哪个副本。Module::CoreList 和 stdgems.org 大致覆盖了前三个字段;我还没发现有什么能覆盖全部五个字段,而且底层的标识层也存在同样的缺失,因为 purl 没有相应的语法来区分“ruby 3.3.2 中内置的 rexml”和 pkg:gem/[email protected],而这正是 Go stdlib 伪模块在某个生态系统中设法规避的问题。这场迁移正在十几个生态系统中发生,而相关的记录管理工作仅在其中两个中部分存在。

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