Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ resolver = "2"
members = [
"crates/typed-wasm-verify",
"crates/typed-wasm-codegen",
"crates/typed-wasm-gate",
]
25 changes: 25 additions & 0 deletions crates/typed-wasm-gate/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 26 additions & 0 deletions crates/typed-wasm-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
<!-- Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> -->
# 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.
163 changes: 163 additions & 0 deletions crates/typed-wasm-gate/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
//! 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<AccessSiteError>),

/// 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<String>),

/// L13 region-import violations — module-local inconsistency, or
/// (in a link graph) unresolved producers / schema disagreement.
#[error("L13 region-import violations: {0:?}")]
RegionImports(Vec<RegionImportsError>),
}

/// 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<CompatCertificate>,
}

/// 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<u8>,
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<VerifiedModule, GateError> {
// 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<Vec<(String, VerifiedModule)>, 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<wasmi::Instance, wasmi::Error> {
let compiled = wasmi::Module::new(engine, module.bytes())?;
linker.instantiate_and_start(store, &compiled)
}
}
106 changes: 106 additions & 0 deletions crates/typed-wasm-gate/tests/gate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
// 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 = <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<u8>)> =
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<u8>)> =
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(_))
));
}
Loading