Rust + WebAssembly 全链路解析

第12章 WASI:WebAssembly 系统接口

作者 杨艺韬 · 19,478 字 · 发布于 · 更新于

"Least privilege: Every program and every user of the system should operate using the least set of privileges necessary to complete the job." — Jerome Saltzer & Michael Schroeder, The Protection of Information in Computer Systems(1975)

12.1 为什么 WASM 需要 WASI

WASM MVP 规范定义了四类值类型(i32/i64/f32/f64)、线性内存、表、函数导入导出——但没有定义任何 I/O 原语。WASM 模块不能打开文件、不能建立网络连接、不能读取时钟、不能获取随机数、不能向终端输出一行文字。

这不是规范的疏漏——这是刻意的设计选择。WASM 的目标是做一个安全的沙箱执行格式,而 I/O 是安全风险的源头。规范把 I/O 推到"宿主定义"的边界:浏览器用 JS API 提供能力,服务器端需要一个等价的标准化方案。

flowchart TD
  subgraph "WASM MVP 规范定义"
    A[值类型 i32/i64/f32/f64]
    B[线性内存 memory]
    C[表 table]
    D[函数 import/export]
    E[控制流 br/br_if/br_table]
    F[结构化控制流 block/loop/if]
  end
  subgraph "规范未定义(需要宿主提供)"
    G[文件 I/O]
    H[网络 socket]
    I[时钟 / 定时器]
    J[随机数]
    K[进程环境变量]
    L[终端输出]
  end
  A --- B --- C --- D --- E --- F
  G --- H --- I --- J --- K --- L
  style G fill:#dc2626,color:#fff
  style H fill:#dc2626,color:#fff
  style I fill:#dc2626,color:#fff
  style J fill:#dc2626,color:#fff
  style K fill:#dc2626,color:#fff
  style L fill:#dc2626,color:#fff

浏览器中的 WASM 通过 wasm-bindgen 调用 JS API——console.log、fetch、document.querySelector——JS 是宿主,提供一切能力。但当 WASM 跑在浏览器之外(服务器、边缘计算、CLI 工具、嵌入式),没有 JS——谁来提供文件 I/O、网络、时钟、随机数?

WASI(WebAssembly System Interface)的回答是:不是操作系统,而是一组标准化接口。WASI 定义 WASM 模块"可以请求什么能力"以及"宿主如何授权这些能力"。

WASI 和 POSIX 的本质区别:POSIX 是"进程能做什么"——一个 POSIX 进程默认有完整的系统调用权限(文件、网络、进程管理),权限通过操作系统级别的 uid/gid/capability 控制。WASI 是"模块被允许做什么"——一个 WASM 模块默认没有任何权限,每项能力都需要宿主显式授予。

这不是程度上的差异——这是模型上的根本不同。POSIX 的安全模型是"默认开放,事后限制"(进程可以 open 任何文件,除非 DAC/MAC 策略拒绝);WASI 的安全模型是"默认封闭,事先授权"(模块没有任何权限,除非宿主通过句柄传入)。

12.2 能力安全模型

WASI 的核心设计哲学是能力安全(capability security)——模块不能自己获取权限,只能通过宿主赋予的句柄(handle)访问资源。

能力安全的理论基础来自 Dennis 和 Van Horn 1966 年提出的 capability 模型,以及 Saltzer 1974 年提出的最小权限原则。在能力系统中,一个主体对客体的访问权由其持有的 capability 对象决定——capability 不可伪造、不可提升、只能通过已有 capability 的授权获得新 capability。

POSIX vs WASI 的对比

// 示意代码:Directory 代表"宿主交给你的目录句柄",不是某个具体 crate 的类型
// POSIX: 进程自己打开文件——隐式权限
fn read_config() -> String {
    let mut f = File::open("/etc/config.json").unwrap(); // 任何进程都能尝试打开
    let mut buf = String::new();
    f.read_to_string(&mut buf).unwrap();
    buf
}

// WASI: 必须从宿主获得文件句柄——显式授权
fn read_config(dir: &Directory) -> String {
    let mut f = dir.open("config.json").unwrap(); // 只能访问 dir 下的文件
    let mut buf = String::new();
    f.read_to_string(&mut buf).unwrap();
    buf
}

在 POSIX 中,File::open 的权限来自进程的 uid——如果进程以 root 运行,它可以打开任何文件。一个库函数内部偷偷打开 /etc/passwd,调用者无法阻止——除非使用 seccomp/AppArmor 等外部机制。在 WASI 中,dir.open 的权限来自宿主传入的 Directory 句柄——模块只能访问这个句柄允许的目录;宿主没有交出的目录,模块里的任何代码都不可能访问到。

要注意这道边界划在模块实例这一层,而不是模块内部的每个函数之间。Preview 1 的句柄就是一个 i32 整数,同一个模块里的任何代码都能拿 3 去调 path_open;Rust 的 std::fs::File::open("/data/x") 也不需要谁把句柄传进来,wasi-libc 会自己按路径前缀找到对应的预打开 fd。上面 read_config(dir) 那种"库函数只能碰到调用者递给它的句柄"的写法,是 cap-std 这类库在语言层面提倡的风格,WASI P1 本身并不在模块内部强制它。

graph LR
  subgraph "POSIX:隐式权限"
    A[进程] -->|uid = root| B[文件系统根 /]
    A -->|uid = user| C[用户目录 ~/]
  end
  subgraph "WASI:显式授权"
    D[WASM 模块] -->|fd 3| E["/data/ (只读)"]
    D -->|fd 4| F["/tmp/ (读写)"]
    D -.->|无句柄| G["/etc/ ✗"]
  end
  style G fill:#dc2626,color:#fff
  style E fill:#10b981,color:#333
  style F fill:#10b981,color:#333

能力安全的实际意义

能力安全模型在以下几个场景中尤其关键:

多租户服务器:一个 WASM 运行时同时执行多个用户的代码。在 POSIX 模型中,不同用户的代码共享进程的 uid——要么所有代码权限相同,要么用容器/进程隔离(代价大)。在 WASI 模型中,每个模块实例拥有独立的句柄集——用户 A 的模块只收到指向 /data/user-a/ 的句柄,物理上无法访问用户 B 的数据。

插件系统:应用程序加载第三方插件。在 POSIX 模型中,插件继承了应用进程的全部权限——恶意插件可以读取应用的所有文件。在 WASI 模型中,应用只为插件提供必要的句柄——图片处理插件只收到输入图片的流句柄和输出流的句柄,不能访问文件系统其他部分。

供应链安全:依赖库可能包含恶意代码。在 POSIX 模型中,恶意依赖可以 open("/etc/shadow") 或连接外部服务器泄露数据。在 WASI 模型中,如果应用没有为模块提供文件系统和网络句柄,恶意依赖的这些操作无论藏在哪条代码路径里、有没有被审计发现,执行到时都只会失败——越不过宿主划定的边界。

12.3 WASI Preview 1:fd-based API

WASI Preview 1(导入模块名 wasi_snapshot_preview1,如今官方也称 WASI 0.1)是第一个被广泛实现的版本。它的来历:2019 年 WASI 刚公布时的 API(当时叫 wasi-core)以 wasi_unstable 为导入模块名;2019 年 11 月,WASI 仓库把这一版冻结为 snapshot 0,同时创建了 snapshot 1,也就是 wasi_snapshot_preview1(WebAssembly/WASI 仓库 PR #147「Create snapshot 1」,2019-11-12)。它用 witx 描述接口,受 POSIX 和 CloudABI 影响;如今 P1 的 witx 与文档已从 WASI 仓库主分支移到 wasi-0.1 分支(PR #855,2025-12),主分支只维护 0.2/0.3 的 WIT。Wasmtime 44 至今仍同时实现两者:crates/wasi/src/p0.rs 对应 wasi_unstable,p1.rs 对应 wasi_snapshot_preview1。

P1 的 API 设计模仿 POSIX 的文件描述符(fd)模型——但做了关键的安全约束。

核心 API

API POSIX 等价 说明
fd_write(fd, iovs) writev() 向 fd 写入数据(scatter-gather)
fd_read(fd, iovs) readv() 从 fd 读取数据(scatter-gather)
fd_close(fd) close() 关闭 fd
fd_fdstat_get(fd) fcntl(F_GETFL) 获取 fd 类型(文件/目录/socket)
path_open(fd, dirflags, path, oflags, …) openat() 相对于 fd 打开文件
fd_prestat_get(fd) 无 查询 fd 是不是预打开目录、名字多长
fd_prestat_dir_name(fd, buf, len) 无 获取预打开目录的路径名
fd_filestat_get(fd) fstat() 获取文件元数据
args_get() argv 获取命令行参数
environ_get() environ 获取环境变量
clock_time_get(id, precision) clock_gettime() 获取时间
random_get(buf, len) getrandom() 获取随机数
poll_oneoff(in, out, n) poll() 同时等待多个 fd 可读写或时钟到期
proc_exit(code) exit() 退出进程

(完整函数表以 witx 为准,共 46 个,见后文 §12.7。)

关键设计:path_open 的第一个参数是 fd——一个已打开的目录文件描述符。模块不能直接打开 /etc/passwd,必须先有一个指向 /etc/ 的目录 fd,然后 path_open(dir_fd, ..., "passwd")。这就是能力安全的 fd 层实现——fd 不是全局的"打开任何文件"权限,而是"在这个目录下打开文件"的受限权限。

每个 P1 函数都返回一个 16 位的 errno(typenames.witx 里的 $errno 枚举,(@witx tag u16)),真正的结果通过调用方传入的指针写回线性内存。枚举值与 POSIX 同名但编号是 WASI 自己的:success 为 0,acces 2、badf 8、noent 44、perm 63,最后一个 notcapable(76)是 WASI 独有的"句柄权限不够"。Rust 的 io::Error 里看到的 Os { code: 44, .. } 就是这里的编号,而不是 Linux 的 ENOENT(2)。

Wasmtime 44 如何实现 P1

wasmtime-wasi 44 里的 P1 不是一套独立实现,而是架在 P2 实现之上的适配层(crates/wasi/src/p1.rs 模块文档开头就这么说)。wiggle::from_witx! 读入 witx/p1/wasi_snapshot_preview1.witx(p1.rs:858)生成 46 个导入函数的胶水;WasiP1Ctx(p1.rs:142)里装着 P2 的 WasiCtx、资源表,外加一个 WasiP1Adapter,后者维护 P1 语义独有的那张 fd 整数 → 描述符 表(Descriptors,p1.rs:351,一个 BTreeMap<u32, Descriptor>)。这张表在第一次调用时才建:先压入 stdin/stdout/stderr 占 0、1、2,再按顺序压入 P2 的 get_directories() 返回的预打开目录(p1.rs:358-432),所以第一个预打开目录是 fd 3。之后 path_open 这类调用把整数 fd 查成 P2 的 Resource<Descriptor>,转手调用 P2 的 open-at;新 fd 优先复用关闭后回收的编号,否则取当前最大编号加一(p1.rs:436-465)。

Wasmtime CLI 跑 core 模块时默认走的就是这条路径:src/commands/run.rs:1119-1140 里,除非显式 -S preview2=n 或开了 -S threads,都会把 wasmtime_wasi::p0 和 p1 的导入挂上去;只有那两种情况才退回老的 wasi-common 实现。

预打开目录(preopens)

模块启动时没有 fd——宿主通过预打开目录(preopens)机制赋予初始能力。Wasmtime 的 --dir 参数:

# 只写 HOST_DIR:宿主的 /data 以同名 /data 暴露给模块
wasmtime --dir /data my_module.wasm

# 多个目录
wasmtime --dir /data --dir /tmp my_module.wasm

# HOST::GUEST:宿主 /home/user/project/data 映射为模块内的 /data
wasmtime --dir /home/user/project/data::/data my_module.wasm

注意 CLI 的语法就是 --dir HOST_DIR[::GUEST_DIR](Wasmtime 仓库 src/common.rs:91 的 #[arg(long = "dir", value_name = ...)],解析函数 parse_dirs 在同文件 123 行),没有只读/读写的后缀——命令行给出的预打开目录是可读写的。要做只读授权必须走嵌入 API:WasiCtxBuilder::preopened_dir(crates/wasi/src/ctx.rs:291)的第三、四个参数就是 DirPerms 和 FilePerms。这条命令告诉 Wasmtime:"模块可以访问 /data 和 /tmp 这两个目录,此外什么都看不到。"模块启动时,fd 3 指向 /data,fd 4 指向 /tmp——模块通过 fd_prestat_dir_name 获取路径名,通过 path_open(3, ...) 在 /data 下打开文件。

fd_prestat_dir_name 返回预打开目录的路径字符串,模块用它知道"fd 3 对应 /data,fd 4 对应 /tmp"。这是模块获取文件系统访问能力的唯一入口——没有出现在 preopens 中的路径,模块无法访问。

其余几项授权在 CLI 上的默认值也值得记住(src/common.rs、src/commands/run.rs,44.0.0):环境变量默认一个都不传,--env NAME=VAL 设置单个变量、--env NAME 从宿主继承同名变量、-S inherit-env 才是整体继承;stdin/stdout/stderr 在 CLI 上默认继承(-S inherit-stdout=n 等可关),这一点和嵌入 API 里 WasiCtxBuilder 的默认值相反;命令行参数就是模块文件名加上它后面的参数,模块看到的 argv[0] 是 my_module.wasm 这样的文件名。

WASI Preview 1 的架构

flowchart TD
  subgraph "WASM 模块"
    A["path_open(3, READ, 'config.json')"]
    B["fd_read(5, buf)"]
    C["fd_write(1, 'hello')"]
  end
  subgraph "WASI Preview 1 宿主实现"
    D[fd 3: 预打开目录 /data]
    E[fd 5: 打开的文件 /data/config.json]
    F[fd 1: stdout]
  end
  subgraph "操作系统"
    G[文件系统]
    H[终端]
  end
  A -->|"相对 fd 3 打开"| D
  D -->|"返回 fd 5"| E
  B -->|"读取 fd 5"| E
  E -->|"系统调用 read()"| G
  C -->|"写入 fd 1"| F
  F -->|"系统调用 write()"| H
  style D fill:#f59e0b,color:#333
  style E fill:#10b981,color:#333
  style F fill:#22d3ee,color:#333

wasm32-wasip1 编译目标(原 wasm32-wasi)

Rust 1.35(2019-05)的发布说明以 wasm32-unknown-wasi 之名加入了这个目标,之后通行的名字是 wasm32-wasi,让 Rust 代码可以编译到 WASI Preview 1 环境。注意这个名字已经改了:随着 Preview 2 的出现,旧名过于含糊,Rust 1.78(2024-05)新增了同义的 wasm32-wasip1(Tier 2)和 wasm32-wasip2(当时 Tier 3,1.82 升为 Tier 2),1.81 起使用 wasm32-wasi 会收到警告,1.84(2025-01)把 wasm32-wasi 彻底移除(均见 Rust 仓库 RELEASES.md)。本章凡写 Preview 1 目标的地方,在 1.96 上都是 wasm32-wasip1:

# 安装目标
rustup target add wasm32-wasip1

# 编译
cargo build --target wasm32-wasip1 --release

编译产物是 .wasm 文件,它的导入段引用 wasi_snapshot_preview1 命名空间下的函数——而且只导入实际用到的那些。用 1.96.0 编 §12.5 那个统计行数的小工具,导入段一共 13 项:args_get/args_sizes_get、environ_get/environ_sizes_get、fd_close、fd_fdstat_get、fd_filestat_get、fd_prestat_get、fd_prestat_dir_name、fd_read、fd_write、path_open、proc_exit。任何支持 WASI Preview 1 的运行时(Wasmtime、Wasmer、WAMR)都可以执行这个文件。

12.4 wasi-libc:C 语言的 WASI 运行时

WASI Preview 1 的 API 是低级的 fd 操作——C 程序不直接使用 fd_read/fd_write,它们使用 stdio.h 的 fopen/fread/fwrite/printf。wasi-libc 就是这个桥梁——基于 WASI 原语实现的 C 标准库。

wasi-libc 的主体来自 musl,与 WASI 系统调用打交道的底层取自 cloudlibc——它把 POSIX 的文件 I/O、字符串操作、数学函数、环境变量等功能映射到 WASI Preview 1 的系统调用。对于 WASI 不支持的功能,wasi-libc 要么干脆不提供(fork 这类在头文件里就不声明,编译期直接报错),要么只给需要显式开启的有限模拟(例如 signal 要加 -D_WASI_EMULATED_SIGNAL 并链接 -lwasi-emulated-signal)。

wasi-libc 的层次结构:

┌─────────────────────────────────────┐
│  C 应用代码                          │
│  fopen / fread / printf / malloc    │
├─────────────────────────────────────┤
│  wasi-libc                          │
│  fopen → openat(fd, path)           │
│  fread → fd_read(fd, iovs)          │
│  printf → fd_write(1, format)       │
│  malloc → dlmalloc + memory.grow    │
├─────────────────────────────────────┤
│  WASI Preview 1 系统调用             │
│  path_open / fd_read / fd_write     │
│  clock_time_get / random_get        │
├─────────────────────────────────────┤
│  WASM 线性内存 + 宿主               │
└─────────────────────────────────────┘

wasi-libc 对 Rust 的意义:Rust 在 wasm32-wasip1 上的标准库(std)底层依赖 wasi-libc。1.96 的 std 对 WASI 直接复用了 Unix 那套实现:library/std/src/sys/fs/mod.rs 的 any(target_family = "unix", target_os = "wasi") 分支让 std::fs 走 fs/unix.rs,stdio、时间、线程、全局分配器也一样走各自的 unix 实现——于是 std::fs::File::open 先调 wasi-libc 的 open,由 wasi-libc 把绝对路径匹配到某个预打开目录、再发出 path_open;std::io::stdout() 经 libc 的 write 落到 fd_write;堆分配走 wasi-libc 的 malloc(dlmalloc)。只有少数几处绕过 libc、用 wasi crate 直接发系统调用,例如 args/wasip1.rs 的 args_get、random/wasip1.rs 的 random_get。这个 libc 不需要你另装:rustup target add wasm32-wasip1 自带一份 self-contained 的 wasi-libc;只有编 C/C++ 时才需要 wasi-sdk。

12.5 Rust std 在 WASI 上的支持现状

Rust 标准库在 wasm32-wasip1 上的支持是渐进的——有些模块完整可用,有些部分可用,有些完全不可用。下面两张表按 1.96.0 实测(wasmtime 44 运行)。

完整可用的模块

模块 说明
std::fs 文件读写、目录遍历——经 wasi-libc 落到 path_open/fd_read/fd_write
std::io stdin()/stdout()/stderr()——fd 0/1/2 由宿主提供
std::time SystemTime/Instant——基于 clock_time_get
std::env args()/vars()——基于 args_get/environ_get
std::process exit()——基于 proc_exit
std::thread::sleep 基于 poll_oneoff 等待时钟事件
std::collections 纯数据结构,无系统依赖
std::alloc 全局分配器是 wasi-libc 的 malloc(dlmalloc),向上靠 memory.grow 要内存
std::string/std::vec 纯堆分配,通过 std::alloc 工作

不可用的模块

模块 原因
std::net Preview 1 没有建连的 socket API——类型和方法都在,但 TcpStream::connect 之类一律返回 Unsupported 错误
std::thread::spawn wasm32-wasip1 没有创建线程的接口,spawn 失败(Os { code: 58, kind: Unsupported },spawn 随即 panic);Mutex/RwLock 本身可用,只是永远不会真正竞争。需要线程要换 wasm32-wasip1-threads 目标
std::process::Command Preview 1 没有 fork/exec,output() 等返回 Unsupported 错误
std::os::unix 不存在(编译报 E0433)——WASI 不是 Unix;对应的是 std::os::wasi 和跨平台的 std::os::fd

部分可用的模块

std::sync::atomic:原子类型照常可用。wasm32-wasip1 默认不开 atomics 目标特性,线性内存也不共享,所以原子操作被编成普通的 load/store,不需要任何内存屏障——单线程里本来也没有第二个观察者。Ordering 的区别只有在 wasm32-wasip1-threads 这类多线程目标上才有实际意义。

实际影响

一个典型的 Rust CLI 工具编译到 wasm32-wasip1:

use std::fs;
use std::io;
use std::env;

fn main() -> io::Result<()> {
    let args: Vec<String> = env::args().collect();
    if args.len() < 2 {
        eprintln!("Usage: {} <file>", args[0]);
        std::process::exit(1);
    }

    let content = fs::read_to_string(&args[1])?;  // ✅ 基于 path_open + fd_read
    let lines = content.lines().count();
    println!("{} has {} lines", args[1], lines);  // ✅ 基于 fd_write(1, ...)

    Ok(())
}

这段代码在 wasm32-wasip1 上完全可用——它只用到了 std::fs、std::io、std::env、std::process,全部由 WASI Preview 1 支持。实跑(假设 crate 名为 lines):

$ wasmtime --dir . lines.wasm Cargo.toml
Cargo.toml has 6 lines
$ wasmtime lines.wasm Cargo.toml          # 没给 --dir
Error: Os { code: 44, kind: NotFound, message: "No such file or directory" }

第二条值得细看:没有任何预打开目录能匹配 Cargo.toml,wasi-libc 在模块内部就以 noent(44)失败了,这次 open 根本没有变成一次发往宿主的 path_open(用 §12.15 的 trace 可以看到,只有 wasi-libc 探测预打开目录用的 fd_prestat_get)。

但如果尝试打开网络连接:

use std::net::TcpStream;

fn main() {
    // ⚠️ 能编译,但运行时必然失败
    let _stream = TcpStream::connect("127.0.0.1:8080").unwrap();
}

这里有个容易搞错的细节:它是能编译过的。Rust 标准库为 wasm32-wasip1 提供了一份 std::net 的实现(library/std/src/sys/net/connection/wasip1.rs),其中 TcpStream::connect 的函数体只有一句 unsupported()(同文件 62-64 行)。所以失败发生在运行时——unwrap() 在一个 Unsupported 错误上 panic(1.96 + wasmtime 44 实跑:Error { kind: Unsupported, message: "operation not supported on this platform" },随后以 unreachable trap 结束,CLI 退出码 134),而不是编译器报错。指望编译器帮你拦住网络调用是不成立的。

12.6 Wasmtime:从 Rust 宿主运行 WASI 模块

Wasmtime 是字节码联盟(Bytecode Alliance)的旗舰 WASM 运行时,用 Rust 编写。它不只是命令行工具——它提供完整的 Rust API,让任何 Rust 应用可以嵌入 WASM 执行能力。

最简运行

use wasmtime::{Engine, Linker, Module, Result, Store};
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "my_module.wasm")?;

    // WASIp1 的导入挂到普通的 core wasm Linker 上
    let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
    p1::add_to_linker_sync(&mut linker, |t| t)?;

    // 创建 WASI 上下文——控制模块的能力
    let wasi = WasiCtxBuilder::new()
        .args(&["my_module", "--verbose"])
        .env("MODE", "production")
        // 参数顺序:宿主路径、模块内看到的路径、目录权限、文件权限
        .preopened_dir("./data", "data", DirPerms::READ, FilePerms::READ)?
        .inherit_stdout()
        .inherit_stderr()
        .build_p1();

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate(&mut store, &module)?;

    // 调用 _start(WASI 的 main 函数入口)
    let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
    start.call(&mut store, ())?;

    Ok(())
}

(Wasmtime 44 里 WASIp1 的入口是 wasmtime_wasi::p1 模块——add_to_linker_sync(crates/wasi/src/p1.rs:847)/ add_to_linker_async 加导入,WasiCtxBuilder::build_p1()(crates/wasi/src/ctx.rs:481)产出 WasiP1Ctx(crates/wasi/src/p1.rs:142)。本节和 §12.11 的宿主代码都已按 wasmtime/wasmtime-wasi 44.0.0 编译并实跑。更早版本里的 wasmtime_wasi::sync::*、wasmtime_wasi::add_to_linker 这些路径已经不存在了。)

WasiCtxBuilder 的每一行调用都赋予模块一项能力。没有列出的能力,模块就无法使用——这是能力安全在代码层面的直接体现。

能力控制示例

// 场景一:纯计算模块——不需要任何 I/O
let wasi = WasiCtxBuilder::new()
    .args(&["compute"])
    .inherit_stdout()  // 只需要输出结果
    .inherit_stderr()  // 和错误信息
    .build_p1();
// 模块无法访问文件系统——没有 preopened_dir

// 场景二:数据处理模块——需要读写特定目录
// 注意 DirPerms 只有 READ 和 MUTATE 两个位(MUTATE 才是"能在目录里新建/删除")
// 见 crates/wasi/src/filesystem.rs:92 的 bitflags!:const READ = 0b1; const MUTATE = 0b10;
let wasi = WasiCtxBuilder::new()
    .preopened_dir("./input", "input", DirPerms::READ, FilePerms::READ)?
    .preopened_dir(
        "./output",
        "output",
        DirPerms::READ | DirPerms::MUTATE,
        FilePerms::READ | FilePerms::WRITE,
    )?
    .build_p1();
// 模块只能读 input/ 目录,读写 output/ 目录

// 场景三:受限环境——连 stdout 都不给
let wasi = WasiCtxBuilder::new()
    .args(&["silent-worker"])
    .build_p1();
// WasiCtxBuilder 默认 stdout/stderr 是"吞掉一切"的空流(ctx.rs 50-53 行的默认值说明,实现是 cli.rs:94-95 的 `empty()`):
// 写入照样成功、内容直接丢弃——完全静默执行

cap-std 沙箱的实现原理

Wasmtime 使用 cap-std crate 实现文件系统的能力安全——cap-std 的 Dir 类型保证所有文件操作都在指定目录内,不能通过 ../ 或符号链接逃逸。

flowchart TD
  A["WASM 模块: open('../../etc/passwd')"] --> B[WASI path_open 调用]
  B --> C["cap-std: 相对预打开目录 fd 解析<br/>(openat2 RESOLVE_BENEATH / 逐段打开)"]
  C --> D{解析过程是否越出该目录?}
  D -->|是| E["返回 PermissionDenied ✗"]
  D -->|否| F["返回 fd ✓"]
  A2["WASM 模块: open('config.json')"] --> B2[WASI path_open 调用]
  B2 --> C2["cap-std: 同一条解析路径"]
  C2 --> D2{解析过程是否越出该目录?}
  D2 -->|否| F2["返回 fd ✓"]
  style E fill:#dc2626,color:#fff
  style F fill:#10b981,color:#333
  style F2 fill:#10b981,color:#333

⚠️ 这里要破除一个很常见的误解:cap-std 并不是"先 canonicalize 拿到绝对路径,再比较它是不是以预打开目录为前缀"。那种"解析完再比对字符串"的做法恰恰有 TOCTOU 窗口——检查和真正 open 之间,路径上的某一段可能被替换成符号链接。cap-std 的做法是把边界约束交给解析动作本身:

  1. 相对句柄解析:所有路径都相对预打开目录的那个 fd 解析,模块从来拿不到、也用不上绝对路径
  2. 解析期约束:Linux 5.6+ 上用 openat2 的 RESOLVE_BENEATH、FreeBSD 13.0+ 上用 openat(O_RESOLVE_BENEATH),一次系统调用就让内核保证不越界;其余平台退回到逐段打开路径分量,自己专门处理 .. 和符号链接。越界的访问返回 PermissionDenied(见 cap-std 的 README 对这两条路径的说明),到了 P1 这一侧就是 errno perm(63)——wasmtime --dir .::/data 下打开 /data/../../etc/passwd,trace 里是 path_open(fd=3, "../../etc/passwd") -> Err(Perm),Rust 侧报 Os { code: 63, kind: PermissionDenied, message: "Operation not permitted" }
  3. 权限检查:即使路径合法,也要检查操作是否符合预打开时声明的权限——DirPerms 只有 READ 和 MUTATE 两个位,FilePerms 只有 READ 和 WRITE(crates/wasi/src/filesystem.rs:72 的 FilePerms 与 :92 的 DirPerms 两组 bitflags!;:103 那组是 PathFlags,不是权限位)

关键的不可绕过性质因此不是"算完再比对",而是:模块构造的任意路径字符串(/data/../../../etc/passwd、/data/symlink-to-root/etc/passwd)在解析的每一步都被限制在预打开目录之内,越界在解析当场就失败,不存在"先解析出宿主真实路径、再判断要不要拒绝"的中间状态。

12.7 Preview 1 的根本性局限

Preview 1 虽然可用,但有几个根本性问题——不是"缺少功能"的问题,而是"模型不适合扩展"的问题。

基于整数 fd 的类型不安全

fd 在 Preview 1 中是 i32 整数——fd 3 可能是文件、目录、socket——编译时无法区分。错误使用(对 socket 调用 fd_filestat_get)只能运行时检查。

fd 3 → 可能是 regular file
fd 4 → 可能是 directory
fd 5 → 可能是 socket(如果将来加网络 API)

所有 fd 都共享同一个整数空间,所有 fd 操作都可以对所有 fd 调用——类型错误在编译时不可检测。这和 POSIX 一样——但 POSIX 至少有 fstat() 在运行时区分 fd 类型。WASI Preview 1 也有 fd_fdstat_get,但这是运行时检查,不是编译时保证。

fd 模型不支持组合

两个模块想共享一个文件,只能传递 fd 数字——但 fd 数字只在它所属的那个实例里有意义:它是该实例 WASI 上下文里那张描述符表的下标(Wasmtime 里就是前面说的 Descriptors 表),不是宿主操作系统的 fd,而不同的模块实例有各自的 WASI 上下文。在组件模型中,组件之间传递的是有类型的资源句柄,由运行时负责在两边的句柄表之间转移,不再是裸整数。

没有网络 API

Preview 1 基本只有文件 I/O。witx 里确实有 sock_accept/sock_recv/sock_send/sock_shutdown 四个函数(Wasmtime 仓库 crates/wasi/witx/p1/wasi_snapshot_preview1.witx 的 478、490、503、515 行),但它们只能作用在宿主预先打开并传进来的 socket 上——创建 socket 的 socket、connect、bind、listen 一个都没有。主动建连只能靠各运行时自己的非标准扩展(例如 Wasmer 的 WASIX),P1 里没有标准方案。这意味着:

  • std::net 在 wasm32-wasip1 上建不了连接:connect/bind 一律返回 Unsupported,只有对宿主预先交进来的 socket 做 accept/读写才走得通(同文件 222 行起的 accept 调的就是 sock_accept)
  • HTTP 客户端/服务器没有标准方案
  • 数据库驱动(TCP 连接)无法工作
  • 任何需要网络的功能都必须走自定义的导入导出约定

没有异步支持

所有 API 都是同步调用——fd_read 会阻塞调用它的那个宿主线程。P1 能做的只是 poll_oneoff:一次性等待若干 fd 就绪或时钟到期,外加 fdflags 里的 nonblock;它没有能和宿主事件循环协作的异步调用模型。模块本身是单线程的,阻塞意味着整个实例暂停,跑它的宿主线程也被占住。这对服务器场景很不利——一个处理 HTTP 请求的 WASM 模块如果同步等待上游响应,这段时间里它什么也做不了。

单体规范(monolith)

Preview 1 是一个整体版本化的规范:46 个函数挤在同一个 wasi_snapshot_preview1 命名空间里,只能整体冻结、整体换代,文件系统部分想改进,就得连随机数、时钟一起出一个新 snapshot。要澄清的是,这不意味着宿主必须为每个模块链接全部 46 个函数——模块只导入自己用到的那些:一个只调 random_get 的 cdylib,1.96.0 编出来的导入段就只有 wasi_snapshot_preview1::random_get 一项。问题在演进和组合的粒度上,不在链接上。

graph TD
  subgraph "Preview 1: 单体规范"
    A["wasi_snapshot_preview1<br/>(46 个函数)"] --> B["一个命名空间<br/>只能整体冻结、整体换代"]
  end
  subgraph "Preview 2: 模块化接口"
    C["wasi:random"] --> D["只需 random 的模块<br/>只导入 wasi:random"]
    E["wasi:clocks"] --> F["只需时钟的模块<br/>只导入 wasi:clocks"]
    G["wasi:filesystem"] --> H["需要文件的模块<br/>导入 wasi:filesystem"]
  end
  style A fill:#dc2626,color:#fff
  style C fill:#10b981,color:#333
  style E fill:#10b981,color:#333
  style G fill:#10b981,color:#333

(图里的 46 是数出来的:crates/wasi/witx/p1/wasi_snapshot_preview1.witx 里 @interface func 共 46 处。)

这些局限直接推动了 WASI Preview 2 的设计——下一章将详细拆解 Preview 2 如何用 WIT 接口定义和组件模型解决这些问题。

12.8 预打开目录的深层机制

预打开目录是 WASI 能力安全的核心实现机制,值得深入拆解其工作流程。

从宿主到模块:句柄的传递链

当 Wasmtime 处理 --dir /data::/data 参数时,背后执行了一系列操作:

第一步,宿主打开目录。CLI 把 --dir 交给 WasiCtxBuilder::preopened_dir(src/common.rs:327-334),后者调用 cap-std 的 Dir::open_ambient_dir("/data", ambient_authority())(crates/wasi/src/ctx.rs:298),获得一个受限制的目录句柄。这个句柄保证所有后续操作都在 /data 边界内。

第二步,注册到 preopens 列表。这个目录连同模块内看到的名字 "/data" 被记进 WASI 上下文的预打开列表;等模块第一次发起 P1 调用时,P1 适配层建起 fd 表,按顺序给它分配编号——从 3 开始,因为 0/1/2 固定给 stdin/stdout/stderr(p1.rs:358-432)。

第三步,模块查询 preopens。这一步由 wasi-libc 在第一次需要解析路径时完成(实测一个只调 thread::sleep 的程序根本不会扫描):从 fd 3 开始依次调用 fd_prestat_get,是目录就再调 fd_prestat_dir_name 取回名字 "/data",直到某个 fd 返回 badf 为止。trace 里看到的正是 fd_prestat_get(3) -> Dir { pr_name_len: 5 }、fd_prestat_dir_name(3)、fd_prestat_get(4) -> Err(Badf) 这三步。模块现在知道自己有一个指向 /data 的句柄。

第四步,模块使用句柄。Rust 代码里 File::open("/data/config.json"),wasi-libc 按最长前缀匹配到 /data,发出 path_open(3, symlink_follow, "config.json", ...)——这里的 3 就是预打开目录的 fd。Wasmtime 把这个调用转成 P2 的 open-at,最终由 cap-std::Dir 在 /data 目录下安全地打开 config.json,并把新描述符登记为 fd 4。

sequenceDiagram
  participant H as 宿主 (Wasmtime)
  participant C as cap-std
  participant W as WASM 模块
  H->>C: Dir::open_ambient_dir("/data")
  C-->>H: Dir 句柄 (受限制)
  H->>H: 注册 fd 3 → Dir("/data")
  Note over W: 模块启动
  W->>H: fd_prestat_get(3) / fd_prestat_dir_name(3)
  H-->>W: "/data"
  W->>H: path_open(3, READ, "config.json")
  H->>C: dir.open("config.json")
  C->>C: 检查路径在 /data 边界内
  C-->>H: File 句柄
  H->>H: 注册 fd 4 → File
  H-->>W: fd 4
  W->>H: fd_read(4, buffer)
  H->>C: file.read(buffer)
  C-->>H: 数据
  H-->>W: 读取的字节数

路径映射的安全语义

--dir /host/path::/guest/path 语法中的路径映射有两层语义:左侧是宿主文件系统上的真实路径,右侧是模块内部看到的虚拟路径。模块只知道虚拟路径——它不知道 /data 实际上映射到宿主的 /home/user/project/data。

这种映射有几个重要的安全属性:

首先,模块无法探测宿主的目录结构。模块只能看到虚拟路径,无法通过任何 WASI API 获取宿主的绝对路径。即使模块尝试 path_open(3, ..., "../../etc/passwd"),cap-std 也会在解析过程中发现越界并拒绝(§12.6 已实测返回 perm)。

其次,不同的模块实例可以有不同的路径映射。同一个运行时中的两个模块实例,fd 3 可以指向完全不同的目录——互不干扰。这是多租户安全的基础。

再次,同一个宿主目录可以被映射多次。宿主可以把它以不同的虚拟路径、不同的权限交出去多次——CLI 的 --dir 只能给读写权限,这要用嵌入 API,例如 preopened_dir("/srv/data", "/ro", DirPerms::READ, FilePerms::READ) 加上 preopened_dir("/srv/data", "/rw", DirPerms::all(), FilePerms::all())。模块看到的是两个独立的预打开目录,无法判断它们是否指向同一个物理目录。

12.9 跨专栏关联:WASI 与 Rust 编译目标

WASI 引入的 wasm32-wasip1 和 wasm32-wasip2 编译目标,和《Rust 编译器与运行时揭秘》专栏第 16 章讨论的目标三元组、目标文件生成与链接机制直接相关:

  • 三元组结构:wasm32-wasip1 中 wasm32 是架构(32 位 WASM),wasip1 在目标规格里拆成 os = "wasi" 加 env = "p1"(compiler/rustc_target/src/spec/targets/wasm32_wasip1.rs:21-22)。编译器据此选择标准库实现、链接器行为、条件编译 cfg
  • std 实现分支:Rust 标准库把 WASI 特有的实现按子系统拆开放在 library/std/src/sys/ 下——args/wasip1.rs、env/wasi.rs、random/wasip1.rs、net/connection/wasip1.rs、io/error/wasi.rs、pal/wasi/;而 std::fs、stdio、时间、线程则通过 any(target_family = "unix", target_os = "wasi") 这类条件复用 Unix 实现,经 wasi-libc 落到 path_open/fd_read/fd_write(§12.4)
  • 条件编译:target_os = "wasi" 选中 WASI 的实现,再用 target_env = "p1"/"p2"/"p3" 区分代际(如 sys/net/connection/mod.rs 在 p1 上用 wasip1.rs 那份只能 accept 不能建连的实现,在 p2/p3 上走真正的 socket 实现)

从编译器视角看,WASI 不是一个"精简的 Linux"——它是一个全新的操作系统抽象层,有自己的文件描述符语义、自己的安全模型、自己的能力传递机制。Rust 标准库必须为它单独选择实现:凡是 WASI 语义与 Unix 不同的地方(参数、环境、随机数、网络、错误码)都有独立的 wasi/wasip1 文件,只有 wasi-libc 已经把语义抹平的部分才复用 unix 的代码。

12.10 与其他沙箱技术的对比

WASI 不是唯一的沙箱方案——Linux 有 seccomp/namespaces,容器有 Docker/Podman,语言级有 V8 Isolate。WASI 在这个谱系中的位置值得分析。

WASI vs Linux seccomp

seccomp 通过系统调用过滤限制进程的能力——进程以 BPF 程序的形式把过滤规则装进内核(设置了 no_new_privs 的普通进程也能装),规则一旦装上只能再叠加、不能撤销。WASI 的能力控制是应用级的——宿主在创建每个实例时为它单独配置,不同实例可以拿到完全不同的能力集。

seccomp 的一个根本局限:它只能看系统调用号和整数参数,看不到指针指向的路径字符串,因此无法区分"打开 /data/config.json"和"打开 /etc/passwd"——两者都调用 openat。要实现路径级的过滤,需要结合 landlock 或 AppArmor。WASI 天然支持路径级控制——预打开目录就是路径级白名单。

WASI vs 容器(Docker/Podman)

容器通过 Linux namespace 实现隔离——文件系统 namespace、网络 namespace、PID namespace。容器提供的是进程级隔离——容器内的进程有完整的 POSIX 环境,只是"看到"的文件系统和网络是虚拟的。

WASI 提供的是模块级隔离——WASM 模块没有完整的 POSIX 环境,只有 WASI 定义的有限接口。容器的隔离粒度是"进程",WASI 的隔离粒度是"模块实例"——同一个宿主进程里可以并存成百上千个互相隔离的实例。容器启动一个进程的开销是毫秒级(即使是最轻量的容器),WASI 实例化一个模块的开销是微秒级——因为不需要创建 namespace、不需要启动新进程、不需要加载动态链接库。

WASI vs V8 Isolate

V8 Isolate 是 Cloudflare Workers 使用的沙箱方案——每个 Isolate 是一个独立的 V8 执行环境,有独立的堆和栈。V8 Isolate 的优势是启动远快于进程和容器,局限是它原生只跑 JavaScript:Rust/C/Go 写的逻辑要先编译成 WASM,再在 isolate 里借 V8 的 WebAssembly 引擎运行(Workers 本身支持这样做),系统能力则由 JS 侧的 API 提供,而不是 WASI。

WASI 支持多种语言编译到 WASM——Rust、C、C++、Go 都可以,Python 也有 CPython 官方维护的 WASI 构建(Pyodide 走的是 Emscripten、面向浏览器,不是 WASI)。但 WASI 的启动要多做几件事:验证 WASM 二进制、分配并初始化线性内存与表、解析并类型检查全部导入。这些都可以提前做掉——Wasmtime 的 docs/examples-fast-instantiation.md 列出了三根杠杆:pooling allocator(预分配内存/表池,文档 11 行起)、Config::memory_init_cow(用写时复制映射初始化线性内存,35 行)、InstancePre(把导入解析和类型检查提到实例化之前,38 行起)。验证和编译属于 Module 的创建阶段,可以用预编译挪走(§12.11);实例化本身在默认配置下就已是亚毫秒级——本机(Xeon Gold 6240,Wasmtime 44.0.0 默认 Engine)对 §12.11 那个 158 KB 的模块实测约 0.11 ms,改用 InstancePre 后约 0.07–0.08 ms,比启动一个容器低几个数量级。

这些对比不是要分出胜负——而是帮助工程师根据场景选择合适的沙箱方案。如果需要完整的 POSIX 环境和系统级隔离,用容器。如果只需要 JavaScript 沙箱且追求极致启动速度,用 V8 Isolate。如果需要多语言支持、模块级能力控制、微秒级启动,用 WASI。

12.11 实战:用 Wasmtime 嵌入 WASI 执行

理论之外、看一个完整的端到端例子——从 Rust 源码到沙箱执行。这个例子展示 WASI 能力控制在代码层面的精确性。

待沙箱化的 Rust 模块

假设一个数据处理任务:从 input.json 读取 JSON、计算统计信息、写入 output.json。这个模块要在严格的沙箱里跑——不能访问其他文件、不能联网、不能调用 shell。

// guest/src/main.rs
use std::fs;
use serde_json::Value;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let raw = fs::read_to_string("/data/input.json")?;
    let data: Value = serde_json::from_str(&raw)?;
    
    let count = data.as_array().map(|a| a.len()).unwrap_or(0);
    let result = serde_json::json!({"record_count": count});
    
    fs::write("/data/output.json", result.to_string())?;
    Ok(())
}

编译:

cargo build --target wasm32-wasip1 --release
# 产出: target/wasm32-wasip1/release/processor.wasm

嵌入式宿主代码

宿主程序加载 WASM 并精确控制它的能力:

// host/src/main.rs
use wasmtime::{Engine, Linker, Module, Result, Store};
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "processor.wasm")?;

    let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
    p1::add_to_linker_sync(&mut linker, |t| t)?;

    // 关键:精确控制能力——只有这一个目录,读写权限也写死在这里
    let wasi = WasiCtxBuilder::new()
        .preopened_dir("./sandbox-data", "data", DirPerms::all(), FilePerms::all())?
        .inherit_stdout()
        .inherit_stderr()
        .build_p1();

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate(&mut store, &module)?;
    let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
    start.call(&mut store, ())?;

    Ok(())
}

安全边界的验证

这个例子的安全边界严格:

  • 模块只能读写 ./sandbox-data 目录、看不到宿主其他文件
  • 网络:P1 本来就没有建连接口;inherit_network() 只影响 P2 的 wasi:sockets,这里也没调用
  • 没有 inherit_args()——模块拿不到宿主命令行参数
  • 没有 inherit_env()——模块看不到宿主环境变量

如果模块代码尝试 fs::read_to_string("/etc/passwd"),得到的是 NotFound(errno 44)——它甚至没有到达宿主:/etc/passwd 匹配不上唯一的预打开目录 data,wasi-libc 在模块内部就失败了。要是写成 /data/../../etc/passwd,请求会以 path_open(3, "../../etc/passwd") 发到宿主,被 cap-std 拦下,得到 PermissionDenied(errno 63)。两种情况都不是 OS 权限在起作用。

把上面两段代码实跑一遍(guest 用 1.96.0 编 wasm32-wasip1、依赖 serde_json 1.x;sandbox-data/input.json 放 [1,2,3,4]),sandbox-data/output.json 得到 {"record_count":4}。guest 里写的是绝对路径 /data/...,而预打开目录名是 data,wasi-libc 做前缀匹配时两种写法都能对上(实测 data 与 /data 均可)。

启动开销的构成

上面这条流程的冷启动开销分布,量级关系是稳定的;绝对值随机器、模块大小、Wasmtime 配置差异很大。下表的数字是本机在宿主代码里加 Instant 计时量的(Xeon Gold 6240,Wasmtime 44.0.0 默认 Engine,guest 为上面 158 KB 的 processor.wasm,三次取中),只作量级参考:

阶段 相对量级 开销来源
Module::from_file 加载 + 验证 + 编译 最大的一块,约 46 ms 解析、验证、Cranelift 编译到机器码
Linker::instantiate 实例化 约 0.11 ms,比加载阶段低两个多数量级 分配线性内存/表、解析导入
业务逻辑执行(小 JSON) 取决于业务本身,本例约 4.5 ms 纯计算 + 几次 WASI 调用

决定冷启动的几乎全是第一行。 把编译结果预先做好(Module::serialize 产出的 cwasm,之后用 Module::deserialize 加载),加载阶段就只剩反序列化和 mmap——同一模块实测从约 46 ms 降到约 0.63 ms;再叠上 docs/examples-fast-instantiation.md 里那三根杠杆(pooling allocator、Config::memory_init_cow、InstancePre),实例化还能从默认的约 0.1 ms 继续往下压。这是边缘平台把冷启动做到毫秒以内的通用做法。

12.12 WASI 在生产中的反模式

WASI 看起来直观、但实际部署中常踩坑。

反模式 1:把 inherit 当默认

// ❌ 反模式:图省事
let wasi = WasiCtxBuilder::new()
    .inherit_stdio()
    .inherit_args()
    .inherit_env()
    .preopened_dir("/", "/", DirPerms::all(), FilePerms::all())?
    .build_p1();

这样配置等于让 WASM 模块拥有宿主的全部能力——根目录可读写、命令行参数可见、环境变量可见。失去了 WASI 的核心价值。默认应该是"什么都不给"、按需添加。

反模式 2:忽视 cap-std 的 ambient authority

// ❌ 反模式:宿主先用 std::fs 把文件打开,再想办法"塞"给模块
let f = std::fs::File::open("/data/file.txt")?;  // 走宿主完整权限
let wasi = WasiCtxBuilder::new()
    // 模块还是无法访问 f、但宿主已经用 ambient authority 打开了它
    .build_p1();

正确做法:把路径交给 WasiCtxBuilder::preopened_dir,由它内部调用 cap_std::fs::Dir::open_ambient_dir 拿到受限目录句柄(见 crates/wasi/src/ctx.rs)。ambient_authority() 是 cap-std 里显式的"我承认这一步用了宿主的环境权限"标记——凡是出现它的地方都该被审计。

反模式 3:用大目录代替细粒度

// ❌ 给 /home 整个目录、希望模块只读其中一个子目录
.preopened_dir("/home", "home", DirPerms::READ, FilePerms::READ)?

模块得到 /home 后能访问 /home/.ssh/ 等敏感目录。应该精确到需要的子目录:

// ✅ 只给具体的工作目录
.preopened_dir("/home/alice/workspace", "workspace", DirPerms::READ, FilePerms::READ)?

反模式 4:误用同步 I/O 阻塞 runtime

WASI Preview 1 的所有 I/O 都是同步的——fd_read 会阻塞 WASM 模块。如果宿主用单线程 runtime 跑多个 WASM 实例、一个实例的阻塞会拖垮全部。

// ❌ 单线程 runtime + 多 WASM 实例
let store = Store::new(&engine, wasi);
// 多个 instance 共享 store——一个 fd_read 阻塞、全停

正确做法:每个 WASM 实例独立 store、独立线程或 async runtime(Wasmtime 的 async API + tokio)。

反模式 5:信任 WASM 模块的输入

WASM 模块自己说的话不可信——它可能是恶意编译的。常见错误:

  • 模块返回的 path、宿主直接当真实路径用
  • 模块要求的内存大小、宿主无限制地 grow
  • 模块上报的"我执行完了"、没有 timeout 兜底

每一项都要宿主做防御性检查——WASI 边界只防文件系统、不防业务逻辑。

反模式 6:忘了 deterministic execution 不是默认

WASI 默认提供真实时间 (clock_time_get) 和真实随机数 (random_get)——这让相同输入的执行结果不一致。需要确定性执行(如区块链)的场景、要用 WasiCtxBuilder 的自定义 clock 和 random:

let wasi = WasiCtxBuilder::new()
    .wall_clock(fixed_wall_clock())          // 固定墙上时间
    .monotonic_clock(fixed_monotonic())      // 固定单调时钟
    .secure_random(seeded_rng(42))           // 固定种子的 RngCore
    .insecure_random(seeded_rng(43))         // 非安全随机源也要一并固定
    .build_p1();
// fixed_wall_clock/fixed_monotonic 需自行实现 HostWallClock / HostMonotonicClock(crates/wasi/src/clocks.rs)

确定性是有意识的选择、不是 WASI 的默认行为。

反模式 7:不限制 fuel/instructions

WASM 模块可以无限循环——WASI 不会自动停止它。生产环境必须设置 fuel limit:

let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let mut store = Store::new(&engine, wasi);
store.set_fuel(1_000_000)?;  // 燃料上限(旧版的 add_fuel 已被 set_fuel 取代)

没有 fuel limit、一个恶意或 buggy 模块会让整个 runtime 卡死。

12.13 WASI 演化路径与生产选型

WASI 不是单一规范、而是持续演化的标准家族。不同版本对应不同生态成熟度——选型要看版本和需求匹配。

三个 WASI 版本的能力对比

flowchart LR
    classDef p1 fill:#fed,stroke:#c60
    classDef p2 fill:#def,stroke:#06c
    classDef p3 fill:#efe,stroke:#080
    P1[Preview 1<br/>2019]:::p1 --> P2[Preview 2 / 0.2<br/>2024]:::p2
    P2 --> P3[Preview 3 / 0.3<br/>2026-06]:::p3
    P1 --> P1A[fd-based API]
    P1 --> P1B[文件 I/O]
    P1 --> P1C[无网络]
    P2 --> P2A[WIT 接口]
    P2 --> P2B[组件模型]
    P2 --> P2C[wasi:http / wasi:sockets]
    P3 --> P3A[原生异步 / async]
    P3 --> P3B[future / stream 取代 wasi:io 轮询]

生产选型矩阵

场景 推荐版本 理由
CLI 工具 / 数据处理 Preview 1 成熟、工具链完整、wasm32-wasip1 稳定
边缘函数 / Serverless Preview 2 wasi:http 标准化、平台支持好
插件系统 Preview 2 组件模型让插件接口可演化
实验性 / 探索 Preview 3(WASI 0.3) 异步原生支持;Rust 的 wasm32-wasip3 仍是 Tier 3
区块链 / 智能合约 通常不用 WASI,走自定义宿主接口 需要严格控制确定性,WASI 的时钟、随机数本身就是不确定源

不同 runtime 的支持情况

Runtime Preview 1 Preview 2 主用场景
Wasmtime ✓ 完整 ✓ 完整 通用、参考实现
Wasmer ✓ 完整 有限 多语言嵌入
WAMR ✓ 完整(libc-wasi) 有限 嵌入式 / IoT
Spin 以组件为主 ✓ 主推 Serverless
wasmCloud 以组件为主 ✓ 主推 分布式

除 Wasmtime 一行可对照其源码外,其余几行的 P2 支持程度变化很快,选型前以各项目当期文档为准。选 runtime 不只看性能——看你需要的 WASI 版本是否支持成熟。

迁移路径建议

已经在 Preview 1 上的项目、迁移到 Preview 2 的渐进路径:

  1. 先包成组件,代码不动:用 wasm-tools component new 配合 Wasmtime 仓库提供的 wasi_snapshot_preview1 适配器模块(crates/wasi-preview1-component-adapter),把现有 P1 模块包成一个 P2 组件——适配器把 P1 的 46 个函数翻译成 P2 接口调用
  2. 换编译目标:Rust 项目改用 wasm32-wasip2,std::fs 等照常可用,std::net 也能真正建连
  3. 新功能直接用 P2 接口:HTTP 等新需求用 wasi:http,这类接口只能在组件里导入,core 模块用不了
  4. 按需替换底层调用:直接使用 wasi:filesystem 等接口的代码逐步取代 fd 风格的调用

过渡期里,P1 模块经适配器在 P2 宿主上运行、P2 组件原生运行,两种产物可以并存——这是 WASI 演化路径友好的体现。

当前阶段(2026)的实用建议

  • 生产稳定项目:Preview 1 + Wasmtime——成熟度最高
  • 新项目:Preview 2 + Wasmtime——面向未来、工具链已可用
  • edge / serverless:跟随平台(Spin / Cloudflare Workers)的版本选择
  • 观望者:保持关注、不必急着切——WASI 0.3.0 已于 2026-06-11 发布(WebAssembly/WASI 仓库 docs/Release.md 与 v0.3.0 release,此后按两月一次发 0.3.x,0.3.1 于 2026-08-11 发布),但本专栏对照的 Wasmtime 44 里实现的还是发布前的候选版,WIT 包版本写作 wasi:cli@0.3.0-rc-2026-03-15(crates/wasi/src/p3/wit/deps/cli.wit:1);Rust 侧 wasm32-wasip3 在 1.96 仍是 Tier 3(compiler/rustc_target/src/spec/targets/wasm32_wasip3.rs 里 tier: Some(3)),平台文档 src/doc/rustc/src/platform-support/wasm32-wasip3.md 注明在 WASIp3 获批前标准库不会真的去用 WASIp3 API(该说明写于 2025-10-01,1.96 未更新)

12.14 WASI 应用的安全审计

WASI 提供了能力安全的基础——但如何设计具体应用的能力授权才是工程实践。一个错误授权的 WASI 应用可能让"安全沙箱"变成纸糊的——必须有明确的审计流程。

12.14.1 能力清单驱动的审计

WASI 应用的安全审计起点是显式能力清单——明确列出应用需要的所有能力,运行时按清单授权:

graph TD
  A["WASI 应用"] --> B["申明的能力清单"]
  B --> C["文件系统:/tmp/data 读写"]
  B --> D["网络:example.com:443 只读"]
  B --> E["环境变量:APP_CONFIG"]
  B --> F["时钟:单调时间"]
  B --> G["随机数:仅 OsRng"]
  C --> H["运行时授权检查"]
  D --> H
  E --> H
  F --> H
  G --> H
  style B fill:#10b981,color:#333
  style H fill:#f59e0b,color:#333

清单的格式各平台不同——例如 Spin 在 spin.toml 里按组件声明可访问的文件与外连主机。审计时核对:

  1. 清单是否最小化——能减能力的不要多授权
  2. 清单是否完整——应用实际需要的都列出
  3. 路径是否精确——preopened_dir(/tmp, /) vs preopened_dir(/tmp/sandbox, /) 安全等级差很多

12.14.2 威胁建模框架

每个 WASI 应用都应该做 STRIDE 威胁建模:

威胁类型 WASI 场景 缓解
Spoofing(身份冒充) 应用伪造调用方身份 WASI 不传递调用上下文,依赖宿主验证
Tampering(篡改) 应用修改超出权限的文件 preopen 路径精确限制 + 只读权限
Repudiation(否认) 应用否认操作 宿主侧审计日志(不依赖 WASI)
Information Disclosure 应用读到不该看的数据 严格 preopen + 不 inherit_env
DoS 应用无限循环 fuel / epoch 中断
EoP(提权) 沙箱逃逸 runtime 漏洞修复 + 最新版本

实战中最容易出问题的是 Information Disclosure——开发者为了图方便用 inherit_env() 把宿主所有环境变量传给 WASI 应用,应用一旦把环境打进日志,AWS_SECRET_KEY 之类就跟着泄漏了。

12.14.3 审计清单实战

生产前的审计 checklist:

flowchart TD
  A["WASI 应用准备上线"] --> B["1. 列能力清单"]
  B --> C["2. 检查 preopen 路径最小化"]
  C --> D["3. 检查环境变量授权"]
  D --> E["4. 检查网络授权"]
  E --> F["5. 配置 fuel/epoch 限制"]
  F --> G["6. 配置内存上限"]
  G --> H["7. 检查 runtime 版本"]
  H --> I["8. 跑 fuzz 压测"]
  I --> J["9. 渗透测试"]
  J --> K["上线"]
  style K fill:#10b981,color:#333

每条都有可量化的检查点——例如"环境变量授权"应该写出明确的允许列表:

let allowed_env = ["APP_CONFIG", "LOG_LEVEL"];
let mut wasi = WasiCtxBuilder::new();
for key in allowed_env {
    if let Ok(value) = std::env::var(key) {
        wasi.env(key, &value);
    }
}
// 不调用 inherit_env() —— 不传所有变量
let wasi_ctx = wasi.build_p1();

12.14.4 第三方 WASI 模块的信任评估

如果 WASI 模块来自第三方(市场上的插件、用户上传的代码),信任评估更严格:

维度 检查项
代码来源 签名验证(cosign / sigstore)
二进制完整性 SHA-256 校验
静态分析 wasm-tools dump 看导入项
行为审计 在 staging 环境 trace 系统调用
版本固定 不接受隐式升级

对组件用 wasm-tools component wit my_module.wasm、对核心模块用 wasm-tools print my_module.wasm | grep '(import',都能列出它声明的全部导入——审计员检查这些导入是否合理。如果一个声称是"图像处理"的模块导入了 wasi:http/outgoing-handler,就该拒绝或要求解释——它具备往外发数据的能力。

12.15 WASI 系统调用追踪与调试

WASI 应用的调试比传统应用复杂——错误经常发生在 host 函数边界,而不是应用代码内。系统化的追踪手段是诊断关键。

12.15.1 strace 风格的 WASI 调用追踪

Wasmtime 的 CLI 日志由 WASMTIME_LOG 环境变量控制(docs/cli-logging.md),把 wasmtime_wasi 这个 target 调到 trace 就能看到每次 WASI 调用:

WASMTIME_LOG=wasmtime_wasi=trace wasmtime my_app.wasm 2>&1 | head -50

对 §12.5 的行数统计工具执行 WASMTIME_LOG=wasmtime_wasi=trace wasmtime --dir .::/data lines.wasm /data/Cargo.toml,实际输出节选如下(去掉了时间戳,长行截断):

TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_prestat_get"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(3)
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_prestat_get"}: wasmtime_wasi::p1::wasi_snapshot_preview1: result=Ok(Dir(PrestatDir { pr_name_len: 5 }))
TRACE wiggle abi{module="wasi_snapshot_preview1" function="path_open"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(3) dirflags=Lookupflags(SYMLINK_FOLLOW) path=*guest 0x103230/10 oflags=Oflags(0x0) ...
TRACE wiggle abi{module="wasi_snapshot_preview1" function="path_open"}: wasmtime_wasi::p1::wasi_snapshot_preview1: result=Ok(Fd(4))
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_read"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(4) iovs=*guest 0xffd78/1
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_read"}: wasmtime_wasi::p1::wasi_snapshot_preview1: result=Ok(76)
TRACE wiggle abi{module="wasi_snapshot_preview1" function="fd_close"}: wasmtime_wasi::p1::wasi_snapshot_preview1: fd=Fd(4)

每次调用两行:先是参数,后是结果(Ok(..) 或 Err(Error { inner: Perm }) 这样的 errno)。注意路径参数只打印成 guest 指针和长度,看不到字符串本身。

追踪揭示应用真实的访问模式——如果应用声称"只读 data.txt",trace 里却出现了别的 path_open,或者一连串 Err(Perm) 的越界尝试,能立即捕捉异常行为。

12.15.2 自定义 host 实现包装

更精细的追踪是包装 WasiCtx 的 host 实现——记录每次调用的参数、返回值、耗时:

use std::sync::{Arc, Mutex};
use wasmtime::component::ResourceTable;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};

struct AuditingState {
    ctx: WasiCtx,
    table: ResourceTable,
    log: Arc<Mutex<Vec<String>>>,
}

// Wasmtime 44 的 WasiView 只有一个方法,返回把 ctx 和资源表打包在一起的视图
impl WasiView for AuditingState {
    fn ctx(&mut self) -> WasiCtxView<'_> {
        WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
    }
}

真正的"每调用一条日志"要包在具体接口的 Host trait 实现上(或者干脆先用上面的 WASMTIME_LOG)——WasiView 本身只负责把状态交出去。

生产中这种包装通常仅在 staging 环境启用——每个 WASI 调用都多出一次格式化与写日志,热路径上的相对开销会很可观。具体放大多少取决于日志后端和调用频率,要上生产先自己压一遍。

12.15.3 错误信息的解读

WASI 错误码对应特定语义——理解这些码有助于快速定位。P1 返回的是 typenames.witx 里的 errno,Rust 侧 io::Error 的 code 就是它的编号;P2 的 wasi:filesystem 换成了 error-code 枚举,名字不同但一一对应:

P1 errno(编号) P2 error-code(wasi:filesystem@0.2.6) 常见原因
noent(44) no-entry 文件不存在;或者路径根本匹配不上任何预打开目录(wasi-libc 在模块内就报这个,§12.5)
perm(63) not-permitted cap-std 拦下了越出预打开目录的路径(§12.6)
acces(2) access 宿主操作系统层面的权限不足
notcapable(76) —(P2 去掉了 rights 机制) P1 的 rights 位不允许该操作
badf(8) bad-descriptor fd 已关闭或从未分配
loop(32) loop 符号链接成环
again(6) would-block 非阻塞 fd 暂无数据,调用方应 poll_oneoff

排查时先分清错误在哪一侧产生:noent 可能根本没到宿主,perm 则是宿主拒绝的——结合 trace 看有没有对应的 path_open 就能判断。

12.15.4 集成到生产可观测性

WASI 调用应该作为指标暴露——配合第 18 章的可观测性框架:

// 包装每个 WASI 调用,发送到 Prometheus(metrics 0.24 的宏写法)
fn instrumented_call<R>(name: &str, f: impl FnOnce() -> R) -> R {
    let start = std::time::Instant::now();
    let result = f();
    metrics::histogram!("wasi_call_duration_seconds", "name" => name.to_string())
        .record(start.elapsed().as_secs_f64());
    metrics::counter!("wasi_call_total", "name" => name.to_string()).increment(1);
    result
}

在 Grafana 看板中观察 WASI 调用的频率、延迟、错误率——异常模式(突然增多的 perm/noent、连接突然超时)能在用户感知前发现问题。

12.16 从 Linux 应用移植到 WASI

把现有 Linux/POSIX 应用移植到 WASI 是常见需求——CLI 工具、数据处理脚本、服务端 binary 等。但 WASI 不等于 Linux——理解差异和移植路径是关键工程技能。

12.16.1 移植难度的三档分类

graph TD
  A["Linux 应用类型"] --> B["简单:纯计算 + 文件 IO"]
  A --> C["中等:网络 + 子进程"]
  A --> D["困难:信号 + IPC + 系统调用"]
  B --> B1["sed / awk / 哈希工具<br/>基本编译即可"]
  C --> C1["简单 HTTP 服务 / curl<br/>需改用 wasi:http"]
  D --> D1["sshd / cron / docker<br/>WASI 不支持,重写"]
  style B fill:#10b981,color:#333
  style C fill:#f59e0b,color:#333
  style D fill:#dc2626,color:#fff

12.16.2 简单应用:cargo build 即可

C 写的工具(如 SHA-256 计算器):

# 假设有 sha256sum.c(需要 wasi-sdk 提供的 sysroot)
clang --target=wasm32-wasip1 --sysroot=$WASI_SYSROOT sha256sum.c -o sha256sum.wasm
wasmtime --dir=. sha256sum.wasm myfile.txt

Rust 类似:

cargo build --target wasm32-wasip1 --release
wasmtime --dir=. target/wasm32-wasip1/release/my_tool.wasm input.txt

注意 --dir=. 显式 preopen 当前目录——WASI 默认 deny-all。

12.16.3 中等应用:网络 + 异步

需要网络的应用必须用 WASI Preview 2 + wasi:http 或 wasi:sockets:

// Preview 2 的 HTTP client(wasi crate 0.14,目标 wasm32-wasip2;已编译通过)
use wasi::http::outgoing_handler;
use wasi::http::types::*;

fn fetch(authority: &str, path: &str) -> Result<Vec<u8>, String> {
    let req = OutgoingRequest::new(Headers::new());
    req.set_scheme(Some(&Scheme::Https)).map_err(|e| format!("{:?}", e))?;
    req.set_authority(Some(authority)).map_err(|e| format!("{:?}", e))?;
    req.set_path_with_query(Some(path)).map_err(|e| format!("{:?}", e))?;
    req.set_method(&Method::Get).map_err(|e| format!("{:?}", e))?;

    let future_resp = outgoing_handler::handle(req, None).map_err(|e| format!("{:?}", e))?;
    // ... 在 future_resp 上 subscribe + 轮询,拿到 IncomingResponse 后读 body ...
    todo!()
}

URL 要拆成 scheme、authority、path 三段分别设置;set_path_with_query 只接受路径加查询串。

子进程(fork/exec)在 WASI 中不支持——POSIX 这部分被故意排除。需要子进程功能的应用必须重新设计:

原 POSIX 模式 WASI 替代
system("curl ...") 用 wasi:http 直接调用
popen("grep ...") 在 WASM 内用 regex crate
fork() 后子进程处理 单线程内的异步任务,或拆成多个实例由宿主调度(线程要靠 wasm32-wasip1-threads)

12.16.4 困难应用:放弃 WASI 的判定

某些 Linux 应用根本不能移植到 WASI——必须有标准说"不":

flowchart TD
  A["要移植 Linux 应用"] --> B{"用了 fork/exec?"}
  B -->|是| C["不能移植"]
  B -->|否| D{"用了 signal handlers?"}
  D -->|是| C
  D -->|否| E{"用了 TCP/UDP socket?"}
  E -->|是| F["改用 P2 的 wasi:sockets"]
  E -->|否| G{"用了 mmap?"}
  G -->|是| C
  G -->|否| H{"用了 ioctl?"}
  H -->|是| C
  H -->|否| I["可以移植"]
  style C fill:#dc2626,color:#fff
  style I fill:#10b981,color:#333

不能移植的典型场景:

  • shell:依赖 fork/exec 启动子进程
  • 守护进程:依赖信号处理(SIGHUP 等)
  • 网络代理:依赖 raw socket 控制 TCP 包
  • 数据库引擎:依赖 mmap、O_DIRECT、fsync 语义

这些应用要么继续在传统容器中运行,要么重写架构(例如把 shell 的子进程模型改为单进程协程)。

12.16.5 常见移植坑

graph LR
  A["移植坑"] --> B["1. 默认无文件系统访问"]
  A --> C["2. 时区数据库缺失"]
  A --> D["3. argv[0] 行为不同"]
  A --> E["4. panic 退出码"]
  A --> F["5. 浮点精度差异"]
  style A fill:#f59e0b,color:#333

每个坑的快速诊断:

  • 文件系统:报 NotFound(errno 44)而文件明明存在 → 多半是没有匹配的预打开目录,加 --dir=
  • 时区:chrono::Local::now() 返回 UTC(chrono 0.4.45 实测,即使用 --env TZ 传了时区、预打开了 /usr/share/zoneinfo 也一样)→ 把时区作为参数显式传入,自己换算
  • argv[0]:wasmtime CLI 传的是模块文件名(如 my_tool.wasm),不是 my_tool → 帮助信息里的命令名直接写死,或用 wasmtime --argv0 指定
  • exit code:panic 在 wasm32-wasip1 上以 unreachable trap 结束,wasmtime CLI 的退出码是 134(实测),不是普通的 1;main 返回 Err 则是 proc_exit(1)。想要可控的退出码,用 Result 或 process::exit(n),别靠 panic
  • 浮点:sin/cos 这类超越函数在 WASI 上用的是 musl 系的数学库实现,与原生平台(如 glibc)的 libm 在末位可能不同 → 关键场景显式校验

12.16.6 移植决策树

flowchart TD
  A["要把 Linux 应用搬到 WASI?"] --> B{"应用核心是计算还是 IO?"}
  B -->|纯计算| C["✓ 强烈推荐"]
  B -->|计算+文件| D["✓ 推荐"]
  B -->|计算+网络| E{"网络模式?"}
  B -->|系统编程| F["✗ 不推荐"]
  E -->|HTTP 客户端/服务端| G["✓ 用 wasi:http"]
  E -->|TCP/UDP socket| H["△ 用 P2 的 wasi:sockets"]
  E -->|raw socket/IPv6 RAW| F
  style C fill:#10b981,color:#333
  style D fill:#10b981,color:#333
  style G fill:#10b981,color:#333
  style H fill:#f59e0b,color:#333
  style F fill:#dc2626,color:#fff

按这套决策树先过一遍,能在投入之前就发现走不通的路。

12.17 WASI 在 IoT 与嵌入式场景

WASM + WASI 在嵌入式领域有独特的吸引力——比传统 OS 容器轻量、比裸机汇编更易开发、安全性比解释型语言(Lua)更强。Bytecode Alliance 的 WAMR(WebAssembly Micro Runtime)就是为这场景而生。

12.17.1 IoT/嵌入式的特殊约束

graph TD
  A["IoT 设备约束"] --> B["内存"]
  A --> C["存储"]
  A --> D["CPU"]
  A --> E["功耗"]
  B --> B1["64KB - 64MB RAM<br/>典型 256KB-1MB"]
  C --> C1["Flash 256KB - 16MB"]
  D --> D1["MHz 级 ARM Cortex-M<br/>or RISC-V"]
  E --> E1["电池供电常见<br/>每微秒都珍贵"]
  style B fill:#dc2626,color:#fff
  style D fill:#dc2626,color:#fff

这些约束让传统的"完整 Linux + 容器"路径不可行。WASM 是少数能同时满足轻量+安全+多语言的方案。

12.17.2 WAMR:嵌入式 WASM 运行时

WAMR 是 Bytecode Alliance 维护的轻量 WASM 运行时,专为嵌入式设计:

维度 WAMR Wasmtime
二进制大小 几十 KB 级(见下) MB 级
执行模式 解释 / AOT / JIT(Fast JIT、LLVM JIT) Cranelift 或 Winch 编译(可 AOT 成 .cwasm)/ Pulley 解释器
目标平台 RTOS / Linux / 裸机 主流 OS
WASI 支持 Preview 1(libc-wasi) Preview 1 + Preview 2

WAMR README 给出的体积(Cortex-M4F 上 core vmlib 的代码段):快速解释器约 58.9K、经典解释器约 56.3K、AoT 运行时约 29.4K,libc-wasi 库另加约 21.4K(2026-09-24 核对)。WAMR 的运行时二进制比 Wasmtime 小一到两个数量级(WAMR 主打的就是「footprint 小到能进 MCU」,而 Wasmtime 带着 Cranelift 这套完整编译后端——第 4 章 §4.13 数过 cranelift 的代码规模),这是 IoT 部署选它的主要原因。具体体积随构建配置(解释器/AOT/JIT、裁剪选项)差别很大,选型前按自己的配置量一遍。

12.17.3 实战:用 WAMR 跑 Rust WASM

# 1. 编译 Rust 到 wasm32-wasip1
cargo build --target wasm32-wasip1 --release

# 2. 用 WAMR 的 AOT 编译器预编译(设备上通常不做 JIT)
wamrc --target=arm \
    --output=/tmp/my_app.aot \
    target/wasm32-wasip1/release/my_app.wasm

# 3. 部署到设备(通常通过 OTA)
# 设备上的 WAMR 加载 AOT 文件并执行

设备端 C 代码:

// 示意:省略了错误检查;read_file 代表"把文件读进内存并返回长度"
#include "wasm_export.h"

int main(void) {
    char error_buf[128];
    wasm_runtime_init();

    // 加载 AOT 文件
    uint32_t file_size;
    uint8_t *buf = read_file("/firmware/my_app.aot", &file_size);
    wasm_module_t module = wasm_runtime_load(
        buf, file_size, error_buf, sizeof(error_buf));

    // WASI 模块要在实例化前给出预打开目录、环境变量和参数(这里全部为空)
    wasm_runtime_set_wasi_args(module, NULL, 0, NULL, 0, NULL, 0, NULL, 0);

    wasm_module_inst_t instance = wasm_runtime_instantiate(
        module, 16384, 16384, error_buf, sizeof(error_buf));

    // 执行 WASI 命令模块的 main
    wasm_application_execute_main(instance, 0, NULL);

    wasm_runtime_deinstantiate(instance);
    wasm_runtime_unload(module);
    wasm_runtime_destroy();
    return 0;
}

(函数签名按 WAMR 仓库主分支的 wasm_export.h 头文件,2026-09-24 核对。)

12.17.4 IoT 场景的应用模式

graph TB
  subgraph "传感器节点"
    S1["温湿度传感器"] --> S2["WAMR + WASM"]
    S2 --> S3["数据预处理"]
    S3 --> S4["MQTT/CoAP 上传"]
  end
  subgraph "网关"
    G1["WAMR + WASM"] --> G2["数据聚合"]
    G2 --> G3["边缘 AI 推理"]
    G3 --> G4["告警决策"]
  end
  subgraph "云端"
    C1["数据接收"] --> C2["持久化"]
  end
  S4 --> G1
  G4 --> C1
  style S2 fill:#10b981,color:#333
  style G1 fill:#10b981,color:#333

WASM 在三个层次都有价值:

  • 传感器节点:用 Rust 写数据预处理(滤波、归一化),编译为 WASM 部署。比 C 安全,比 Python 轻量。
  • 边缘网关:跑多个 WASM 模块(每个供应商的传感器一个模块),相互隔离。模块崩溃不影响其他。
  • 远程更新(OTA):发新 .wasm 更新业务逻辑——比重刷整个镜像快,新模块也仍然跑在沙箱里。

12.17.5 嵌入式 WASM 的限制

不是所有嵌入式场景都适合 WASM:

不适合 理由
极致实时(< 1μs) WASM 解释/JIT 都有开销
极致低功耗(μA 级) 运行时本身耗电
只有几十 KB Flash 的设备 WAMR 解释器本身就要五六十 KB 代码段,还得留给应用和数据
直接 GPIO 控制 需要 host 提供 syscall
中断处理 WASM 不支持中断

WASM 的甜点:256KB 内存以上的设备 + 软实时(ms 级延迟)+ 业务逻辑可隔离——这覆盖了消费 IoT 和工业 IoT 的大量场景。

12.17.6 OTA 更新的安全模型

WASM 的安全保证让 OTA 更新可靠:

flowchart LR
  A["云端发新 .wasm"] --> B["设备签名验证"]
  B --> C["WAMR 验证 + AOT 编译"]
  C --> D["新模块替换旧"]
  D --> E["运行时若崩溃<br/>沙箱限制损失"]
  E --> F["回滚到上一版本"]
  style B fill:#6366f1,color:#fff
  style E fill:#10b981,color:#333
  style F fill:#f59e0b,color:#333

关键流程:

  1. 云端用 sigstore/cosign 签名 .wasm
  2. 设备验证签名后才加载
  3. WAMR 验证字节码合法性(拒绝畸形)
  4. 新模块运行——如果崩溃,沙箱限制损失到该模块
  5. 回滚到上一版本(保存最近 N 版本)

这套机制比传统 OTA 安全得多——传统镜像替换出问题可能砖机,WASM 模块崩溃只影响业务逻辑。

12.17.7 工程实践清单

flowchart TD
  A["IoT WASM 部署"] --> B["1. 选 WAMR 而非 Wasmtime"]
  A --> C["2. AOT 编译,避免设备端 JIT"]
  A --> D["3. 模块体积 < 100KB"]
  A --> E["4. 严格资源限制(fuel + 内存)"]
  A --> F["5. OTA 必须签名"]
  A --> G["6. 多模块隔离不同供应商"]
  A --> H["7. 监控异常指标"]
  style A fill:#6366f1,color:#fff

每条都对应嵌入式工程的特定需求——遵循这套清单能让 WASM 在 IoT 场景真正发挥价值。

12.18 WASI 应用的性能调优

WASI 给业务带来了能力安全 + 多语言互操作 + 跨平台——但也引入了独特的性能特征。理解 WASI 的开销来源和调优手段是生产部署的关键技能。

12.18.1 WASI 性能的关键瓶颈

graph TD
  A["WASI 应用的性能瓶颈"] --> B["host 调用开销"]
  A --> C["内存复制"]
  A --> D["边界检查"]
  A --> E["实例化时间"]
  B --> B1["每次 fd_read 都有固定开销<br/>小块读写累计可观"]
  C --> C1["数据进出 WASM 必须复制"]
  D --> D1["内存访问的边界检查<br/>(64 位宿主多靠保护页消除)"]
  E --> E1["冷启动的大头是编译"]
  style B fill:#f59e0b,color:#333
  style E fill:#dc2626,color:#fff

每个瓶颈的优化角度不同——必须先用 profiler 定位,再针对性优化。

12.18.2 host 调用的批处理

频繁 host 调用是常见瓶颈。批处理消除单次调用的固定开销:

// 依赖 wasi = "0.11"(P1 的原始绑定,函数都是 unsafe、返回 Result)
// 反模式:每次读 1 字节
fn slow_read(fd: u32, n: usize) -> Vec<u8> {
    let mut buf = Vec::with_capacity(n);
    for _ in 0..n {
        let mut byte = [0u8; 1];
        let iov = wasi::Iovec { buf: byte.as_mut_ptr(), buf_len: 1 };
        unsafe { wasi::fd_read(fd, &[iov]).expect("fd_read") };
        buf.push(byte[0]);
    }
    buf
}

// 推荐:一次读全部(fd_read 可能少读,真实代码要循环到读满或读到 0)
fn fast_read(fd: u32, n: usize) -> Vec<u8> {
    let mut buf = vec![0u8; n];
    let iov = wasi::Iovec { buf: buf.as_mut_ptr(), buf_len: n };
    let got = unsafe { wasi::fd_read(fd, &[iov]).expect("fd_read") };
    buf.truncate(got);
    buf
}

本机实测(Xeon Gold 6240,wasi 0.11.1、Rust 1.96.0 --release、wasmtime 44.0.0 CLI,从预打开目录里一个 10,000 字节的文件读):slow_read 约 7.5 ms,即每次 1 字节的 fd_read 约 750 ns;fast_read 只要 2–7 µs,差了三个数量级。差距来自每次调用都要跨一次 WASM/宿主边界、经过 P1→P2 适配层,并在宿主侧再做一次文件读取——批处理把这些固定开销摊掉了。

12.18.3 流式处理避免大块复制

读取大文件时,不要全读入内存:

// 片段:Stats / compute_stats 为示意类型与函数,Result 取 std::io::Result
use std::io::{Read, Result};

// 反模式:全读再处理
fn process_file(path: &str) -> Result<Stats> {
    let data = std::fs::read(path)?;  // 复制全文件到内存
    compute_stats(&data)
}
// 1GB 文件需要 1GB 内存

// 推荐:流式读取
fn process_file_streaming(path: &str) -> Result<Stats> {
    let mut file = std::fs::File::open(path)?;
    let mut stats = Stats::default();
    let mut buf = vec![0u8; 64 * 1024];  // 64KB 缓冲

    loop {
        let n = file.read(&mut buf)?;
        if n == 0 { break; }
        stats.update(&buf[..n]);
    }
    Ok(stats)
}
// 64KB 内存即可处理任意大小文件

Preview 2 把流做成了一等接口——wasi:io/streams 的输入/输出流是独立的资源类型,可以跨接口传递,比 fd-based API 更适合表达流式处理。

12.18.4 实例化时间优化

WASI 应用的冷启动主要由编译时间决定:

flowchart LR
  A["请求到达"] --> B["1. 加载 .wasm"]
  B --> C["2. 编译"]
  C --> D["3. WASI ctx 初始化"]
  D --> E["4. 实例化"]
  E --> F["5. 业务执行"]
  C --> G["AOT 缓存可绕过"]
  style C fill:#dc2626,color:#fff
  style G fill:#10b981,color:#333

各阶段的优化:

阶段 优化手段 收益量级
加载 mmap 而非 read 少一次全量拷贝
编译 AOT 预编译 + Module::deserialize 最大的一块:整个 Cranelift 编译阶段被挪到构建期
WASI ctx 复用 ctx 模板 省掉重复的 builder 构造
实例化 InstancePre + pooling allocator + Config::memory_init_cow 实例化降到"从池里取内存和表"

AOT 是最大杀手锏——冷启动里占比最高的就是编译,把它挪走之后剩下的量级完全不同。四项对应的官方说明见 Wasmtime 仓库 docs/examples-fast-instantiation.md;具体数值随模块大小和机器变化,务必自己量。

12.18.5 内存预分配

WASI 应用通常需要大量临时内存——不预分配会触发频繁 grow:

// 反模式:边读边 grow
fn collect_lines(fd: u32) -> Vec<String> {
    let mut lines = Vec::new();  // 容量 0
    // ... 读取 + push ...
    // 100k 行需要 ~17 次 grow
}

// 推荐:预估容量
fn collect_lines_pre(fd: u32, estimated: usize) -> Vec<String> {
    let mut lines = Vec::with_capacity(estimated);
    // ...
}

Vec::with_capacity 一次分配——避免多次 grow 的累积开销(§3.11)。

12.18.6 跨调用复用:复用 InstancePre,而不是复用实例

直觉上会想把 Instance 放进池子里反复用,但在 Wasmtime 里这条路走不通:Instance 只是它所属 Store 里的一个句柄,离开那个 Store 就没有意义;而一个用过的实例,线性内存、全局变量里都留着上一个请求的状态,Wasmtime 也没有"把实例重置回初始状态"的 API——把它交给下一个租户,就是把上一个租户的数据交给他。

Wasmtime 的复用单位是 InstancePre 加上 pooling allocator 的内存槽:导入解析和类型检查只做一次,每个请求新建一个 Store 和实例,用完整个丢掉,内存和表归还池子。

// 启动时做一次
let pre = linker.instantiate_pre(&module)?;

// 每个请求
let wasi = WasiCtxBuilder::new().build_p1();
let mut store = Store::new(&engine, wasi);
let instance = pre.instantiate(&mut store)?;   // 本机实测约 0.07–0.08 ms(§12.10)
let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
start.call(&mut store, ())?;
// store 在这里被丢弃,实例的全部状态随之消失

这样"每请求一个实例"的开销主要就只剩实例化本身,而实例之间天然隔离。

12.18.7 性能调优检查清单

flowchart TD
  A["WASI 性能调优"] --> B["1. host 调用是否批量?"]
  A --> C["2. 大文件流式处理?"]
  A --> D["3. AOT 缓存启用?"]
  A --> E["4. 内存预分配?"]
  A --> F["5. InstancePre + pooling?"]
  A --> G["6. fuel 限制合理?"]
  A --> H["7. 性能基准回归测试?"]
  style A fill:#6366f1,color:#fff

上线前过一遍这张清单,再结合 §13.16 对运行时差异与基准方法的分析,能避开大部分常见的性能问题。

12.19 WASI 在云原生生态的位置

WASI 不是孤立技术——它是云原生生态的一部分。理解 WASI 与其他 CNCF 项目的关系,有助于判断它在长期技术栈中的位置。

12.19.1 云原生生态全景

graph TD
  A["云原生生态"] --> B["编排层"]
  A --> C["运行时层"]
  A --> D["可观测层"]
  A --> E["服务网格层"]
  A --> F["函数计算层"]
  B --> B1["Kubernetes / Nomad"]
  C --> C1["containerd / Wasmtime"]
  D --> D1["Prometheus / OpenTelemetry"]
  E --> E1["Envoy / Istio (with proxy-wasm)"]
  F --> F1["Knative / Spin / OpenFaaS"]
  style C fill:#10b981,color:#333
  style E fill:#6366f1,color:#fff
  style F fill:#f59e0b,color:#333

WASI 在每一层都有对应位置:

层 WASI 集成点 主要项目
编排 runtimeClassName: wasm Kubernetes + runwasi
运行时 Wasmtime / WAMR Bytecode Alliance
可观测 wasi-logging / wasi-otel 提案(均在 Phase 1) 尚无定稿接口,见第 13 章 §13.15.4
服务网格 proxy-wasm filter Envoy + Istio
函数计算 Spin / SpinKube CNCF 沙盒项目(Spin 2025-01 进入)

12.19.2 CNCF 中的 WASM/WASI 项目

graph TD
  A["CNCF WASM 相关项目"] --> B["孵化中"]
  A --> C["沙盒"]
  A --> D["生态"]
  B --> B1["wasmCloud<br/>WASM 组件编排"]
  C --> C1["Spin<br/>轻量 FaaS"]
  C --> C2["WasmEdge<br/>云原生 WASM 运行时"]
  D --> D1["containerd-shim-wasm<br/>K8s 集成"]
  D --> D2["proxy-wasm<br/>Envoy 扩展"]
  style B fill:#10b981,color:#333

CNCF 里的 WASM 项目由来已久:WasmEdge(2021-04)、wasmCloud(2021-07)先后进入沙盒,wasmCloud 于 2024-11 升入孵化,Spin 于 2025-01 进入沙盒(日期取自 CNCF landscape 的项目元数据,2026-09-24 核对)。

12.19.3 WASI 与 Service Mesh

Service mesh 是 WASM 在生产中的重要部署场景之一(Envoy/Istio 走 proxy-wasm):

flowchart TB
  subgraph "Service Mesh + WASM"
    A["请求"] --> B["Envoy sidecar"]
    B --> C["proxy-wasm filter 链"]
    C --> D["filter 1: 认证(WASM)"]
    D --> E["filter 2: 限流(WASM)"]
    E --> F["filter 3: 自定义业务(WASM)"]
    F --> G["上游服务"]
  end
  style C fill:#10b981,color:#333

Istio 提供 WasmPlugin API,把 WASM 作为 filter 扩展的标准方式——取代早期的 Mixer 适配器扩展模型。注意 proxy-wasm 是 Envoy 生态自己的一套 ABI,并不是 WASI。

12.19.4 WASM/WASI 在不同行业的落地

行业 典型案例
CDN / 边缘计算 Fastly Compute(Wasmtime + WASI)、Cloudflare Workers(在 V8 里跑 WASM,系统能力走 JS API 而非 WASI)
服务网格 Envoy、Istio(proxy-wasm)
区块链 Polkadot、NEAR、CosmWasm(用 WASM 执行合约,接口是各链自定义的,不走 WASI)

表里多数例子用的是 WASM 本身而不是 WASI——共同点是"需要安全沙箱 + 多语言支持 + 轻量级"。

12.19.5 给工程团队的判断框架

flowchart TD
  A["何时考虑 WASI"] --> B{"业务场景?"}
  B --> C["插件系统 / 用户自定义"]
  B --> D["边缘函数"]
  B --> E["多语言后端"]
  B --> F["传统单体 Web 服务"]
  C --> G["✓ 考虑"]
  D --> G
  E --> G
  F --> H["不必"]
  style G fill:#10b981,color:#333
  style H fill:#dc2626,color:#fff

不是所有项目都该用 WASI——只有上述特定场景才有清晰收益。把它放在"工具箱中的一种工具"位置——按场景选用,避免技术追新。

12.20 WASI 应用的测试策略

测试是 WASI 应用上线前的最后一道防线——但 WASI 的特殊性(沙箱、能力安全、跨平台)让测试比传统应用复杂。这一节整理 WASI 应用的完整测试策略。

12.20.1 WASI 测试的特殊性

graph TD
  A["WASI 测试挑战"] --> B["环境模拟"]
  A --> C["能力配置"]
  A --> D["跨运行时一致性"]
  A --> E["性能基准"]
  B --> B1["wasi-libc / Wasmtime mock"]
  C --> C1["不同能力组合的 deny/allow"]
  D --> D1["Wasmtime / Wasmer / WAMR 行为一致"]
  E --> E1["运行时性能差异"]
  style A fill:#f59e0b,color:#333

每条挑战都需要专门测试基础设施。

12.20.2 测试金字塔(WASI 版)

graph TD
  A["WASI 测试金字塔"] --> B["单元测试"]
  A --> C["集成测试"]
  A --> D["端到端测试"]
  A --> E["跨运行时测试"]
  B --> B1["纯 Rust,cargo test"]
  C --> C1["Wasmtime 嵌入测试"]
  D --> D1["真实 WASI 环境部署"]
  E --> E1["多 runtime 行为验证"]
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333
  style E fill:#dc2626,color:#fff

各级别投入比例(经验性建议,不是定数):

  • 单元测试 60%(最快,最便宜)
  • 集成测试 25%
  • E2E 10%
  • 跨 runtime 5%(仅做核心场景)

12.20.3 单元测试:cargo test

// 业务逻辑(不依赖 WASI)
mod core {
    pub fn process(input: &[u8]) -> Vec<u8> {
        input.iter().map(|&b| b.wrapping_add(1)).collect()
    }
}

#[cfg(test)]
mod tests {
    use super::core;

    #[test]
    fn test_process_basic() {
        assert_eq!(core::process(b"abc"), vec![b'b', b'c', b'd']);
    }

    #[test]
    fn test_process_empty() {
        assert_eq!(core::process(&[]), Vec::<u8>::new());
    }
}

把业务逻辑与 WASI 调用分离——业务逻辑直接用原生 cargo test 跑,不必每次编成 wasm 再进运行时;需要在 WASI 上跑测试时,也可以 cargo test --target wasm32-wasip1,并把 CARGO_TARGET_WASM32_WASIP1_RUNNER 设为 wasmtime。

12.20.4 集成测试:Wasmtime 嵌入

// integration_test.rs
use wasmtime::{Engine, Linker, Module, Result, Store};
use wasmtime_wasi::p1::{self, WasiP1Ctx};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};

#[test]
fn test_wasi_app_processes_file() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "target/wasm32-wasip1/release/my_app.wasm")?;

    let wasi = WasiCtxBuilder::new()
        .preopened_dir("test_data", "/data", DirPerms::all(), FilePerms::all())?
        .build_p1();

    let mut store = Store::new(&engine, wasi);
    let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
    p1::add_to_linker_sync(&mut linker, |t| t)?;

    let instance = linker.instantiate(&mut store, &module)?;
    let main = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
    main.call(&mut store, ())?;

    // 验证输出:模块写的 /data/output.txt 就是宿主上的 test_data/output.txt
    let output = std::fs::read("test_data/output.txt")?;
    assert_eq!(output, b"expected result");
    Ok(())
}

这种测试在真实 Wasmtime 中跑——验证 WASI 调用的端到端正确性。

12.20.5 能力测试:deny/allow 矩阵

WASI 的能力配置是常见漏洞源——必须测试不同能力组合下的行为:

use wasmtime_wasi::I32Exit;

// run_wasi_app:按上一节的方式实例化并调用 _start,返回 wasmtime::Result<()>
#[test]
fn test_no_filesystem_access_fails() {
    let wasi = WasiCtxBuilder::new().build_p1();  // 不给 preopen
    let err = run_wasi_app(wasi).unwrap_err();
    // 模块里 open 失败(noent)→ main 返回 Err → proc_exit(1)
    assert_eq!(err.downcast_ref::<I32Exit>().map(|e| e.0), Some(1));
}

#[test]
fn test_only_read_filesystem() -> wasmtime::Result<()> {
    let wasi = WasiCtxBuilder::new()
        .preopened_dir(
            "test_data",
            "/data",
            DirPerms::READ,  // 仅读,没有 MUTATE
            FilePerms::READ,
        )?
        .build_p1();
    // 写文件被拒(perm)→ 同样以 proc_exit(1) 结束
    let err = run_wasi_app_writes_file(wasi).unwrap_err();
    assert_eq!(err.downcast_ref::<I32Exit>().map(|e| e.0), Some(1));
    Ok(())
}

断言不能靠匹配错误字符串:模块内部的 io::Error 只会被打印到它自己的 stderr,宿主拿到的是 _start 的结果——main 返回 Err 时是 I32Exit(1),panic 时则是 unreachable trap(实测两个场景都返回 I32Exit(1),guest 的 stderr 分别是 NotFound(44)和 PermissionDenied(63))。

每个能力配置都要测——确保 deny 真的 deny、allow 真的 allow。

12.20.6 跨运行时测试

// WasiRuntime / WasmtimeRuntime 等是项目自己封装的测试抽象,不是现成的库
fn test_with_runtime<R: WasiRuntime>(runtime: &R) -> Result<()> {
    let result = runtime.run("my_app.wasm", &test_input())?;
    assert_eq!(result, expected_output());
    Ok(())
}

#[test]
fn test_wasmtime() -> Result<()> {
    let rt = WasmtimeRuntime::new();
    test_with_runtime(&rt)
}

#[test]
fn test_wasmer() -> Result<()> {
    let rt = WasmerRuntime::new();
    test_with_runtime(&rt)
}

#[test]
fn test_wamr() -> Result<()> {
    let rt = WamrRuntime::new();
    test_with_runtime(&rt)
}

让同一份 .wasm 在多个 runtime 跑——保证行为一致。这对发布到不同平台的 WASI 应用至关重要。

12.20.7 性能基准测试

// #[bench] 需要 nightly 的 #![feature(test)];稳定版上可换 criterion
#[bench]
fn bench_wasi_file_io(b: &mut test::Bencher) {
    let runtime = WasmtimeRuntime::new();
    b.iter(|| {
        runtime.run("io_benchmark.wasm", &test_input());
    });
}

把性能 SLO 写进基准测试——任何回归 > 5% 在 CI 中报警。

12.20.8 模糊测试(fuzzing)

// fuzz/fuzz_targets/wasi_input.rs
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    let runtime = WasmtimeRuntime::new();
    // 不应该 panic 或 trap,无论 input 是什么
    let _ = runtime.run_with_input("my_app.wasm", data);
});

cargo fuzz 让 WASI 应用被自动测试——防御不可信输入引发的 trap。

12.20.9 测试覆盖率

flowchart TD
  A["WASI 测试覆盖率目标"] --> B["1. 业务逻辑 > 90%"]
  A --> C["2. WASI 调用路径 > 80%"]
  A --> D["3. 能力配置 100% 关键路径"]
  A --> E["4. 跨 runtime 100% 核心 API"]
  A --> F["5. fuzz 持续运行"]
  style A fill:#10b981,color:#333

能力配置错误、跨 runtime 行为不一致、未测试的输入边界,都是会让 WASI 应用在线上出问题的地方。

12.20.10 CI 集成模板

# .github/workflows/wasi-test.yml
- name: Unit tests
  run: cargo test

- name: Integration tests with Wasmtime
  run: cargo test --test integration

- name: Cross-runtime tests
  run: |
    cargo test --test integration --features wasmer
    cargo test --test integration --features wamr

- name: Fuzz test (10 minutes)
  run: timeout 600 cargo fuzz run wasi_input

- name: Benchmark + regression check(criterion)
  run: cargo bench -- --baseline main

这套 CI 模板让 WASI 应用的质量可以被持续检查,而不是只靠上线前手动测一下。

下一章深入 WASI Preview 2 的 WIT 接口定义和组件模型——这是 WASI 走向工程化的关键一步。