diff --git a/Cargo.lock b/Cargo.lock index 6d8ede2..60aa66e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,16 @@ dependencies = [ "wasmprinter", ] +[[package]] +name = "typed-wasm-gate" +version = "0.1.0" +dependencies = [ + "thiserror", + "typed-wasm-codegen", + "typed-wasm-verify", + "wasmi", +] + [[package]] name = "typed-wasm-verify" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 28cd4e4..a3bfd4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,4 +4,5 @@ resolver = "2" members = [ "crates/typed-wasm-verify", "crates/typed-wasm-codegen", + "crates/typed-wasm-gate", ] diff --git a/crates/typed-wasm-gate/Cargo.toml b/crates/typed-wasm-gate/Cargo.toml new file mode 100644 index 0000000..3500748 --- /dev/null +++ b/crates/typed-wasm-gate/Cargo.toml @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "typed-wasm-gate" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +description = "Load-time enforcement gate: refuse to instantiate wasm that fails typed-wasm verification (Phase 3 slice)" +repository = "https://github.com/hyperpolymath/typed-wasm" +readme = "README.md" + +[features] +default = ["wasmi-runtime"] +# In-process instantiation adapter backed by wasmi (pure Rust — runs in +# CI without a system runtime). A wasmtime adapter is the documented +# follow-up; the gate API is runtime-agnostic by construction. +wasmi-runtime = ["dep:wasmi"] + +[dependencies] +typed-wasm-verify = { path = "../typed-wasm-verify", features = ["unstable-l2", "unstable-l13-imports"] } +thiserror = "2" +wasmi = { version = "1.1.0", optional = true } + +[dev-dependencies] +typed-wasm-codegen = { path = "../typed-wasm-codegen" } +wasmi = "1.1.0" diff --git a/crates/typed-wasm-gate/README.md b/crates/typed-wasm-gate/README.md new file mode 100644 index 0000000..85e1c1b --- /dev/null +++ b/crates/typed-wasm-gate/README.md @@ -0,0 +1,26 @@ + + +# typed-wasm-gate + +Load-time enforcement for typed-wasm — the Phase 3 slice (runtime-side +enforcement). Build-time verification trusts whoever ran the build; the +gate moves the trust boundary to the **loader**: + +```rust +let verified = typed_wasm_gate::gate_module(&bytes)?; // full verifier stack +let instance = wasmi_runtime::instantiate_verified(&engine, &mut store, &linker, &verified)?; +``` + +`VerifiedModule` is a witness type: its only constructors are +`gate_module` / `gate_link_graph`, which run structural validation, +L7/L10 ownership/linearity, L2 carrier bounds + access typing, and L13 +region-import consistency (plus cross-module `SchemaSub` certification +for graphs, ADR-0007). The instantiation adapters accept only +`&VerifiedModule` — a violating module cannot reach a runtime through +this API because its witness never exists. + +The gate itself is runtime-agnostic (bytes in, witness + `GateReport` +out). The `wasmi-runtime` feature (default) ships a pure-Rust in-process +adapter that CI executes end-to-end; a **wasmtime adapter** is the +intended follow-up and needs nothing beyond what the wasmi one uses — +compile the module from `VerifiedModule::bytes()` and instantiate. diff --git a/crates/typed-wasm-gate/src/lib.rs b/crates/typed-wasm-gate/src/lib.rs new file mode 100644 index 0000000..b2f8b0b --- /dev/null +++ b/crates/typed-wasm-gate/src/lib.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +//! Load-time enforcement gate — the Phase 3 slice (runtime-side +//! enforcement, issue #51). +//! +//! Build-time verification (`tw build` self-verify, `tw link`) trusts +//! whoever ran the build. This crate moves the trust boundary to the +//! LOADER: a [`VerifiedModule`] is a witness type whose only +//! constructors are [`gate_module`] / [`gate_link_graph`], which run +//! the full typed-wasm verifier stack over the raw bytes — structural +//! validation, L7/L10 ownership/linearity, L2 carrier bounds + access +//! typing, L13 region-import consistency, and (for graphs) cross-module +//! `SchemaSub` certification. Instantiation adapters accept only +//! `&VerifiedModule`, so an unverified or violating module cannot reach +//! a runtime through this crate's API at all. +//! +//! The gate is runtime-agnostic: it consumes bytes and returns a +//! witness + [`GateReport`]. The `wasmi-runtime` feature provides an +//! in-process adapter (pure Rust, CI-testable); a wasmtime adapter is +//! the documented follow-up and needs nothing from the gate beyond +//! what `wasmi::instantiate_verified` already uses. + +use thiserror::Error; +use typed_wasm_verify::{ + verify_access_sites_from_module, verify_access_typing_from_module, verify_from_module, + verify_link_graph, verify_region_imports_from_module, AccessSiteError, CompatCertificate, + RegionImportsError, VerifyError, +}; + +/// Why the gate refused a module (first failing layer reported). +#[derive(Debug, Error)] +pub enum GateError { + /// Not decodable as wasm at all, or an L7/L10/L13-negative + /// ownership violation (see the inner error). + #[error("ownership/linearity verification failed: {0}")] + Ownership(#[from] VerifyError), + + /// L2 carrier bounds violations (`typedwasm.regions` / + /// `typedwasm.access-sites` internal consistency). + #[error("L2 access-site bounds violations: {0:?}")] + AccessSites(Vec), + + /// L2 access-typing violations: a pinned instruction is not the + /// right memory op at the right offset for its claimed field. + #[error("L2 access-typing violations: {0:?}")] + AccessTyping(Vec), + + /// L13 region-import violations — module-local inconsistency, or + /// (in a link graph) unresolved producers / schema disagreement. + #[error("L13 region-import violations: {0:?}")] + RegionImports(Vec), +} + +/// What the gate established about a module it passed. +#[derive(Debug, Clone, Default)] +pub struct GateReport { + /// Pinned access sites whose instruction-level typing was checked. + pub typed_sites_checked: u32, + /// Declared-only (unpinned) access sites — counted, not checked. + pub declared_only_sites: u32, + /// Cross-module certificates this module participates in as the + /// consumer (link-graph gate only; empty for single-module gates). + pub certificates: Vec, +} + +/// Witness that `bytes` passed the gate. The field is private and the +/// only constructors run the verifier stack — possession of a +/// `VerifiedModule` IS the proof of verification. +#[derive(Debug, Clone)] +pub struct VerifiedModule { + bytes: Vec, + report: GateReport, +} + +impl VerifiedModule { + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + pub fn report(&self) -> &GateReport { + &self.report + } +} + +/// Run the single-module verifier stack; a `VerifiedModule` comes back +/// only if every layer passes. +pub fn gate_module(bytes: &[u8]) -> Result { + // L7 / L10 / L13-negative (also rejects undecodable bytes). + verify_from_module(bytes)?; + + // L2 carrier bounds. + let bounds = verify_access_sites_from_module(bytes)?; + if !bounds.is_empty() { + return Err(GateError::AccessSites(bounds)); + } + + // L2 access typing on pinned sites. + let typing = verify_access_typing_from_module(bytes)?; + if !typing.errors.is_empty() { + return Err(GateError::AccessTyping( + typing.errors.iter().map(|e| e.to_string()).collect(), + )); + } + + // L13 module-local import-table consistency. + let import_errs = verify_region_imports_from_module(bytes)?; + if !import_errs.is_empty() { + return Err(GateError::RegionImports(import_errs)); + } + + Ok(VerifiedModule { + bytes: bytes.to_vec(), + report: GateReport { + typed_sites_checked: typing.type_verified, + declared_only_sites: typing.declared_only, + certificates: Vec::new(), + }, + }) +} + +/// Gate a whole link graph: every module passes the single-module gate +/// AND cross-module schema agreement holds (`verify_link_graph`, +/// ADR-0007). Returns the witnesses in input order, each consumer's +/// certificates attached to its report. +pub fn gate_link_graph( + named: &[(&str, &[u8])], +) -> Result, GateError> { + let mut gated: Vec<(String, VerifiedModule)> = Vec::new(); + for (name, bytes) in named { + gated.push((name.to_string(), gate_module(bytes)?)); + } + let report = verify_link_graph(named)?; + if !report.errors.is_empty() { + return Err(GateError::RegionImports(report.errors)); + } + for cert in report.certificates { + if let Some((_, module)) = gated.iter_mut().find(|(n, _)| *n == cert.consumer) { + module.report.certificates.push(cert); + } + } + Ok(gated) +} + +/// In-process instantiation adapter backed by wasmi. Accepts only the +/// gate's witness type — this is the enforcement point. +#[cfg(feature = "wasmi-runtime")] +pub mod wasmi_runtime { + use super::VerifiedModule; + + /// Load + instantiate a verified module in a wasmi store. All + /// wasmi-level errors pass through untouched; what this adapter + /// adds is the TYPE-LEVEL guarantee that `module` went through the + /// gate. + pub fn instantiate_verified( + engine: &wasmi::Engine, + store: &mut wasmi::Store<()>, + linker: &wasmi::Linker<()>, + module: &VerifiedModule, + ) -> Result { + let compiled = wasmi::Module::new(engine, module.bytes())?; + linker.instantiate_and_start(store, &compiled) + } +} diff --git a/crates/typed-wasm-gate/tests/gate.rs b/crates/typed-wasm-gate/tests/gate.rs new file mode 100644 index 0000000..806ddce --- /dev/null +++ b/crates/typed-wasm-gate/tests/gate.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Load-time enforcement: honest modules gate + instantiate + RUN; +// violating modules are refused BEFORE any runtime sees them. The +// producer inputs come from the real front-end (parse .twasm source → +// emit), so this is the whole pipeline: source → bytes → gate → +// instantiate → execute. + +#![cfg(feature = "wasmi-runtime")] + +use typed_wasm_codegen::{emit, parser, Op}; +use typed_wasm_gate::{gate_link_graph, gate_module, wasmi_runtime::instantiate_verified, GateError}; +use wasmi::{Engine, Linker, Store, TypedFunc}; + +const EX03: &str = include_str!("../../../examples/03-ownership-linearity.twasm"); +const GAME: &str = include_str!("../../typed-wasm-codegen/tests/fixtures/multimodule/game.twasm"); +const ZIG_DOUBLE: &[u8] = + include_bytes!("../../typed-wasm-verify/tests/fixtures/zig_producer/zig_double_use.wasm"); + +/// The honest example-03 module passes the gate and actually runs. +#[test] +fn honest_module_gates_and_executes() { + let module_ir = parser::parse_module(EX03).expect("ex03 parses"); + let bytes = emit(&module_ir); + + let verified = gate_module(&bytes).expect("honest module must pass the gate"); + assert!(verified.report().typed_sites_checked > 0, "gate checked real pinned sites"); + + let engine = Engine::default(); + let mut store = Store::new(&engine, ()); + let linker = >::new(&engine); + let instance = + instantiate_verified(&engine, &mut store, &linker, &verified).expect("instantiates"); + + // Run a lowered body end-to-end: read_particle_pos on zeroed memory. + let read_pos: TypedFunc<(i32,), f32> = + instance.get_typed_func(&store, "read_particle_pos").unwrap(); + assert_eq!(read_pos.call(&mut store, (0,)).unwrap(), 0.0); +} + +/// A double-free mutant is refused at the gate — there is no way to +/// hand it to `instantiate_verified` at all (the witness type never +/// exists), which is the enforcement property. +#[test] +fn double_free_mutant_is_refused_at_the_gate() { + let mut module_ir = parser::parse_module(EX03).expect("ex03 parses"); + let despawn = module_ir + .funcs + .iter() + .position(|f| f.name == "despawn_particle") + .unwrap(); + module_ir.funcs[despawn].body = + vec![Op::LocalGet(0), Op::Drop, Op::LocalGet(0), Op::Drop]; + module_ir.funcs[despawn].locals.clear(); + module_ir.funcs[despawn].accesses.clear(); + let bytes = emit(&module_ir); + + match gate_module(&bytes) { + Err(GateError::Ownership(_)) => {} + other => panic!("double-free must be refused at the gate: {other:?}"), + } +} + +/// A foreign producer's violating module (the Zig double-use fixture) +/// is refused the same way — the gate is producer-neutral. +#[test] +fn foreign_producer_violation_is_refused() { + assert!(matches!( + gate_module(ZIG_DOUBLE), + Err(GateError::Ownership(_)) + )); +} + +/// Whole-graph gating: the split game modules pass with certificates +/// attached to the consumers; a schema-mutant graph is refused. +#[test] +fn link_graph_gates_with_certificates_and_refuses_mutants() { + let modules = parser::parse_modules(GAME).expect("game.twasm parses"); + let built: Vec<(String, Vec)> = + modules.iter().map(|(n, m)| (n.clone(), emit(m))).collect(); + let graph: Vec<(&str, &[u8])> = + built.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect(); + + let gated = gate_link_graph(&graph).expect("clean graph passes"); + let ai = &gated.iter().find(|(n, _)| n == "ai").unwrap().1; + assert_eq!(ai.report().certificates.len(), 1); + assert_eq!(ai.report().certificates[0].producer, "physics"); + + // Mutant: ai expects a type the producer does not export. + let mut mutant = parser::parse_modules(GAME).unwrap(); + mutant[1].1.region_imports[0] + .expected_fields + .iter_mut() + .find(|f| f.name == "flags") + .unwrap() + .wasm_ty = typed_wasm_verify::WasmTy::F64; + let built: Vec<(String, Vec)> = + mutant.iter().map(|(n, m)| (n.clone(), emit(m))).collect(); + let graph: Vec<(&str, &[u8])> = + built.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect(); + assert!(matches!( + gate_link_graph(&graph), + Err(GateError::RegionImports(_)) + )); +}