编程 Rust 1.96.0 深度解读:Copy Range 重塑 slice 操作范式,双 CVE 加固 Cargo 安全防线

2026-06-16 13:49:17 +0800 CST views 10

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)] 一个包含范围信息的字段,不再需要把 startend 拆成两个 usize 分开存储。意味着"区间语义"第一次可以像 i32bool 一样零成本地嵌入任何数据结构。意味着很多需要手动拆分范围的代码,可以直接用范围本身作为一等公民。

本文从 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();

但这里埋了一个坑:同时实现 IteratorCopy 是危险的行为(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>?门都没有
}

结果就是:开发者不得不把 startend 拆成两个独立的字段,手动维护它们的一致性。一旦涉及切片操作,"把一个范围传给函数再返回"这种朴素需求,就变成了一场字段拆分与合并的体力劳动。

1.2 RFC 3550 的解决思路:换掉 Iterator,改用 IntoIterator

RFC 3550 提出的解决方案非常聪明:不改动现有 Range 的行为(向后兼容),而是在 core::range 模块下引入一套全新的 Range 类型,它们实现 IntoIterator 而非 Iterator

关键区别在这里:

  • Iterator:直接提供 .next() 方法,有状态,不能 Copy
  • IntoIterator:提供 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 现有代码:

  1. 语法 0..1 仍然生成旧的 core::ops::Range(legacy 类型),确保现有代码继续工作
  2. 后续 Rust 版本将引入 core::range::legacy::* 作为旧类型的新家
  3. 最终,在某个新的 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(如 regexproc-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)将文件写入比预期高一层的位置

具体来说,攻击流程如下:

  1. 恶意 crate 的 tarball 包含一个符号链接 evil -> ../
  2. 同时包含一个文件 evil/../../target_file,即通过符号链接跳转到 crate 缓存的上一级目录
  3. 解压时,符号链接被解析,target_file 被写入 crate 缓存之外的位置
  4. 同一个 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 packagecargo 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/indexhttps://example.com/index.git 视为同一个地址,共享认证凭据。

问题:这个规范化逻辑被错误地应用到了 sparse index 上。sparse index 托管在任意 HTTPS 服务器上,这些服务器不会.git 结尾和不含 .git 的 URL 当作同一个资源——它们是完全不同的 URL。

攻击场景

攻击者需要同时控制以下条件:

  1. https://example.com/index 是一个稀疏索引(可自由上传 crate)
  2. https://example.com/index 的 crate 可以依赖其他 registry 的 crate
  3. 攻击者能向 https://example.com/index.git 上传文件(可以是一个伪装成 git 仓库的稀疏索引)
  4. 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 为 AssertUnwindSafeLazyCellLazyLock 实现了 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,别犹豫,立刻升级。

复制全文 生成海报 Rust 1.96 Range CVE Cargo WebAssembly RFC3550 安全漏洞

推荐文章

全新 Nginx 在线管理平台
2024-11-19 04:18:33 +0800 CST
2025,重新认识 HTML!
2025-02-07 14:40:00 +0800 CST
在 Rust 生产项目中存储数据
2024-11-19 02:35:11 +0800 CST
前端如何优化资源加载
2024-11-18 13:35:45 +0800 CST
使用 sync.Pool 优化 Go 程序性能
2024-11-19 05:56:51 +0800 CST
Vue3中怎样处理组件引用?
2024-11-18 23:17:15 +0800 CST
Vue3 中提供了哪些新的指令
2024-11-19 01:48:20 +0800 CST
设置mysql支持emoji表情
2024-11-17 04:59:45 +0800 CST
20个超实用的CSS动画库
2024-11-18 07:23:12 +0800 CST
程序员茄子在线接单