Rust 1.96.0 深度解读:Copy Range 重塑 slice 操作范式,双 CVE 加固 Cargo 安全防线
前言:一次被低估的版本升级
2026年5月28日,Rust 1.96.0 正式发布。乍一看 changelog——几个新 API、两个 CVE 修复、WebAssembly target 的行为变更——似乎又是一个"例行公事"式的版本迭代。但如果你仔细深挖 RFC 3550 在标准库层面的落地,就会发现这次发布藏着真正改变游戏规则的东西:新一代 Range 类型终于 stable 了,而且是 Copy 的。
这意味着什么?意味着你可以在 struct 里直接 #[derive(Clone, Copy)] 一个包含范围信息的字段,不再需要把 start 和 end 拆成两个 usize 分开存储。意味着"区间语义"第一次可以像 i32、bool 一样零成本地嵌入任何数据结构。意味着很多需要手动拆分范围的代码,可以直接用范围本身作为一等公民。
本文从 Rust 1.96.0 的核心变化出发,深度剖析新 Range 类型的设计动机与实现细节、两个 CVE 的安全影响与修复机制、WebAssembly target 的 breaking change,以及这些变化对日常 Rust 开发的实际意义。
一、新 Range 类型:从 Iterator 换成 IntoIterator,一字之差天壤之别
1.1 历史包袱:为什么旧 Range 不能 Copy
在说新 Range 之前,必须先理解旧设计的问题出在哪里。
Rust 标准库的 core::ops::Range<T> 自从诞生以来就实现了 Iterator trait。这意味着你可以直接对 0..10 调用 .map()、.filter()、.collect():
let nums: Vec<i32> = (0..10).map(|x| x * 2).collect();
但这里埋了一个坑:同时实现 Iterator 和 Copy 是危险的行为(Rust 编译器甚至有专门的 Clippy lint copy_iterator 来警告这个问题)。为什么危险?因为 Copy 意味着"按位复制不会改变语义",但 Iterator 有内部状态(游标位置),复制一个正在遍历的 Iterator 会导致两条遍历路径共享同一个游标,产生难以预测的行为。
正因如此,Rust 的 Range 始终没有实现 Copy:
// 这段代码在 Rust 1.95 中无法编译
#[derive(Clone, Copy)]
struct Span {
start: usize,
end: usize,
// 想要直接存一个 Range<usize>?门都没有
}
结果就是:开发者不得不把 start 和 end 拆成两个独立的字段,手动维护它们的一致性。一旦涉及切片操作,"把一个范围传给函数再返回"这种朴素需求,就变成了一场字段拆分与合并的体力劳动。
1.2 RFC 3550 的解决思路:换掉 Iterator,改用 IntoIterator
RFC 3550 提出的解决方案非常聪明:不改动现有 Range 的行为(向后兼容),而是在 core::range 模块下引入一套全新的 Range 类型,它们实现 IntoIterator 而非 Iterator。
关键区别在这里:
Iterator:直接提供.next()方法,有状态,不能 CopyIntoIterator:提供into_iter()方法,可以在调用时消耗自身并产生一个独立的迭代器,原类型本身不需要有状态
IntoIterator 不要求类型有内部游标,所以完全可以是 Copy 的。只要调用 into_iter(),就会生成一个独立的迭代器实例,两条遍历路径互不干扰。
1.3 新 Range 类型的核心 API
Rust 1.96.0 在 core::range 模块下稳定了以下类型:
// 核心类型定义(简化示意)
mod core::range {
// 新 Range<usize> —— 实现了 Copy!
pub struct Range<T> {
pub start: T,
pub end: T,
}
impl<T> Copy for Range<T> where T: Copy {}
impl<T> IntoIterator for Range<T>
where
T: Try<Output = usize>,
RangeIterator<T>: Iterator<Item = T>,
{
type Item = T;
type IntoIter = RangeIterator<T>;
fn into_iter(self) -> Self::IntoIter { ... }
}
// 类似地还有:
pub struct RangeFrom<T> { pub start: T }
pub struct RangeInclusive<T> { pub start: T, pub end: T }
// RangeTo, RangeFull 将在后续版本以 re-export 方式提供
}
1.4 实操:使用新 Range 重构 Span 类型
这是新 Range 类型最直接的价值体现。看一个实际的代码对比:
旧写法(Rust 1.95 及之前):
#[derive(Clone, Copy)]
pub struct Span {
start: usize,
end: usize,
}
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub fn of<'a>(&self, s: &'a str) -> &'a str {
&s[self.start..self.end]
}
// 想把这个范围传给另一个函数?
// 必须传两个字段:
pub fn as_range(&self) -> (usize, usize) {
(self.start, self.end)
}
}
新写法(Rust 1.96+):
use core::range::Range;
#[derive(Clone, Copy)]
pub struct Span(Range<usize>);
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Self(start..end)
}
// 直接把 Range 发出去,一行搞定
pub fn as_range(&self) -> Range<usize> {
self.0
}
// 更优雅的切片操作
pub fn of<'a>(&self, s: &'a str) -> &'a str {
&s[self.0] // Range<usize> 实现了 Index trait,直接索引切片
}
}
注意最后一行:&s[self.0] —— Range<usize> 实现了 Index<usize>,可以直接用来给切片编索引,完全无需拆解。
1.5 RangeInclusive 的字段公开化
旧版 std::ops::RangeInclusive 的字段是私有的,理由是它内部维护了一个"是否已耗尽"的布尔状态,暴露字段可能让用户破坏这个不变量:
// 旧 RangeInclusive 的字段是非公开的
struct RangeInclusive<T> {
// 私有字段
inner: RangeInclusiveImpl<T>,
}
新版的 core::range::RangeInclusive 则把字段公开了,因为新版不需要维护耗尽状态——迭代器由 into_iter() 单独生成,原类型本身只是一个纯粹的 (start, end) 对,不存在"正在迭代中"的状态可以被破坏:
pub struct RangeInclusive<T> {
pub start: T,
pub end: T,
}
这使得直接构造和模式匹配 RangeInclusive 成为可能:
use core::range::RangeInclusive;
// 直接构造
let range: RangeInclusive<i32> = 1..=10;
// 模式匹配解构
let RangeInclusive { start, end } = 0..=100;
1.6 兼容性处理:legacy 模块与未来计划
RFC 3550 采用了渐进式迁移策略,不会立刻 break 现有代码:
- 语法
0..1仍然生成旧的core::ops::Range(legacy 类型),确保现有代码继续工作 - 后续 Rust 版本将引入
core::range::legacy::*作为旧类型的新家 - 最终,在某个新的 Rust Edition 中,语法
0..1将改为生成新的core::range::Range
Library 作者的迁移建议:对于公共 API,建议改用 impl RangeBounds<T> 作为参数类型,这样既可以接受 legacy Range,也可以接受新的 Range 类型:
use std::ops::RangeBounds;
// 接受所有 RangeBounds 实现类型
pub fn process<T: RangeBounds<usize>>(range: T) {
// 使用 range.start_bound() 和 range.end_bound()
}
// 这样新旧两种调用方式都可以工作:
process(0..10); // legacy
process(core::range::Range { start: 0, end: 10 }); // new
二、assert_matches! 与 debug_assert_matches!:比 matches! 更好的断言宏
2.1 为什么要发明新的宏
Rust 1.96 稳定了两个新宏:assert_matches! 和 debug_assert_matches!。有人可能会问:assert!(matches!(...)) 难道不够用吗?
// 老写法
assert!(matches!(value, Some(x) if x > 0));
问题在于断言失败时的输出体验。当 assert! 失败时,它打印的是整个表达式的 Debug 表示,而不是只关注"不匹配"的部分:
let value: Option<i32> = Some(-5);
// assert!(matches!(...)) 失败时打印:
// thread 'main' panicked at 'assertion failed: matches!(value, Some(x) if x > 0)',
// value: Some(-5)
// assert_matches!(...) 失败时打印:
// thread 'main' panicked at 'assertion failed: `value` matched `Some(x) if x`...
// value: Some(-5)
assert_matches! 会以更友好的方式展示"期望匹配的模式"与"实际值",让你一眼看出是哪里出了问题。
2.2 使用方式
use core::assert_matches;
// 基础用法
assert_matches!(maybe_value, Some(x) if x > 0);
// 带条件判断
assert_matches!(
result,
Ok(records) if records.len() > 0,
"Expected at least one record, got: {:?}", result
);
// debug 版本(只在 debug build 中生效)
debug_assert_matches!(input, Ok(content) if !content.is_empty());
// 在测试中使用
#[test]
fn test_parse_valid_header() {
let line = "Content-Type: text/html";
assert_matches!(
parse_header(line),
Ok(("content-type", "text/html")),
"Failed to parse: {}", line
);
}
2.3 为什么没有加入 Prelude
值得注意的是,这两个宏没有加入 std prelude,即无法直接使用 assert_matches! 而必须手动 use core::assert_matches;(或 use std::assert_matches;)。
原因是 popular crates(如 regex、proc-macro2 等)已经定义了同名的宏。如果直接放进 prelude,会导致这些 crates 的用户在升级 Rust 后遭遇命名冲突编译错误。这是一个务实的权衡:保持 std 演进速度的同时不破坏生态。
三、WebAssembly Target 的 Breaking Change:undefined 符号现在是错误
3.1 变化内容
Rust 1.96 之前,针对 WebAssembly 目标平台(如 wasm32-unknown-unknown)编译时,未定义的符号会被 linker 自动转换为来自 env 模块的 WebAssembly import。这意味着即使代码引用了一个不存在的外部函数,链接阶段也不会报错——只要运行时环境(如浏览器或 WASI 运行时)提供了这个符号,程序就能跑起来。
从 Rust 1.96 开始,链接器不再自动添加 --allow-undefined 参数,未定义的符号会直接触发 linker 错误:
# Rust 1.95 及之前的构建
$ rustc --target wasm32-unknown-unknown --crate-type lib mylib.rs
# 链接器警告(但不报错): undefined symbol: `missing_func`
# Rust 1.96 的构建
$ rustc --target wasm32-unknown-unknown --crate-type lib mylib.rs
error: linking with `wasm-ld` failed: undefined symbol: `missing_func`
3.2 为什么这个改变是必要的
这个变化其实是在纠错长期存在的隐式行为。很多 build 系统配置错误(比如漏掉了某个依赖的 .wasm 文件,或者符号名拼写错误),本应在链接时报错,但之前被 --allow-undefined 掩盖了。在生产环境部署 WASM 模块时才发现缺失的符号,轻则运行时 panic,重则静默使用了错误的行为。
此外,这个改变也使得 Rust 的 WebAssembly 行为与 wasm-ld 的默认行为保持一致,降低了认知负担。
3.3 如何迁移
如果你的代码确实依赖动态链接(即期望在运行时从外部提供某些符号),有两条迁移路径:
路径一:使用 RUSTFLAGS 重新启用旧行为(仅在需要时):
RUSTFLAGS="-Clink-arg=--allow-undefined" cargo build --target wasm32-unknown-unknown
路径二:在源代码中使用 #[link] 属性显式声明导入:
// 显式声明从 "env" 模块导入符号
#[link(wasm_import_module = "env")]
extern "C" {
// 在运行时必须由宿主环境提供这个函数
fn host_provided_callback(ptr: *const u8, len: usize);
}
pub fn trigger_callback(data: &[u8]) {
unsafe {
host_provided_callback(data.as_ptr(), data.len());
}
}
第二种方式更推荐,因为它显式表达了意图,而第一种方式是全局放开所有未定义符号,容易掩盖真正的配置错误。
四、双 CVE 加固:Cargo 的安全补丁全景解读
Rust 1.96 包含了两个影响 Cargo 的安全修复。虽然crates.io 用户不受影响(因为 crates.io 本身禁止上传包含符号链接的包),但使用第三方 registry 的开发者需要认真对待。
4.1 CVE-2026-5223:符号链接提取漏洞(Medium)
漏洞原理
当 Cargo 构建依赖时,它会从 registry 下载 crate 的 tarball(.tar.gz),解压到本地缓存目录(~/.cargo/registry/cache/ 或类似路径)。Cargo 有一系列保护措施,阻止文件被解压到 crate 缓存目录之外。
但研究员 Christos Papakonstantinou 发现了一个逃逸路径:精心构造的 tarball 可以利用符号链接(symlink)将文件写入比预期高一层的位置。
具体来说,攻击流程如下:
- 恶意 crate 的 tarball 包含一个符号链接
evil -> ../ - 同时包含一个文件
evil/../../target_file,即通过符号链接跳转到 crate 缓存的上一级目录 - 解压时,符号链接被解析,
target_file被写入 crate 缓存之外的位置 - 同一个 registry 下其他 crate 的缓存可能被同一位置的同名文件覆盖
~/.cargo/registry/
├── cache/
│ └── vendor.example.com-xxx/
│ └── evil@1.0.0.crate
│ ├── evil -> ../ ← 符号链接指向 cache 的上一级
│ └── evil/../../ ← 解析后指向 registry/ 本身
│ └── some_other_crate-2.0.0.crate ← 覆盖了另一个 crate
攻击后果
如果恶意 crate 覆盖了 registry 中另一个合法 crate 的缓存文件,后续 cargo build 可能使用被篡改的代码而非原始代码。这相当于供应链攻击——攻击者不需要劫持 registry 本身,只需要发布一个"看起来无害"的恶意 crate。
修复方案
Rust 1.96 的 Cargo 在解压 tarball 时,无条件拒绝提取任何符号链接,无论来源是 crates.io 还是第三方 registry(crates.io 原本就禁止上传符号链接,现在 Cargo 的 tarball 解压层也加了这层防护)。
// Cargo 解压逻辑简化
fn extract_tarball(tarball: &[u8], dest: &Path) -> Result<()> {
let mut archive = Archive::new(BrotliDecompress::new(BufReader::new(tarball)));
for entry in archive.entries()? {
let mut entry = entry?;
let file_type = entry.header().entry_type();
// Rust 1.96 新增:拒绝符号链接
if file_type.is_symlink() {
return Err(TarError::SymlinkNotAllowed);
}
entry.unpack_in(dest)?;
}
Ok(())
}
注意:cargo package 和 cargo publish 从来不会创建包含符号链接的 tarball(Rust 编译器层面的限制),所以这次修复不会影响正常的发布流程。
受影响范围
- 所有使用第三方 registry 且下载过恶意构造的 tarball 的 Cargo 版本
- 使用 crates.io 的用户不受影响(crates.io 服务器端已阻止符号链接上传)
- 修复版本:Rust 1.96.0(及对应的 Cargo)
4.2 CVE-2026-5222:稀疏索引 URL 规范化漏洞(Low)
漏洞原理
这个漏洞的条件比较"刁钻",需要同时满足多个特定条件才能利用。
背景知识:Rust 的 Cargo 支持两种 registry 协议——git index(原始方式)和 sparse index(稀疏协议,通过 HTTPS 直接获取 JSON 索引,性能更好)。
历史兼容性:git registry 允许 URL 带或不带 .git 后缀访问同一个仓库(这是 git 主机托管服务的通用行为),Cargo 因此在 URL 规范化时会把 https://example.com/index 和 https://example.com/index.git 视为同一个地址,共享认证凭据。
问题:这个规范化逻辑被错误地应用到了 sparse index 上。sparse index 托管在任意 HTTPS 服务器上,这些服务器不会把 .git 结尾和不含 .git 的 URL 当作同一个资源——它们是完全不同的 URL。
攻击场景
攻击者需要同时控制以下条件:
https://example.com/index是一个稀疏索引(可自由上传 crate)https://example.com/index的 crate 可以依赖其他 registry 的 crate- 攻击者能向
https://example.com/index.git上传文件(可以是一个伪装成 git 仓库的稀疏索引) https://example.com/index.git被配置为一个需要认证的稀疏索引,且其下载 URL 指向一个记录凭据的服务器
受害者操作:下载攻击者发布的 crate foo(依赖了 https://example.com/index.git 中的 crate bar)
Cargo 行为:Cargo 错误地将 https://example.com/index 的认证凭据发送给了 https://example.com/index.git
结果:攻击者获取了受害者的 Cargo registry 认证令牌。
修复方案
Rust 1.96 的 Cargo 将 .git 后缀剥离逻辑限制为仅在 git 协议下使用,sparse index 的 URL 不再进行后缀规范化。
// Cargo registry URL 处理逻辑修复
fn normalize_registry_url(url: &Url) -> Url {
match url.scheme() {
// git 协议:仍然剥离 .git 后缀(保持历史兼容性)
"git+https" | "git+ssh" | "file" => {
strip_git_suffix(url)
}
// sparse 协议:不再做任何规范化
"https" | "http" => {
// 直接使用原始 URL,不剥离 .git
url.clone()
}
_ => url.clone(),
}
}
受影响范围
- Rust 1.68(引入稀疏索引)至 Rust 1.96 之间所有使用第三方 sparse registry 的版本
- 使用 crates.io 的用户不受影响
- 使用 git index 的第三方 registry 基本不受影响(因为 git 主机本身就允许
.git后缀的有无访问) - 修复版本:Rust 1.96.0
风险评估
Rust 官方将此漏洞评级为 Low(低风险),原因是利用条件过于严苛:需要攻击者同时控制同一域名下的两个 registry 端点,且受害者需要使用特定的第三方 sparse registry。大多数开发者使用的都是 crates.io,这个漏洞在实际场景中几乎无法被利用。
但这不意味着可以忽视——如果你的团队使用了私有 registry,立即升级到 Rust 1.96 是最稳妥的做法。
五、其他值得关注的稳定性增强
5.1 From trait 的批量实现
Rust 1.96 为 AssertUnwindSafe、LazyCell 和 LazyLock 实现了 From<T> trait,使得这些包装类型的构造更加符合直觉:
use std::panic::AssertUnwindSafe;
// 之前:必须手动 wrap
let safe = AssertUnwindSafe(expensive_computation());
// 现在:可以用 From 了
let safe: AssertUnwindSafe<_> = expensive_computation().into();
// LazyLock 也支持
use std::sync::LazyLock;
static CONFIG: LazyLock<Config> = "default_config.json".into();
5.2 Rust Foundation Maintainers Fund 启动
与 Rust 1.96 发布同期(2026年6月2日),Rust Foundation 宣布启动 Maintainers Fund,专门用于资助 Rust 核心团队的维护者。这是一个重要的生态信号:Rust 不再只是一个语言项目,而是一个需要长期投入的工程基础设施。
六、升级指南:如何平滑迁移到 Rust 1.96
6.1 立即行动
# 一行命令升级
rustup update stable
# 确认版本
rustc --version
# 输出: rustc 1.96.0 (2026-05-28)
6.2 WebAssembly 用户迁移检查清单
- 运行
cargo build --target wasm32-unknown-unknown确认没有新的 linker 错误 - 如果有链接错误,检查是配置缺失还是真正的未定义引用
- 不要用
RUSTFLAGS全局放开--allow-undefined——只在确实需要动态链接的地方显式使用 - 优先使用
#[link(wasm_import_module = "env")]显式声明导入
6.3 使用第三方 Cargo Registry 的用户
- 立即升级:这是最高优先级,因为涉及安全修复
- 审查团队的私有 registry 配置,确认没有不可信的 symlink tarball
- 如果无法立即升级,可以通过配置 registry 拒绝上传包含 symlink 的 tarball(如果 registry 支持此选项)
6.4 新 Range 类型的推荐用法
// 推荐:公共 API 使用 RangeBounds,接受新旧两种 Range
use std::ops::RangeBounds;
pub fn slice<T>(buffer: &[T], range: impl RangeBounds<usize>) -> &[T] {
&buffer[range]
}
// 如果需要具体类型,优先选择新 Range
use core::range::Range;
// 在新代码中定义 Copy 的区间类型
#[derive(Debug, Clone, Copy)]
struct Interval(Range<usize>);
// 注意:当前需要显式构造(语法 0..1 仍生成 legacy 类型)
let interval = Interval(Range { start: 0, end: 10 });
七、展望:Rust 的下一步棋
7.1 Range 迁移的时间线
根据 RFC 3550 的规划,Range 语法的迁移将是 Edition 驱动的——在当前的 Rust 2024 Edition 及更早版本中,0..1 仍然产生 legacy Range;未来的新 Edition 将改为产生新的 core::range::Range。这意味着:
- 短期:现有代码零影响,新代码可以开始逐步采用新 Range API
- 中期:Library 作者开始提供
impl RangeBounds接口 - 长期:新 Edition 发布后,新语法默认生成新 Range,legacy 类型逐步淘汰
7.2 对 Rust 生态的影响
新 Range 类型的引入,将催生一批新的"区间语义"库——比如时间区间库、文件范围库、内存区域库——这些库之前碍于 Range 不是 Copy 而不得不提供额外的 start/end 分离 API。生态的简化将随之而来。
7.3 Cargo 安全态势
2026年连续出现多个 Cargo 安全漏洞(CVE-2026-33056、CVE-2026-5223、CVE-2026-5222),说明 Rust 生态正在经历一个"安全加固"阶段。crates.io 本身的安全性管理较为严格,但第三方 registry 的生态复杂性带来了新的攻击面。可以预期 Rust Security Response Team 会在未来版本中持续加强对 Cargo 供应链安全的投入。
结语
Rust 1.96.0 是一次被版本号"低估"的发布。新 Range 类型从设计到 stable 花了数年时间,它的落地标志着 Rust 的类型系统在"零成本抽象"的道路上又前进了一步——开发者终于可以在不牺牲性能的前提下,把区间语义当作一等公民来使用了。
两个 CVE 修复则提醒我们:Rust 生态的快速扩张,正在让 Cargo 这个包管理器成为攻击者的目标。对于使用私有 registry 的团队,这次升级不容忽视。
一句话总结:升级 Rust 1.96,改用新 Range,写更干净的代码;同时,如果有私有 Cargo Registry,别犹豫,立刻升级。