Bun 的 Rust 重写审计:13000 个 unsafe 块,七成其实不必存在

2026-05-25 23 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

5 月 21 日,Bun 团队公布了一份针对其尚未发布的 Rust 重写版本的安全审计报告。这份借助 AI 辅助完成的审计,把整个代码库翻了个底朝天——结果令人既振奋又警醒:13,365 个 unsafe 语法节点散布在 774 个文件、51 个子系统里,但经过逐项评估,约 69.4%(约 9,300 个)最终可以转为安全代码,真正必须保留的只有约 30.6%。

这意味着什么?一个追求极致性能的运行时项目,在用 Rust 重写时大量使用了 unsafe,但其中近七成并非出于真正的底层需求——它们是习惯、便利或迁移遗留的产物。

为什么 unsafe 这么多

Bun 的核心定位是高速 JavaScript 运行时,底层需要直接操作内存、调用系统 API、与 C 库交互。这些场景确实需要绕过 Rust 的安全检查。但审计揭示的现实是:大量 unsafe 块并非出现在 FFI 边界或裸指针操作中,而是被用在完全可以由编译器守护的地方。

常见的原因包括:

  • 迁移惯性:从 C/Zig 转向 Rust 时,开发者习惯性地用 unsafe 复制原有逻辑,而非重新用安全抽象表达
  • 性能焦虑:担心安全代码有额外开销,提前用 unsafe "优化",但实际并未测量差异
  • API 不熟悉:Rust 标准库和生态提供了很多安全替代(如 slice::swapCellRefCell),但开发者没找到就直接写了裸操作

审计的分类评估把这些原因逐个拆开了。

七成可移除:具体怎么分类的

审计不是简单数个数。团队对每个 unsafe 块做了分类,核心判断标准是:这个块是否在做只有 unsafe 才能做的事?

Rust 中真正需要 unsafe 的操作只有五类:

  1. 解引用裸指针
  2. 调用 unsafe 函数或方法
  3. 访问或修改可变静态变量
  4. 实现 unsafe trait
  5. 读写 Union 字段

如果一个 unsafe 块内部只做了安全操作(比如普通的数组索引、结构体赋值),那这个 unsafe 标注就是多余的——它既没有解锁任何额外能力,也没有让编译器帮你检查这块代码的安全性。

审计发现,约 9,300 个 unsafe 块属于这种情况:它们包裹的代码完全可以用安全 Rust 表达,移除 unsafe 后功能不变,且获得了编译器的安全保障。

剩下约 4,000 个确实涉及裸指针、FFI 调用或内存布局操作,这些是 Bun 作为运行时无法回避的底层需求。

AI 辅助审计怎么做的

这份报告特别标注了"AI 辅助生成"。面对 13,000+ 个 unsafe 块,纯人工逐个审查几乎不可行。团队的做法是:

  • 用工具自动提取所有 unsafe 语法节点及其上下文
  • 让 AI 模型根据上下文分类:该块是否包含真正的 unsafe 操作、是否有安全替代方案
  • 人工复核 AI 分类结果中的关键项和边界情况

这不是让 AI 替你做安全决策,而是让 AI 做初筛和分类,人类做最终判断。对于这种大规模重复性分析任务,AI 的效率优势很明显。

实践:在自己的 Rust 项目中审计 unsafe

不管你是否在写运行时,如果你维护的 Rust 项目中有 unsafe,值得做一次类似的自查。下面是一个可以直接跑的流程。

第一步:统计 unsafe 块

cargo-geiger 扫描项目中所有 unsafe 使用情况:

# 安装 cargo-geiger
cargo install cargo-geiger

# 在项目根目录运行扫描
cargo geiger --all-features

输出会列出每个包中 unsafe 函数数、unsafe 块数、unsafe 表达式数。如果没装 cargo-geiger,也可以用更简单的方式快速统计:

# 统计自己代码(不含依赖)中的 unsafe 块数量
find src -name '*.rs' -exec grep -n 'unsafe' {} + | wc -l

# 列出每个文件中的 unsafe 行及行号
find src -name '*.rs' -exec grep -Hn 'unsafe' {} +

第二步:逐块判断是否必要

对每个 unsafe 块,检查其内部是否包含上述五类真正的 unsafe 操作。这里是一个自动化初筛脚本的思路:

"""
unsafe_audit.py — 初筛 Rust 源码中的 unsafe 块,标记可能可移除的项

用法:
    python unsafe_audit.py /path/to/rust/project/src

输出每个 unsafe 块的位置和内部操作分类,标记"无真正 unsafe 操作"的块。
"""

import os, re, sys

TRUE_UNSAFE_PATTERNS = [
    r'\*\w+',           # 裸指针解引用,如 *ptr
    r'as\s+\*\w+',      # 转裸指针,如 as *const u8
    r'extern\s*\{',     # FFI 声明
    r'static\s+mut',    # 可变静态变量
    r'union\s+\w+',     # Union 定义
    r'unsafe\s+trait',  # unsafe trait
    r'unsafe\s+fn',     # unsafe 函数定义
]

def find_unsafe_blocks(content):
    """粗略提取 unsafe { ... } 块的内容"""
    pattern = r'unsafe\s*\{([^}]*)\}'
    return re.findall(pattern, content, re.DOTALL)

def classify_block(block_body):
    """判断块内是否包含真正的 unsafe 操作"""
    has_true_unsafe = False
    for p in TRUE_UNSAFE_PATTERNS:
        if re.search(p, block_body):
            has_true_unsafe = True
            break
    return has_true_unsafe

def audit_dir(src_dir):
    results = []
    for root, _, files in os.walk(src_dir):
        for f in files:
            if not f.endswith('.rs'):
                continue
            path = os.path.join(root, f)
            with open(path) as fh:
                content = fh.read()
            blocks = find_unsafe_blocks(content)
            for i, body in enumerate(blocks):
                is_necessary = classify_block(body)
                results.append({
                    'file': path,
                    'block_index': i,
                    'body_preview': body.strip()[:80],
                    'necessary': is_necessary,
                })
    return results

if __name__ == '__main__':
    src = sys.argv[1] if len(sys.argv) > 1 else 'src'
    results = audit_dir(src)
    removable = [r for r in results if not r['necessary']]
    necessary = [r for r in results if r['necessary']]
    print(f"总 unsafe 块: {len(results)}")
    print(f"可能可移除: {len(removable)} ({len(removable)/max(len(results),1)*100:.1f}%)")
    print(f"可能需保留: {len(necessary)} ({len(necessary)/max(len(results),1)*100:.1f}%)")
    print("\n--- 可能可移除的块 ---")
    for r in removable[:20]:
        print(f"  {r['file']} #{r['block_index']}: {r['body_preview']}")

运行方式:

python unsafe_audit.py ./src

注意:这个脚本用正则做粗筛,无法处理嵌套大括号和宏展开等复杂情况。生产级审计需要用 Rust 的 AST 工具(如 syn crate)做精确解析。但作为第一步快速摸底,它足够用了。

第三步:移除不必要的 unsafe

确认某个 unsafe 块内部没有真正的 unsafe 操作后,直接删除 unsafe 关键字,保留块内代码。然后用常规测试验证行为不变。

对于确实需要 unsafe 的块,缩小其范围——只把真正需要 unsafe 的那一行包在 unsafe 里,而不是把整个函数或大段逻辑标为 unsafe:

// 之前:整块标 unsafe,范围过大
unsafe fn process_buffer(buf: *const u8, len: usize) -> Vec<u8> {
    let slice = unsafe { std::slice::from_raw_parts(buf, len) }; // 这行才需要 unsafe
    let mut result = Vec::with_capacity(len);                    // 这行不需要
    result.extend_from_slice(slice);                              // 这行不需要
    result
}

// 之后:unsafe 只包裹真正需要的操作
fn process_buffer(buf: *const u8, len: usize) -> Vec<u8> {
    let slice = unsafe { std::slice::from_raw_parts(buf, len) };
    let mut result = Vec::with_capacity(len);
    result.extend_from_slice(slice);
    result
}

Bun 审计的更深层启示

这份报告的价值不只是数字。它揭示了一个 Rust 生态的普遍问题:高性能项目在用 Rust 重写时,倾向于过度使用 unsafe

Bun 原本用 Zig 编写,Zig 的安全模型与 Rust 不同——Zig 默认允许裸指针操作,只在显式标记时启用安全检查。迁移到 Rust 时,开发者自然会把原来的裸指针逻辑直接搬到 unsafe 块里。这能跑,但丢失了 Rust 的核心价值。

69.4% 可移除的比例说明:大部分 unsafe 不是性能必需,而是迁移残留和认知惯性。真正需要 unsafe 的底层操作(FFI、内存布局控制、SIMD 等)只占三成左右。

这对其他正在考虑 Rust 重写的项目是个重要参考:先写安全版本,测量性能,只在瓶颈处引入 unsafe。而不是一开始就大面积 unsafe,再回头审计。

给维护者的检查清单

如果你维护的 Rust 项目中有 unsafe,建议按以下步骤行动:

  1. 统计现状:用 cargo geiger 或脚本统计 unsafe 块数量和分布
  2. 初筛分类:对每个块判断是否包含真正的 unsafe 操作,标记可疑的多余块
  3. 逐个移除:从最简单的块开始,删除不必要的 unsafe,跑测试确认
  4. 缩小范围:对必须保留的 unsafe,把块缩小到只包含真正需要的操作
  5. 添加安全注释:每个保留的 unsafe 块上方写 // SAFETY: ... 注释,说明为什么这行代码是安全的——这是 Rust 社区的标准实践,也是后续审计的基础
  6. 定期复查:把 unsafe 审计纳入 CI 或发布流程,防止新增不必要的 unsafe

Bun 的审计证明了一件事:unsafe 不是性能的代名词,大部分时候它只是还没来得及重构的安全债务。把七成 unsafe 变成安全代码,既不牺牲功能,又让编译器帮你守住边界——这笔账,任何项目都该算一算。


相关推荐