Skip to content

Commit 763e262

Browse files
hyperpolymathclaude
andcommitted
feat(gate): typed-wasm-gate — load-time enforcement with a witness type (Phase 3 slice)
Build-time verification trusts whoever ran the build; this moves the trust boundary to the LOADER. New workspace crate typed-wasm-gate: - VerifiedModule is a witness type: its only constructors are gate_module / gate_link_graph, which run the full verifier stack (structural validation, L7/L10 ownership, L2 bounds + access typing, L13 import consistency; + cross-module SchemaSub certification for graphs per ADR-0007, certificates attached to consumers' reports). - Instantiation adapters accept ONLY &VerifiedModule — a violating module cannot reach a runtime through this API because its witness never exists. wasmi adapter (default feature, pure Rust) is executed end-to-end in CI; a wasmtime adapter is the documented follow-up and needs nothing beyond VerifiedModule::bytes(). - tests: honest ex03 gates + instantiates + RUNS (typed_sites_checked > 0); a double-free mutant is refused at the gate; the foreign Zig producer's violating fixture is refused identically (producer- neutral); the split game graph gates with certificates and a schema-mutant graph is refused. Workspace: 185 tests, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5d67603 commit 763e262

6 files changed

Lines changed: 331 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ resolver = "2"
44
members = [
55
"crates/typed-wasm-verify",
66
"crates/typed-wasm-codegen",
7+
"crates/typed-wasm-gate",
78
]

crates/typed-wasm-gate/Cargo.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
[package]
3+
name = "typed-wasm-gate"
4+
version = "0.1.0"
5+
edition = "2021"
6+
license = "MPL-2.0"
7+
description = "Load-time enforcement gate: refuse to instantiate wasm that fails typed-wasm verification (Phase 3 slice)"
8+
repository = "https://github.com/hyperpolymath/typed-wasm"
9+
readme = "README.md"
10+
11+
[features]
12+
default = ["wasmi-runtime"]
13+
# In-process instantiation adapter backed by wasmi (pure Rust — runs in
14+
# CI without a system runtime). A wasmtime adapter is the documented
15+
# follow-up; the gate API is runtime-agnostic by construction.
16+
wasmi-runtime = ["dep:wasmi"]
17+
18+
[dependencies]
19+
typed-wasm-verify = { path = "../typed-wasm-verify", features = ["unstable-l2", "unstable-l13-imports"] }
20+
thiserror = "2"
21+
wasmi = { version = "1.1.0", optional = true }
22+
23+
[dev-dependencies]
24+
typed-wasm-codegen = { path = "../typed-wasm-codegen" }
25+
wasmi = "1.1.0"

crates/typed-wasm-gate/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
2+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> -->
3+
# typed-wasm-gate
4+
5+
Load-time enforcement for typed-wasm — the Phase 3 slice (runtime-side
6+
enforcement). Build-time verification trusts whoever ran the build; the
7+
gate moves the trust boundary to the **loader**:
8+
9+
```rust
10+
let verified = typed_wasm_gate::gate_module(&bytes)?; // full verifier stack
11+
let instance = wasmi_runtime::instantiate_verified(&engine, &mut store, &linker, &verified)?;
12+
```
13+
14+
`VerifiedModule` is a witness type: its only constructors are
15+
`gate_module` / `gate_link_graph`, which run structural validation,
16+
L7/L10 ownership/linearity, L2 carrier bounds + access typing, and L13
17+
region-import consistency (plus cross-module `SchemaSub` certification
18+
for graphs, ADR-0007). The instantiation adapters accept only
19+
`&VerifiedModule` — a violating module cannot reach a runtime through
20+
this API because its witness never exists.
21+
22+
The gate itself is runtime-agnostic (bytes in, witness + `GateReport`
23+
out). The `wasmi-runtime` feature (default) ships a pure-Rust in-process
24+
adapter that CI executes end-to-end; a **wasmtime adapter** is the
25+
intended follow-up and needs nothing beyond what the wasmi one uses —
26+
compile the module from `VerifiedModule::bytes()` and instantiate.

crates/typed-wasm-gate/src/lib.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
//! Load-time enforcement gate — the Phase 3 slice (runtime-side
5+
//! enforcement, issue #51).
6+
//!
7+
//! Build-time verification (`tw build` self-verify, `tw link`) trusts
8+
//! whoever ran the build. This crate moves the trust boundary to the
9+
//! LOADER: a [`VerifiedModule`] is a witness type whose only
10+
//! constructors are [`gate_module`] / [`gate_link_graph`], which run
11+
//! the full typed-wasm verifier stack over the raw bytes — structural
12+
//! validation, L7/L10 ownership/linearity, L2 carrier bounds + access
13+
//! typing, L13 region-import consistency, and (for graphs) cross-module
14+
//! `SchemaSub` certification. Instantiation adapters accept only
15+
//! `&VerifiedModule`, so an unverified or violating module cannot reach
16+
//! a runtime through this crate's API at all.
17+
//!
18+
//! The gate is runtime-agnostic: it consumes bytes and returns a
19+
//! witness + [`GateReport`]. The `wasmi-runtime` feature provides an
20+
//! in-process adapter (pure Rust, CI-testable); a wasmtime adapter is
21+
//! the documented follow-up and needs nothing from the gate beyond
22+
//! what `wasmi::instantiate_verified` already uses.
23+
24+
use thiserror::Error;
25+
use typed_wasm_verify::{
26+
verify_access_sites_from_module, verify_access_typing_from_module, verify_from_module,
27+
verify_link_graph, verify_region_imports_from_module, AccessSiteError, CompatCertificate,
28+
RegionImportsError, VerifyError,
29+
};
30+
31+
/// Why the gate refused a module (first failing layer reported).
32+
#[derive(Debug, Error)]
33+
pub enum GateError {
34+
/// Not decodable as wasm at all, or an L7/L10/L13-negative
35+
/// ownership violation (see the inner error).
36+
#[error("ownership/linearity verification failed: {0}")]
37+
Ownership(#[from] VerifyError),
38+
39+
/// L2 carrier bounds violations (`typedwasm.regions` /
40+
/// `typedwasm.access-sites` internal consistency).
41+
#[error("L2 access-site bounds violations: {0:?}")]
42+
AccessSites(Vec<AccessSiteError>),
43+
44+
/// L2 access-typing violations: a pinned instruction is not the
45+
/// right memory op at the right offset for its claimed field.
46+
#[error("L2 access-typing violations: {0:?}")]
47+
AccessTyping(Vec<String>),
48+
49+
/// L13 region-import violations — module-local inconsistency, or
50+
/// (in a link graph) unresolved producers / schema disagreement.
51+
#[error("L13 region-import violations: {0:?}")]
52+
RegionImports(Vec<RegionImportsError>),
53+
}
54+
55+
/// What the gate established about a module it passed.
56+
#[derive(Debug, Clone, Default)]
57+
pub struct GateReport {
58+
/// Pinned access sites whose instruction-level typing was checked.
59+
pub typed_sites_checked: u32,
60+
/// Declared-only (unpinned) access sites — counted, not checked.
61+
pub declared_only_sites: u32,
62+
/// Cross-module certificates this module participates in as the
63+
/// consumer (link-graph gate only; empty for single-module gates).
64+
pub certificates: Vec<CompatCertificate>,
65+
}
66+
67+
/// Witness that `bytes` passed the gate. The field is private and the
68+
/// only constructors run the verifier stack — possession of a
69+
/// `VerifiedModule` IS the proof of verification.
70+
#[derive(Debug, Clone)]
71+
pub struct VerifiedModule {
72+
bytes: Vec<u8>,
73+
report: GateReport,
74+
}
75+
76+
impl VerifiedModule {
77+
pub fn bytes(&self) -> &[u8] {
78+
&self.bytes
79+
}
80+
pub fn report(&self) -> &GateReport {
81+
&self.report
82+
}
83+
}
84+
85+
/// Run the single-module verifier stack; a `VerifiedModule` comes back
86+
/// only if every layer passes.
87+
pub fn gate_module(bytes: &[u8]) -> Result<VerifiedModule, GateError> {
88+
// L7 / L10 / L13-negative (also rejects undecodable bytes).
89+
verify_from_module(bytes)?;
90+
91+
// L2 carrier bounds.
92+
let bounds = verify_access_sites_from_module(bytes)?;
93+
if !bounds.is_empty() {
94+
return Err(GateError::AccessSites(bounds));
95+
}
96+
97+
// L2 access typing on pinned sites.
98+
let typing = verify_access_typing_from_module(bytes)?;
99+
if !typing.errors.is_empty() {
100+
return Err(GateError::AccessTyping(
101+
typing.errors.iter().map(|e| e.to_string()).collect(),
102+
));
103+
}
104+
105+
// L13 module-local import-table consistency.
106+
let import_errs = verify_region_imports_from_module(bytes)?;
107+
if !import_errs.is_empty() {
108+
return Err(GateError::RegionImports(import_errs));
109+
}
110+
111+
Ok(VerifiedModule {
112+
bytes: bytes.to_vec(),
113+
report: GateReport {
114+
typed_sites_checked: typing.type_verified,
115+
declared_only_sites: typing.declared_only,
116+
certificates: Vec::new(),
117+
},
118+
})
119+
}
120+
121+
/// Gate a whole link graph: every module passes the single-module gate
122+
/// AND cross-module schema agreement holds (`verify_link_graph`,
123+
/// ADR-0007). Returns the witnesses in input order, each consumer's
124+
/// certificates attached to its report.
125+
pub fn gate_link_graph(
126+
named: &[(&str, &[u8])],
127+
) -> Result<Vec<(String, VerifiedModule)>, GateError> {
128+
let mut gated: Vec<(String, VerifiedModule)> = Vec::new();
129+
for (name, bytes) in named {
130+
gated.push((name.to_string(), gate_module(bytes)?));
131+
}
132+
let report = verify_link_graph(named)?;
133+
if !report.errors.is_empty() {
134+
return Err(GateError::RegionImports(report.errors));
135+
}
136+
for cert in report.certificates {
137+
if let Some((_, module)) = gated.iter_mut().find(|(n, _)| *n == cert.consumer) {
138+
module.report.certificates.push(cert);
139+
}
140+
}
141+
Ok(gated)
142+
}
143+
144+
/// In-process instantiation adapter backed by wasmi. Accepts only the
145+
/// gate's witness type — this is the enforcement point.
146+
#[cfg(feature = "wasmi-runtime")]
147+
pub mod wasmi_runtime {
148+
use super::VerifiedModule;
149+
150+
/// Load + instantiate a verified module in a wasmi store. All
151+
/// wasmi-level errors pass through untouched; what this adapter
152+
/// adds is the TYPE-LEVEL guarantee that `module` went through the
153+
/// gate.
154+
pub fn instantiate_verified(
155+
engine: &wasmi::Engine,
156+
store: &mut wasmi::Store<()>,
157+
linker: &wasmi::Linker<()>,
158+
module: &VerifiedModule,
159+
) -> Result<wasmi::Instance, wasmi::Error> {
160+
let compiled = wasmi::Module::new(engine, module.bytes())?;
161+
linker.instantiate_and_start(store, &compiled)
162+
}
163+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Load-time enforcement: honest modules gate + instantiate + RUN;
5+
// violating modules are refused BEFORE any runtime sees them. The
6+
// producer inputs come from the real front-end (parse .twasm source →
7+
// emit), so this is the whole pipeline: source → bytes → gate →
8+
// instantiate → execute.
9+
10+
#![cfg(feature = "wasmi-runtime")]
11+
12+
use typed_wasm_codegen::{emit, parser, Op};
13+
use typed_wasm_gate::{gate_link_graph, gate_module, wasmi_runtime::instantiate_verified, GateError};
14+
use wasmi::{Engine, Linker, Store, TypedFunc};
15+
16+
const EX03: &str = include_str!("../../../examples/03-ownership-linearity.twasm");
17+
const GAME: &str = include_str!("../../typed-wasm-codegen/tests/fixtures/multimodule/game.twasm");
18+
const ZIG_DOUBLE: &[u8] =
19+
include_bytes!("../../typed-wasm-verify/tests/fixtures/zig_producer/zig_double_use.wasm");
20+
21+
/// The honest example-03 module passes the gate and actually runs.
22+
#[test]
23+
fn honest_module_gates_and_executes() {
24+
let module_ir = parser::parse_module(EX03).expect("ex03 parses");
25+
let bytes = emit(&module_ir);
26+
27+
let verified = gate_module(&bytes).expect("honest module must pass the gate");
28+
assert!(verified.report().typed_sites_checked > 0, "gate checked real pinned sites");
29+
30+
let engine = Engine::default();
31+
let mut store = Store::new(&engine, ());
32+
let linker = <Linker<()>>::new(&engine);
33+
let instance =
34+
instantiate_verified(&engine, &mut store, &linker, &verified).expect("instantiates");
35+
36+
// Run a lowered body end-to-end: read_particle_pos on zeroed memory.
37+
let read_pos: TypedFunc<(i32,), f32> =
38+
instance.get_typed_func(&store, "read_particle_pos").unwrap();
39+
assert_eq!(read_pos.call(&mut store, (0,)).unwrap(), 0.0);
40+
}
41+
42+
/// A double-free mutant is refused at the gate — there is no way to
43+
/// hand it to `instantiate_verified` at all (the witness type never
44+
/// exists), which is the enforcement property.
45+
#[test]
46+
fn double_free_mutant_is_refused_at_the_gate() {
47+
let mut module_ir = parser::parse_module(EX03).expect("ex03 parses");
48+
let despawn = module_ir
49+
.funcs
50+
.iter()
51+
.position(|f| f.name == "despawn_particle")
52+
.unwrap();
53+
module_ir.funcs[despawn].body =
54+
vec![Op::LocalGet(0), Op::Drop, Op::LocalGet(0), Op::Drop];
55+
module_ir.funcs[despawn].locals.clear();
56+
module_ir.funcs[despawn].accesses.clear();
57+
let bytes = emit(&module_ir);
58+
59+
match gate_module(&bytes) {
60+
Err(GateError::Ownership(_)) => {}
61+
other => panic!("double-free must be refused at the gate: {other:?}"),
62+
}
63+
}
64+
65+
/// A foreign producer's violating module (the Zig double-use fixture)
66+
/// is refused the same way — the gate is producer-neutral.
67+
#[test]
68+
fn foreign_producer_violation_is_refused() {
69+
assert!(matches!(
70+
gate_module(ZIG_DOUBLE),
71+
Err(GateError::Ownership(_))
72+
));
73+
}
74+
75+
/// Whole-graph gating: the split game modules pass with certificates
76+
/// attached to the consumers; a schema-mutant graph is refused.
77+
#[test]
78+
fn link_graph_gates_with_certificates_and_refuses_mutants() {
79+
let modules = parser::parse_modules(GAME).expect("game.twasm parses");
80+
let built: Vec<(String, Vec<u8>)> =
81+
modules.iter().map(|(n, m)| (n.clone(), emit(m))).collect();
82+
let graph: Vec<(&str, &[u8])> =
83+
built.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
84+
85+
let gated = gate_link_graph(&graph).expect("clean graph passes");
86+
let ai = &gated.iter().find(|(n, _)| n == "ai").unwrap().1;
87+
assert_eq!(ai.report().certificates.len(), 1);
88+
assert_eq!(ai.report().certificates[0].producer, "physics");
89+
90+
// Mutant: ai expects a type the producer does not export.
91+
let mut mutant = parser::parse_modules(GAME).unwrap();
92+
mutant[1].1.region_imports[0]
93+
.expected_fields
94+
.iter_mut()
95+
.find(|f| f.name == "flags")
96+
.unwrap()
97+
.wasm_ty = typed_wasm_verify::WasmTy::F64;
98+
let built: Vec<(String, Vec<u8>)> =
99+
mutant.iter().map(|(n, m)| (n.clone(), emit(m))).collect();
100+
let graph: Vec<(&str, &[u8])> =
101+
built.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
102+
assert!(matches!(
103+
gate_link_graph(&graph),
104+
Err(GateError::RegionImports(_))
105+
));
106+
}

0 commit comments

Comments
 (0)