diff --git a/crates/typed-wasm-codegen/README.md b/crates/typed-wasm-codegen/README.md index a4429b3..bc9e141 100644 --- a/crates/typed-wasm-codegen/README.md +++ b/crates/typed-wasm-codegen/README.md @@ -22,6 +22,11 @@ cannot drift from the decoder the verifier runs. The output round-trips through `verify_from_module` + `verify_access_sites_from_module` in-process — see `tests/roundtrip.rs`. +It also emits **multi-module** pairs — a Linear-exporting callee (with a +`typedwasm.ownership` carrier) and an importing caller — that round-trip +through `extract_exports` + `verify_cross_module` (#128); see +`tests/multimodule.rs`. + ## Usage ```sh @@ -44,11 +49,12 @@ cargo test -p typed-wasm-codegen | Front-end (`.twasm`) → IR | **deferred** — v0 builds the IR for `example01` directly (seam tracked by #127) | | `typedwasm.regions` + `typedwasm.access-sites` | **emitted**, verifier-accepted | | WAT (text) emission | **emitted** via `--emit wat\|both` (#125) | -| `typedwasm.ownership` (L7/L10) | not emitted for example 01 (no linear resources); lands with `examples/03` under #127 | +| `typedwasm.ownership` (L7/L10) | **emitted** for Linear params (multi-module callee, #128); example 01 has no linear resources | +| Multi-module (linear boundary) | **emitted** — callee export + caller import round-trip through `verify_cross_module` (#128) | | Function-body lowering | representative type-correct bodies, not full `region.scan`/indexing semantics | ## Where this goes next - **#127** — codegen coverage across all 10 levels × all 6 examples (and the front-end → IR JSON seam). -- **#128** — multi-module codegen. -- **#130** — promote the round-trip test into the ECHIDNA property corpus. +- **#130** — promote the round-trip tests into the ECHIDNA property corpus. +- **L13 positive-form / region-imports** — shared-region schema agreement (`examples/02`); rides the `typedwasm.region-imports` carrier (proposal 0003 `[draft]`, no verifier pass yet). diff --git a/crates/typed-wasm-codegen/src/lib.rs b/crates/typed-wasm-codegen/src/lib.rs index 927b4b1..99c9d30 100644 --- a/crates/typed-wasm-codegen/src/lib.rs +++ b/crates/typed-wasm-codegen/src/lib.rs @@ -38,12 +38,14 @@ use typed_wasm_verify::section::{ build_access_sites_section_payload, AccessSiteEntry, NO_TARGET_REGION, }; use typed_wasm_verify::{ - build_regions_section_payload, FieldEntry, FieldKind, Nullability, RegionEntry, WasmTy, - ACCESS_SITES_SECTION_NAME, REGIONS_SECTION_NAME, + build_ownership_section_payload, build_regions_section_payload, FieldEntry, FieldKind, + Nullability, OwnershipEntry, OwnershipKind, RegionEntry, WasmTy, ACCESS_SITES_SECTION_NAME, + OWNERSHIP_SECTION_NAME, REGIONS_SECTION_NAME, }; use wasm_encoder::{ - CodeSection, CustomSection, ExportKind, ExportSection, Function, FunctionSection, Instruction, - MemArg, MemorySection, MemoryType, Module as WasmModule, TypeSection, ValType, + CodeSection, CustomSection, EntityType, ExportKind, ExportSection, Function, FunctionSection, + ImportSection, Instruction, MemArg, MemorySection, MemoryType, Module as WasmModule, + TypeSection, ValType, }; // ---------------------------------------------------------------------- @@ -177,10 +179,54 @@ impl Wty { pub enum Op { LocalGet(u32), I32Const(i32), - I32Load { offset: u64 }, - I32Store { offset: u64 }, - F32Load { offset: u64 }, - F32Store { offset: u64 }, + I32Load { + offset: u64, + }, + I32Store { + offset: u64, + }, + F32Load { + offset: u64, + }, + F32Store { + offset: u64, + }, + /// Call the function at the given global index (imports occupy the + /// low indices). Used by the multi-module caller to invoke an import. + Call(u32), + /// Drop the top stack value — consumes a value exactly once. + Drop, +} + +/// Ownership discipline for a function parameter, emitted into the +/// `typedwasm.ownership` carrier (L7 aliasing / L10 linearity). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ownership { + Unrestricted, + Linear, + SharedBorrow, + ExclBorrow, +} + +impl Ownership { + fn to_kind(self) -> OwnershipKind { + match self { + Ownership::Unrestricted => OwnershipKind::Unrestricted, + Ownership::Linear => OwnershipKind::Linear, + Ownership::SharedBorrow => OwnershipKind::SharedBorrow, + Ownership::ExclBorrow => OwnershipKind::ExclBorrow, + } + } +} + +/// A function import (the cross-module boundary). Occupies a slot in the +/// module's function index space ahead of the local functions. +#[derive(Debug, Clone)] +pub struct Import { + pub module: String, + pub field: String, + pub params: Vec, + pub results: Vec, } /// A typed access site: the load/store at `offset` (bytes into the function @@ -220,8 +266,17 @@ pub struct Memory { #[derive(Debug, Clone)] pub struct Module { pub regions: Vec, - pub memory: Memory, + /// Linear memory, if the module declares one. Region-bearing modules + /// have memory; pure cross-module boundary modules need none. + pub memory: Option, + /// Function imports (the cross-module boundary), ahead of `funcs` in + /// the function index space. + pub imports: Vec, pub funcs: Vec, + /// Per-local-function ownership annotations: `(local_func_index, + /// param_kinds)`. Emitted as the `typedwasm.ownership` carrier; + /// empty = no L7/L10 carrier. + pub ownership: Vec<(usize, Vec)>, } // ---------------------------------------------------------------------- @@ -276,39 +331,51 @@ fn op_to_instruction(op: Op) -> Instruction<'static> { Op::I32Store { offset } => Instruction::I32Store(memarg(offset, 2)), Op::F32Load { offset } => Instruction::F32Load(memarg(offset, 2)), Op::F32Store { offset } => Instruction::F32Store(memarg(offset, 2)), + Op::Call(i) => Instruction::Call(i), + Op::Drop => Instruction::Drop, } } /// Lower a typed-wasm [`Module`] IR to a wasm binary with embedded -/// `typedwasm.regions` and `typedwasm.access-sites` carrier sections. +/// `typedwasm.*` carrier sections (`ownership`, `regions`, +/// `access-sites`), each emitted only when it has content. /// -/// Section order is the canonical wasm ordering -/// (Type, Function, Memory, Export, Code, then custom sections) so the -/// output passes a full wasm validator, not just a lenient parser. +/// Section order is the canonical wasm ordering (Type, Import, Function, +/// Memory, Export, Code, then custom sections) so the output passes a full +/// wasm validator, not just a lenient parser. Imports occupy the low +/// function indices, so a local function's global index is +/// `imports.len() + local_index`. pub fn emit(module: &Module) -> Vec { + let import_count = module.imports.len() as u32; + + // Types: one per import (low type indices), then one per local function. let mut types = TypeSection::new(); + for im in &module.imports { + let params: Vec = im.params.iter().map(|w| w.to_val_type()).collect(); + let results: Vec = im.results.iter().map(|w| w.to_val_type()).collect(); + types.ty().function(params, results); + } + for func in &module.funcs { + let params: Vec = func.params.iter().map(|w| w.to_val_type()).collect(); + let results: Vec = func.results.iter().map(|w| w.to_val_type()).collect(); + types.ty().function(params, results); + } + + let mut imports = ImportSection::new(); + for (i, im) in module.imports.iter().enumerate() { + imports.import(&im.module, &im.field, EntityType::Function(i as u32)); + } + let mut funcs = FunctionSection::new(); let mut code = CodeSection::new(); let mut exports = ExportSection::new(); - let mut mems = MemorySection::new(); - - mems.memory(MemoryType { - minimum: module.memory.min_pages, - maximum: module.memory.max_pages, - memory64: false, - shared: false, - page_size_log2: None, - }); - let mut access_entries: Vec = Vec::new(); - for (func_idx, func) in module.funcs.iter().enumerate() { - // One type per function (duplicate types are legal wasm); type - // index lines up with function index since there are no imports. - let params: Vec = func.params.iter().map(|w| w.to_val_type()).collect(); - let results: Vec = func.results.iter().map(|w| w.to_val_type()).collect(); - types.ty().function(params, results); - funcs.function(func_idx as u32); + for (local_i, func) in module.funcs.iter().enumerate() { + // Type index and global function index both account for the + // imports that precede the local functions. + let global_idx = import_count + local_i as u32; + funcs.function(global_idx); let mut f = Function::new([]); for op in &func.body { @@ -318,12 +385,12 @@ pub fn emit(module: &Module) -> Vec { code.function(&f); if func.export { - exports.export(&func.name, ExportKind::Func, func_idx as u32); + exports.export(&func.name, ExportKind::Func, global_idx); } for site in &func.accesses { access_entries.push(AccessSiteEntry { - func_idx: func_idx as u32, + func_idx: global_idx, instruction_byte_offset: site.offset, region_id: site.region as u32, field_id: site.field as u32, @@ -331,6 +398,17 @@ pub fn emit(module: &Module) -> Vec { } } + // typedwasm.ownership carrier (L7/L10), keyed by GLOBAL function index. + let ownership_entries: Vec = module + .ownership + .iter() + .map(|(local_i, kinds)| OwnershipEntry { + func_idx: import_count + *local_i as u32, + param_kinds: kinds.iter().map(|o| o.to_kind()).collect(), + ret_kind: OwnershipKind::Unrestricted, + }) + .collect(); + // L2–L6 region schema carrier. let region_entries: Vec = module .regions @@ -341,23 +419,49 @@ pub fn emit(module: &Module) -> Vec { region_byte_size: r.byte_size, }) .collect(); - let regions_payload = build_regions_section_payload(®ion_entries); - let access_payload = build_access_sites_section_payload(&access_entries); let mut wasm = WasmModule::new(); wasm.section(&types); + if import_count > 0 { + wasm.section(&imports); + } wasm.section(&funcs); - wasm.section(&mems); + if let Some(mem) = &module.memory { + let mut mems = MemorySection::new(); + mems.memory(MemoryType { + minimum: mem.min_pages, + maximum: mem.max_pages, + memory64: false, + shared: false, + page_size_log2: None, + }); + wasm.section(&mems); + } wasm.section(&exports); wasm.section(&code); - wasm.section(&CustomSection { - name: REGIONS_SECTION_NAME.into(), - data: regions_payload.as_slice().into(), - }); - wasm.section(&CustomSection { - name: ACCESS_SITES_SECTION_NAME.into(), - data: access_payload.as_slice().into(), - }); + // Carriers — each only when non-empty (an access-sites section without + // a companion regions section is a verifier hard error). + if !ownership_entries.is_empty() { + let payload = build_ownership_section_payload(&ownership_entries); + wasm.section(&CustomSection { + name: OWNERSHIP_SECTION_NAME.into(), + data: payload.as_slice().into(), + }); + } + if !region_entries.is_empty() { + let payload = build_regions_section_payload(®ion_entries); + wasm.section(&CustomSection { + name: REGIONS_SECTION_NAME.into(), + data: payload.as_slice().into(), + }); + } + if !access_entries.is_empty() { + let payload = build_access_sites_section_payload(&access_entries); + wasm.section(&CustomSection { + name: ACCESS_SITES_SECTION_NAME.into(), + data: payload.as_slice().into(), + }); + } wasm.finish() } @@ -485,11 +589,13 @@ pub fn example01() -> Module { Module { regions: vec![vec2, players, enemies], - memory: Memory { + memory: Some(Memory { min_pages: 64, max_pages: Some(256), - }, + }), + imports: vec![], funcs, + ownership: vec![], } } @@ -498,6 +604,79 @@ pub fn emit_example01() -> Vec { emit(&example01()) } +// ---------------------------------------------------------------------- +// Multi-module codegen — Phase 1 deliverable 7 (#128) +// +// Producer-side emission at parity with the verifier's *existing* +// cross-module coverage: the L10 linear-ownership import boundary +// (`typed_wasm_verify::verify_cross_module`). A callee module exports a +// Linear-consuming function (recorded in `typedwasm.ownership`); a caller +// module imports it and must call it exactly once per path. +// +// The L13 *positive-form* shared-region schema agreement that +// `examples/02-multi-module.twasm` illustrates (`export region` / +// `import region ... from`) rides the `typedwasm.region-imports` carrier — +// proposal 0003 `[draft]`, with no verifier pass yet — so it is out of +// scope here and tracked separately. +// ---------------------------------------------------------------------- + +/// The callee module: exports `consume` — one Linear param, consumed +/// exactly once — with a `typedwasm.ownership` carrier so a consumer's +/// verifier can read the boundary contract via `extract_exports`. +pub fn multimodule_callee() -> Module { + Module { + regions: vec![], + memory: None, + imports: vec![], + funcs: vec![Func { + name: "consume".into(), + params: vec![Wty::I32], + results: vec![], + body: vec![Op::LocalGet(0), Op::Drop], // uses the Linear param once + accesses: vec![], + export: true, + }], + ownership: vec![(0, vec![Ownership::Linear])], + } +} + +/// A caller module importing the callee's `consume` and calling it +/// `call_count` times in its single function. `call_count == 1` is the +/// clean linear transfer; `>= 2` duplicates the resource and must be +/// rejected by `verify_cross_module`. +pub fn multimodule_caller(call_count: u32) -> Module { + let mut body = Vec::new(); + for _ in 0..call_count { + body.push(Op::LocalGet(0)); + body.push(Op::Call(0)); // the import occupies global function index 0 + } + Module { + regions: vec![], + memory: None, + imports: vec![Import { + module: "callee".into(), + field: "consume".into(), // must match the callee's export name + params: vec![Wty::I32], + results: vec![], + }], + funcs: vec![Func { + name: "use_resource".into(), + params: vec![Wty::I32], + results: vec![], + body, + accesses: vec![], + export: false, + }], + ownership: vec![], + } +} + +/// Convenience: emit the `(callee, caller)` pair for the clean +/// single-call linear transfer. +pub fn emit_multimodule() -> (Vec, Vec) { + (emit(&multimodule_callee()), emit(&multimodule_caller(1))) +} + // ---------------------------------------------------------------------- // WAT (text wasm) emission — Phase 1 deliverable 4 (#125) // ---------------------------------------------------------------------- diff --git a/crates/typed-wasm-codegen/tests/multimodule.rs b/crates/typed-wasm-codegen/tests/multimodule.rs new file mode 100644 index 0000000..e34877e --- /dev/null +++ b/crates/typed-wasm-codegen/tests/multimodule.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Multi-module codegen at verifier parity — Phase 1 deliverable 7 (#128). +// +// Producer-side emission for the verifier's *existing* cross-module +// coverage: the L10 linear-ownership import boundary. The callee exports a +// Linear-consuming function (with a typedwasm.ownership carrier); a caller +// imports it. `verify_cross_module` accepts a single call per path and +// rejects duplication. +// +// The L13 positive-form shared-region schema agreement that +// examples/02-multi-module.twasm shows (export/import region) rides the +// typedwasm.region-imports carrier (proposal 0003 [draft], no verifier +// pass yet) and is intentionally out of scope here. + +use typed_wasm_codegen::{emit, emit_multimodule, multimodule_callee, multimodule_caller}; +use typed_wasm_verify::{ + extract_exports, verify_cross_module, verify_from_module, CrossError, OwnershipKind, + VerifyError, +}; + +#[test] +fn callee_and_caller_are_valid_wasm() { + let (callee, caller) = emit_multimodule(); + wasmparser::Validator::new() + .validate_all(&callee) + .expect("callee must be valid wasm"); + wasmparser::Validator::new() + .validate_all(&caller) + .expect("caller must be valid wasm"); +} + +#[test] +fn callee_exposes_linear_consume_via_extract_exports() { + let callee = emit(&multimodule_callee()); + let iface = extract_exports(&callee).expect("interface extracts"); + assert_eq!(iface.len(), 1); + assert_eq!(iface[0].name, "consume"); + assert_eq!(iface[0].param_kinds, vec![OwnershipKind::Linear]); +} + +#[test] +fn callee_is_intra_function_clean() { + // The callee consumes its own Linear param exactly once. + let callee = emit(&multimodule_callee()); + verify_from_module(&callee).expect("callee L7/L10 must be clean"); +} + +#[test] +fn clean_single_call_passes_cross_module() { + let callee = emit(&multimodule_callee()); + let caller = emit(&multimodule_caller(1)); + let iface = extract_exports(&callee).unwrap(); + verify_cross_module(&iface, &caller).expect("one linear call across the boundary is clean"); +} + +#[test] +fn double_call_is_rejected_cross_module() { + let callee = emit(&multimodule_callee()); + let caller = emit(&multimodule_caller(2)); + let iface = extract_exports(&callee).unwrap(); + match verify_cross_module(&iface, &caller) { + Err(VerifyError::Cross(errs)) => { + assert!( + errs.iter() + .any(|e| matches!(e, CrossError::LinearImportCalledMultiple { count: 2, .. })), + "expected LinearImportCalledMultiple, got {errs:?}" + ); + } + other => panic!("expected a cross-module rejection, got {other:?}"), + } +}