From ca7ef944890ff95da3f611bdfec94be398cdce63 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:10:48 +0100 Subject: [PATCH] feat(verify): L13 region-imports codec + self-consistency pass (proposal 0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-stages typed-wasm proposal 0003 (`typedwasm.region-imports` custom section) behind the new `unstable-l13-imports` cargo feature. Closes the verifier-pass half of issue #140's first bullet; cross-module link-graph schema agreement (`SchemaSub expected actual`) defers to a follow-up `verify_link_graph(modules)` pass per proposal 0003 §"Open questions" #4 default option a. Adds: - `section.rs`: `RegionImportEntry` / `ImportedFieldEntry`, `parse_/build_region_imports_section_payload`, `IMPORT_TABLE_BASE` (= 0x8000_0000, the cross-section foreign-key high-bit threshold) + 9 roundtrip / truncation / UTF-8-lossy / boundary tests. - `lib.rs`: `REGION_IMPORTS_SECTION_NAME`, `RegionImportsError` enum, public `verify_region_imports_from_module` entry point. - `verify.rs`: in-module self-consistency pass with these violations: - `MissingDependentRegions` (region-imports present without regions) - `MissingDependentRegionImports` (regions has import-bit target_region but no region-imports section to resolve against) - `DuplicateImport` (unique `(producer_module_name, region_name)` pairs) - `PointerInImportNotSupportedInV1` (v1 restriction — proposal 0003 §"Producer obligations" #5) - `ImportTargetOutOfRange` (import-bit target_region resolves past the import-table bounds) + 10 verifier-pass tests covering all error variants and the clean path. Feature `unstable-l13-imports` implies `unstable-l2` (the import-bit convention lives inside `typedwasm.regions`'s `target_region`, which is gated by `unstable-l2`). Default verifier surface unchanged (the feature is opt-in until proposal 0003 leaves draft). 85 tests pass (66 baseline + 19 new). Refs #140, refs #95, refs #50. Tracks proposal 0003 §"Acceptance criteria" items 3 + 4 (codec + verifier pass behind the cargo feature). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/typed-wasm-verify/Cargo.toml | 12 + crates/typed-wasm-verify/src/lib.rs | 101 ++++++ crates/typed-wasm-verify/src/section.rs | 310 ++++++++++++++++++ crates/typed-wasm-verify/src/verify.rs | 412 ++++++++++++++++++++++++ 4 files changed, 835 insertions(+) diff --git a/crates/typed-wasm-verify/Cargo.toml b/crates/typed-wasm-verify/Cargo.toml index af7238b..58fac03 100644 --- a/crates/typed-wasm-verify/Cargo.toml +++ b/crates/typed-wasm-verify/Cargo.toml @@ -30,6 +30,18 @@ unstable-l2 = [] # `FunctionCapabilities` + `verify_capabilities_from_module` (PR #109). # L15-C (per-call-site grants) is a separate proposal 0004 (`[draft]`). unstable-l15 = [] +# L13 cross-module region-imports carrier for typed-wasm proposal 0003 +# (`[draft]`, typed-wasm#140 refs #95). Implies `unstable-l2`: the import +# table cross-references `typedwasm.regions` field entries via the +# high-bit `target_region` convention. Enables: +# - `parse_region_imports_section_payload` / +# `build_region_imports_section_payload` + +# `RegionImportEntry` / `ImportedFieldEntry` + `IMPORT_TABLE_BASE` +# - `verify_region_imports_from_module` (this PR) — self-consistency +# only; cross-module schema agreement (`SchemaSub`) defers to a +# later `verify_link_graph` pass per proposal 0003 §"Open +# questions" #4 default option a. +unstable-l13-imports = ["unstable-l2"] [dependencies] # Exact pins: wasmparser's 0.x line ships API breaks on every minor bump diff --git a/crates/typed-wasm-verify/src/lib.rs b/crates/typed-wasm-verify/src/lib.rs index 123d790..6ef371e 100644 --- a/crates/typed-wasm-verify/src/lib.rs +++ b/crates/typed-wasm-verify/src/lib.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell // // typed-wasm post-codegen verifier. // @@ -31,6 +32,12 @@ pub use section::{ Nullability, RegionEntry, WasmTy, REGIONS_SECTION_VERSION, }; +#[cfg(feature = "unstable-l13-imports")] +pub use section::{ + build_region_imports_section_payload, parse_region_imports_section_payload, + ImportedFieldEntry, RegionImportEntry, IMPORT_TABLE_BASE, REGION_IMPORTS_SECTION_VERSION, +}; + /// Ownership kinds matching the OCaml `Codegen.ownership_kind` enum. /// Wire encoding in the `typedwasm.ownership` custom section: a single /// u8 per kind, values 0/1/2/3 as below. @@ -148,6 +155,14 @@ pub const CAPABILITIES_SECTION_NAME: &str = "typedwasm.capabilities"; #[cfg(feature = "unstable-l2")] pub const ACCESS_SITES_SECTION_NAME: &str = "typedwasm.access-sites"; +/// Custom-section name carrying cross-module region-import declarations +/// (proposal 0003, typed-wasm#140 refs #95). Companion to +/// `typedwasm.regions`: a module's `target_region` foreign keys with the +/// import-table bit set (`>= IMPORT_TABLE_BASE`) resolve through this +/// section's entries. UNSTABLE. +#[cfg(feature = "unstable-l13-imports")] +pub const REGION_IMPORTS_SECTION_NAME: &str = "typedwasm.region-imports"; + /// L15 capability-section violation (parsing succeeded, content invalid). #[cfg(feature = "unstable-l15")] #[derive(Debug, Clone, PartialEq, Eq, Error)] @@ -203,6 +218,62 @@ pub enum AccessSiteError { }, } +/// L13 region-imports section violation. Self-consistency only; cross- +/// module schema-agreement (`SchemaSub expected actual`, `SchemaImportMismatch`) +/// belongs to a future `verify_link_graph` pass (proposal 0003 §"Open +/// questions" #4 default option a). +#[cfg(feature = "unstable-l13-imports")] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RegionImportsError { + /// Proposal 0003 §"Producer obligations" #1: a module emitting + /// `typedwasm.region-imports` MUST also emit `typedwasm.regions` (the + /// import-table foreign keys in `typedwasm.regions`'s field entries + /// would otherwise dangle). + #[error("Level 13 violation: typedwasm.region-imports section emitted without companion typedwasm.regions section (MissingDependentCarrier)")] + MissingDependentRegions, + + /// Inverse companion check: a `typedwasm.regions` field entry has a + /// `target_region` value with the import-table bit set, but no + /// `typedwasm.region-imports` section is present to resolve it + /// against. Emitted at most once per module (further occurrences + /// would spam). + #[error("Level 13 violation: typedwasm.regions has target_region with import-table bit set (value {target_region:#010x}) but no typedwasm.region-imports section present to resolve it")] + MissingDependentRegionImports { target_region: u32 }, + + /// Proposal 0003 §"Wire format" Notes: imports MUST have unique + /// `(producer_module_name, region_name)` pairs. + #[error("Level 13 violation: duplicate import: (producer_module_name = {producer_module_name:?}, region_name = {region_name:?}) appears at import-table indices {first_idx} and {duplicate_idx}")] + DuplicateImport { + first_idx: u32, + duplicate_idx: u32, + producer_module_name: String, + region_name: String, + }, + + /// Proposal 0003 §"Producer obligations" #5: imported regions MUST + /// have scalar-only expected schemas in v1. Transitive pointer-chain + /// resolution is deferred to v2 (see proposal 0003 §"Open questions" #1). + #[error("Level 13 violation: import-table entry {import_idx}: expected field {field_idx} ({field_name:?}) has pointer kind {kind:?}; pointer fields are not supported in imported regions in v1 (proposal 0003 §Producer obligations 5)")] + PointerInImportNotSupportedInV1 { + import_idx: u32, + field_idx: u32, + field_name: String, + kind: FieldKind, + }, + + /// A `typedwasm.regions` field entry has a `target_region` value + /// with the import-table bit set, but the resolved index points past + /// the end of the `typedwasm.region-imports` table. + #[error("Level 13 violation: typedwasm.regions region {region_idx} field {field_idx}: target_region value {target_region:#010x} resolves to import-table index {resolved_idx} but only {import_count} imports are declared")] + ImportTargetOutOfRange { + region_idx: u32, + field_idx: u32, + target_region: u32, + resolved_idx: u32, + import_count: u32, + }, +} + // ---------------------------------------------------------------------- // Public entry points (stubbed in C1; implementations land in C2-C4). // ---------------------------------------------------------------------- @@ -260,6 +331,36 @@ pub fn verify_access_sites_from_module( verify::verify_access_sites_from_module(wasm_bytes) } +/// Verify the L13 region-imports section's in-module self-consistency by +/// reading its embedded `typedwasm.region-imports` and `typedwasm.regions` +/// custom sections. Modules emitting neither section verify trivially. +/// +/// Checks: +/// +/// 1. `MissingDependentRegions`: region-imports present without regions +/// is a hard error (proposal 0003 §"Producer obligations" #1). +/// 2. `MissingDependentRegionImports`: regions present with at least one +/// `target_region` value `>= IMPORT_TABLE_BASE` (i.e. claiming an +/// import) without region-imports is a hard error (emitted at most +/// once per module). +/// 3. `DuplicateImport`: imports MUST have unique +/// `(producer_module_name, region_name)` pairs. +/// 4. `PointerInImportNotSupportedInV1`: imported regions' expected +/// fields MUST all be `kind == Scalar` in v1. +/// 5. `ImportTargetOutOfRange`: every `target_region` value with the +/// import-table bit set MUST resolve within the import-table bounds. +/// +/// Does NOT verify cross-module schema agreement (`SchemaSub expected +/// actual` from `MultiModule.idr`); that requires the producer module's +/// bytes and is the subject of a future `verify_link_graph(modules)` pass +/// (proposal 0003 §"Open questions" #4 default option a). +#[cfg(feature = "unstable-l13-imports")] +pub fn verify_region_imports_from_module( + wasm_bytes: &[u8], +) -> Result, VerifyError> { + verify::verify_region_imports_from_module(wasm_bytes) +} + /// Ownership-annotated signature for one exported function. /// Mirrors OCaml `Tw_interface.func_interface`. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/typed-wasm-verify/src/section.rs b/crates/typed-wasm-verify/src/section.rs index 46d83b3..4f94d73 100644 --- a/crates/typed-wasm-verify/src/section.rs +++ b/crates/typed-wasm-verify/src/section.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell // // `typedwasm.ownership` custom-section codec. // @@ -1131,3 +1132,312 @@ mod access_sites_tests { } } } + +// ---------------------------------------------------------------------- +// L13 region-imports carrier — `typedwasm.region-imports` custom section +// +// Pre-staged against typed-wasm proposal 0003 (typed-wasm#140, refs #95). +// UNSTABLE: wire format may change before the proposal is [accepted]. +// +// Wire format (little-endian fixed-width fields, unsigned LEB128 varints, +// byte-aligned, lenient on truncation): +// +// u16le version (= REGION_IMPORTS_SECTION_VERSION = 1) +// u32le_leb128 import_count +// for each import (in import-table-index order, 0..import_count-1): +// u32le_leb128 producer_module_name_len +// u8[] producer_module_name (UTF-8) +// u32le_leb128 region_name_len +// u8[] region_name (UTF-8) +// u32le_leb128 expected_field_count +// for each expected field (in declaration order): +// u32le_leb128 field_name_len +// u8[] field_name (UTF-8) +// u8 kind (FieldKind enum; +// pointer kinds rejected +// in v1 — proposal 0003 +// §Producer obligations 5) +// u8 wasm_ty (WasmTy enum) +// u8 nullability (Nullability enum) +// u32le_leb128 cardinality +// +// Companion to typedwasm.regions's `target_region` value-space extension: +// values `>= IMPORT_TABLE_BASE` (and != NO_TARGET_REGION) in regions field +// entries resolve through this section's import table — index = +// `value - IMPORT_TABLE_BASE`. +// +// L13 cross-module schema agreement (the `SchemaSub expected actual` +// witness from MultiModule.idr) defers to a later `verify_link_graph` +// pass — this section's verifier pass only checks in-module +// self-consistency (proposal 0003 §"Open questions" #4 default option a). +// ---------------------------------------------------------------------- + +#[cfg(feature = "unstable-l13-imports")] +pub const REGION_IMPORTS_SECTION_VERSION: u16 = 1; + +/// High-bit threshold for the cross-section `target_region` extension +/// described in proposal 0003 §"Cross-section foreign-key extension": +/// `typedwasm.regions` field entries with `target_region >= IMPORT_TABLE_BASE` +/// (and `!= NO_TARGET_REGION`) resolve through the region-imports table. +#[cfg(feature = "unstable-l13-imports")] +pub const IMPORT_TABLE_BASE: u32 = 0x8000_0000; + +/// One expected-field record inside an imported region's schema. Mirrors +/// the on-wire shape and the `Field` constructor at `Region.idr:127`. +/// Pointer kinds (`kind != Scalar`) are rejected at verify time in v1. +#[cfg(feature = "unstable-l13-imports")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedFieldEntry { + pub name: String, + pub kind: FieldKind, + pub wasm_ty: WasmTy, + pub nullability: Nullability, + pub cardinality: u32, +} + +/// One import-table entry — names the producer module, the exported +/// region name in that module, and the schema the importer expects. +/// Mirrors `ImportedRegion` at `MultiModule.idr:53`. +#[cfg(feature = "unstable-l13-imports")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegionImportEntry { + pub producer_module_name: String, + pub region_name: String, + pub expected_fields: Vec, +} + +/// Parse the `typedwasm.region-imports` custom-section payload. Lenient +/// on truncation (matches sibling carriers): a short read fills missing +/// bytes with zero, which decodes to NonNull / cardinality=0 / etc. +/// Returns `None` if `version != REGION_IMPORTS_SECTION_VERSION` — future +/// breaking bumps belong in a new public API. +#[cfg(feature = "unstable-l13-imports")] +pub fn parse_region_imports_section_payload(payload: &[u8]) -> Option> { + let mut r = LenientReader::new(payload); + let version = read_u16_le(&mut r); + if version != REGION_IMPORTS_SECTION_VERSION { + return None; + } + let import_count = read_u32_leb128(&mut r); + let mut imports = Vec::with_capacity(import_count.min(1_048_576) as usize); + for _ in 0..import_count { + let producer_module_name = read_utf8_leb128(&mut r); + let region_name = read_utf8_leb128(&mut r); + let field_count = read_u32_leb128(&mut r); + let mut expected_fields = Vec::with_capacity(field_count.min(65_536) as usize); + for _ in 0..field_count { + let name = read_utf8_leb128(&mut r); + let kind = FieldKind::from_byte(r.read_u8()); + let wasm_ty = WasmTy::from_byte(r.read_u8()); + let nullability = Nullability::from_byte(r.read_u8()); + let cardinality = read_u32_leb128(&mut r); + expected_fields.push(ImportedFieldEntry { + name, + kind, + wasm_ty, + nullability, + cardinality, + }); + } + imports.push(RegionImportEntry { + producer_module_name, + region_name, + expected_fields, + }); + } + Some(imports) +} + +/// Encode region-import entries. +/// `parse(build(x)) == Some(x)` for any valid input. +#[cfg(feature = "unstable-l13-imports")] +pub fn build_region_imports_section_payload(imports: &[RegionImportEntry]) -> Vec { + let mut out = Vec::with_capacity(2 + 5 + imports.len() * 32); + out.extend_from_slice(®ION_IMPORTS_SECTION_VERSION.to_le_bytes()); + let import_count: u32 = imports + .len() + .try_into() + .expect("import count must fit in u32"); + write_u32_leb128(&mut out, import_count); + for imp in imports { + write_utf8_leb128(&mut out, &imp.producer_module_name); + write_utf8_leb128(&mut out, &imp.region_name); + let field_count: u32 = imp + .expected_fields + .len() + .try_into() + .expect("field count must fit in u32"); + write_u32_leb128(&mut out, field_count); + for f in &imp.expected_fields { + write_utf8_leb128(&mut out, &f.name); + out.push(f.kind.to_byte()); + out.push(f.wasm_ty.to_byte()); + out.push(f.nullability.to_byte()); + write_u32_leb128(&mut out, f.cardinality); + } + } + out +} + +// UTF-8 string with an LEB128 length prefix. Proposal 0003 uses LEB128 +// throughout (matches access-sites in proposal 0002); the existing +// `read_utf8`/`write_utf8` helpers above use u32le and serve proposals +// 0001/0004, so the LEB128 variants below are distinct. +#[cfg(feature = "unstable-l13-imports")] +fn read_utf8_leb128(r: &mut LenientReader<'_>) -> String { + let len = read_u32_leb128(r) as usize; + let cap = len.min(65_536); + let mut bytes = Vec::with_capacity(cap); + for _ in 0..len { + bytes.push(r.read_u8()); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +#[cfg(feature = "unstable-l13-imports")] +fn write_utf8_leb128(out: &mut Vec, s: &str) { + let bytes = s.as_bytes(); + let len: u32 = bytes.len().try_into().expect("name length must fit in u32"); + write_u32_leb128(out, len); + out.extend_from_slice(bytes); +} + +#[cfg(all(test, feature = "unstable-l13-imports"))] +mod region_imports_tests { + use super::*; + + fn scalar_import_field(name: &str, ty: WasmTy, cardinality: u32) -> ImportedFieldEntry { + ImportedFieldEntry { + name: name.to_string(), + kind: FieldKind::Scalar, + wasm_ty: ty, + nullability: Nullability::NonNull, + cardinality, + } + } + + #[test] + fn empty_payload_returns_none() { + assert_eq!(parse_region_imports_section_payload(&[]), None); + } + + #[test] + fn version_only_yields_zero_imports() { + let mut payload = REGION_IMPORTS_SECTION_VERSION.to_le_bytes().to_vec(); + payload.push(0); // LEB128 0 = import_count + assert_eq!(parse_region_imports_section_payload(&payload), Some(vec![])); + } + + #[test] + fn wrong_version_returns_none() { + let payload = [99u8, 0, 0]; + assert_eq!(parse_region_imports_section_payload(&payload), None); + } + + #[test] + fn roundtrip_single_import_no_fields() { + let imports = vec![RegionImportEntry { + producer_module_name: "world".to_string(), + region_name: "Player".to_string(), + expected_fields: vec![], + }]; + let bytes = build_region_imports_section_payload(&imports); + assert_eq!(parse_region_imports_section_payload(&bytes), Some(imports)); + } + + #[test] + fn roundtrip_single_import_with_scalar_fields() { + let imports = vec![RegionImportEntry { + producer_module_name: "world".to_string(), + region_name: "Player".to_string(), + expected_fields: vec![ + scalar_import_field("hp", WasmTy::I32, 1), + scalar_import_field("speed", WasmTy::F64, 1), + scalar_import_field("name", WasmTy::U8, 16), + ], + }]; + let bytes = build_region_imports_section_payload(&imports); + assert_eq!(parse_region_imports_section_payload(&bytes), Some(imports)); + } + + #[test] + fn roundtrip_multiple_imports_same_producer() { + let imports = vec![ + RegionImportEntry { + producer_module_name: "world".to_string(), + region_name: "Player".to_string(), + expected_fields: vec![scalar_import_field("hp", WasmTy::I32, 1)], + }, + RegionImportEntry { + producer_module_name: "world".to_string(), + region_name: "Enemy".to_string(), + expected_fields: vec![scalar_import_field("hp", WasmTy::I32, 1)], + }, + ]; + let bytes = build_region_imports_section_payload(&imports); + assert_eq!(parse_region_imports_section_payload(&bytes), Some(imports)); + } + + #[test] + fn roundtrip_pointer_kinds_codec_only() { + // The codec round-trips pointer kinds faithfully; the verifier + // pass rejects them in v1 (proposal 0003 §Producer obligations 5). + // This test pins the codec's tolerance. + let imports = vec![RegionImportEntry { + producer_module_name: "world".to_string(), + region_name: "Container".to_string(), + expected_fields: vec![ImportedFieldEntry { + name: "child".to_string(), + kind: FieldKind::PtrOwning, + wasm_ty: WasmTy::NotApplicable, + nullability: Nullability::Nullable, + cardinality: 1, + }], + }]; + let bytes = build_region_imports_section_payload(&imports); + assert_eq!(parse_region_imports_section_payload(&bytes), Some(imports)); + } + + #[test] + fn truncated_payload_zero_fills_trailing_fields() { + let imports = vec![RegionImportEntry { + producer_module_name: "w".to_string(), + region_name: "R".to_string(), + expected_fields: vec![scalar_import_field("f", WasmTy::I32, 7)], + }]; + let full = build_region_imports_section_payload(&imports); + // Chop the trailing cardinality LEB128 (single byte for value 7). + let truncated = &full[..full.len() - 1]; + let parsed = parse_region_imports_section_payload(truncated).expect("parses leniently"); + // cardinality missing → reads as 0 + assert_eq!(parsed[0].expected_fields[0].cardinality, 0); + } + + #[test] + fn utf8_lossy_recovers_bad_bytes() { + // Build a payload with an invalid UTF-8 byte in the producer name. + let mut payload = REGION_IMPORTS_SECTION_VERSION.to_le_bytes().to_vec(); + payload.push(1); // import_count = 1 + payload.push(2); // producer_module_name_len = 2 + payload.push(0xC0); // invalid UTF-8 start byte + payload.push(0xC0); + payload.push(0); // region_name_len = 0 + payload.push(0); // expected_field_count = 0 + let parsed = parse_region_imports_section_payload(&payload).expect("parses leniently"); + // Two REPLACEMENT CHARACTERs (U+FFFD), each encoding to 3 UTF-8 bytes. + assert_eq!(parsed[0].producer_module_name, "\u{FFFD}\u{FFFD}"); + } + + #[test] + fn leb128_handles_large_field_counts() { + let imports = vec![RegionImportEntry { + producer_module_name: "world".to_string(), + region_name: "Big".to_string(), + expected_fields: (0..200u32) + .map(|i| scalar_import_field(&format!("f{i}"), WasmTy::U32, 1)) + .collect(), + }]; + let bytes = build_region_imports_section_payload(&imports); + assert_eq!(parse_region_imports_section_payload(&bytes), Some(imports)); + } +} diff --git a/crates/typed-wasm-verify/src/verify.rs b/crates/typed-wasm-verify/src/verify.rs index c2b6587..7424ab4 100644 --- a/crates/typed-wasm-verify/src/verify.rs +++ b/crates/typed-wasm-verify/src/verify.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell // // Intra-function L7+L10 verifier. // @@ -1000,6 +1001,139 @@ pub fn verify_access_sites_from_module( Ok(errors) } +// ---------------------------------------------------------------------- +// L13 region-imports verifier pass (proposal 0003, typed-wasm#140 refs #95). +// +// In-module self-consistency only — no link-graph traversal. See +// `lib.rs::verify_region_imports_from_module` for the public API +// documentation and the deferred scope. +// ---------------------------------------------------------------------- + +#[cfg(feature = "unstable-l13-imports")] +use crate::section::{parse_region_imports_section_payload, FieldKind, IMPORT_TABLE_BASE}; +#[cfg(feature = "unstable-l13-imports")] +use crate::{RegionImportsError, REGION_IMPORTS_SECTION_NAME}; + +#[cfg(feature = "unstable-l13-imports")] +pub fn verify_region_imports_from_module( + wasm_bytes: &[u8], +) -> Result, VerifyError> { + let parser = Parser::new(0); + let mut region_imports_payload: Option> = None; + let mut regions_payload: Option> = None; + for payload in parser.parse_all(wasm_bytes) { + if let Payload::CustomSection(reader) = payload? { + match reader.name() { + REGION_IMPORTS_SECTION_NAME => { + region_imports_payload = Some(reader.data().to_vec()); + } + REGIONS_SECTION_NAME => { + regions_payload = Some(reader.data().to_vec()); + } + _ => {} + } + } + } + + let regions = regions_payload + .as_deref() + .and_then(parse_regions_section_payload); + let mut errors = Vec::new(); + + if let Some(payload) = region_imports_payload.as_deref() { + // MissingDependentRegions check (proposal 0003 §"Producer + // obligations" #1): region-imports present requires regions + // present. We treat "regions section absent OR present-but- + // unparseable (version mismatch)" identically — there's nothing + // to validate the import-table foreign keys against either way. + if regions.is_none() { + errors.push(RegionImportsError::MissingDependentRegions); + return Ok(errors); + } + + let Some(imports) = parse_region_imports_section_payload(payload) else { + // Unsupported region-imports version: lenient, no errors. + return Ok(errors); + }; + + // Duplicate (producer_module_name, region_name) pairs. + let mut seen: std::collections::HashMap<(String, String), u32> = + std::collections::HashMap::new(); + for (idx, imp) in imports.iter().enumerate() { + let idx = idx as u32; + let key = (imp.producer_module_name.clone(), imp.region_name.clone()); + if let Some(&first_idx) = seen.get(&key) { + errors.push(RegionImportsError::DuplicateImport { + first_idx, + duplicate_idx: idx, + producer_module_name: imp.producer_module_name.clone(), + region_name: imp.region_name.clone(), + }); + } else { + seen.insert(key, idx); + } + } + + // v1 restriction: imported regions are scalar-only. + for (import_idx, imp) in imports.iter().enumerate() { + let import_idx = import_idx as u32; + for (field_idx, f) in imp.expected_fields.iter().enumerate() { + if f.kind != FieldKind::Scalar { + errors.push(RegionImportsError::PointerInImportNotSupportedInV1 { + import_idx, + field_idx: field_idx as u32, + field_name: f.name.clone(), + kind: f.kind, + }); + } + } + } + + // ImportTargetOutOfRange: every target_region with the import-table + // bit set must resolve within the import-table bounds. NO_TARGET_REGION + // (0xFFFFFFFF) is the Scalar sentinel and is excluded — we filter + // by `kind != Scalar` since the sentinel only appears on Scalar + // fields per proposal 0001. + if let Some(regions) = regions.as_ref() { + let import_count = imports.len() as u32; + for (region_idx, r) in regions.iter().enumerate() { + let region_idx = region_idx as u32; + for (field_idx, f) in r.fields.iter().enumerate() { + let field_idx = field_idx as u32; + if f.kind != FieldKind::Scalar && f.target_region >= IMPORT_TABLE_BASE { + let resolved_idx = f.target_region - IMPORT_TABLE_BASE; + if resolved_idx >= import_count { + errors.push(RegionImportsError::ImportTargetOutOfRange { + region_idx, + field_idx, + target_region: f.target_region, + resolved_idx, + import_count, + }); + } + } + } + } + } + } else if let Some(regions) = regions.as_ref() { + // region-imports absent: any target_region with the import bit + // set is a dangling foreign key. Emit at most once per module to + // avoid spamming when many fields share the problem. + for r in regions { + for f in &r.fields { + if f.kind != FieldKind::Scalar && f.target_region >= IMPORT_TABLE_BASE { + errors.push(RegionImportsError::MissingDependentRegionImports { + target_region: f.target_region, + }); + return Ok(errors); + } + } + } + } + + Ok(errors) +} + // ---------------------------------------------------------------------- // Tests — capabilities + access-sites verifier passes // ---------------------------------------------------------------------- @@ -1340,3 +1474,281 @@ mod access_sites_verifier_tests { )); } } + +// ---------------------------------------------------------------------- +// Tests — L13 region-imports verifier pass (proposal 0003, typed-wasm#140) +// ---------------------------------------------------------------------- + +#[cfg(all(test, feature = "unstable-l13-imports"))] +mod region_imports_verifier_tests { + use super::*; + use crate::section::{ + build_region_imports_section_payload, build_regions_section_payload, FieldEntry, FieldKind, + ImportedFieldEntry, Nullability, RegionEntry, RegionImportEntry, WasmTy, + IMPORT_TABLE_BASE, NO_TARGET_REGION, + }; + use wasm_encoder::{ + CodeSection, CustomSection, Function, FunctionSection, Instruction, Module, TypeSection, + ValType, + }; + + fn scalar_field(name: &str, ty: WasmTy) -> FieldEntry { + FieldEntry { + name: name.into(), + kind: FieldKind::Scalar, + wasm_ty: ty, + target_region: NO_TARGET_REGION, + nullability: Nullability::NonNull, + cardinality: 1, + } + } + + fn ptr_field_to_import(name: &str, import_idx: u32) -> FieldEntry { + FieldEntry { + name: name.into(), + kind: FieldKind::PtrBorrow, + wasm_ty: WasmTy::NotApplicable, + target_region: IMPORT_TABLE_BASE + import_idx, + nullability: Nullability::NonNull, + cardinality: 1, + } + } + + fn scalar_import_field(name: &str, ty: WasmTy) -> ImportedFieldEntry { + ImportedFieldEntry { + name: name.into(), + kind: FieldKind::Scalar, + wasm_ty: ty, + nullability: Nullability::NonNull, + cardinality: 1, + } + } + + fn ptr_import_field(name: &str, kind: FieldKind) -> ImportedFieldEntry { + ImportedFieldEntry { + name: name.into(), + kind, + wasm_ty: WasmTy::NotApplicable, + nullability: Nullability::Nullable, + cardinality: 1, + } + } + + fn module_with_sections( + regions: Option>, + imports: Option>, + ) -> Vec { + let mut module = Module::new(); + let mut types = TypeSection::new(); + types + .ty() + .function(Vec::::new(), Vec::::new()); + module.section(&types); + let mut funcs = FunctionSection::new(); + funcs.function(0); + module.section(&funcs); + let mut code = CodeSection::new(); + let mut f = Function::new([]); + f.instruction(&Instruction::End); + code.function(&f); + module.section(&code); + if let Some(regions) = regions { + let bytes = build_regions_section_payload(®ions); + module.section(&CustomSection { + name: REGIONS_SECTION_NAME.into(), + data: (&bytes[..]).into(), + }); + } + if let Some(imports) = imports { + let bytes = build_region_imports_section_payload(&imports); + module.section(&CustomSection { + name: REGION_IMPORTS_SECTION_NAME.into(), + data: (&bytes[..]).into(), + }); + } + module.finish() + } + + #[test] + fn module_without_either_section_verifies_trivially() { + let bytes = module_with_sections(None, None); + assert_eq!(verify_region_imports_from_module(&bytes).unwrap(), vec![]); + } + + #[test] + fn module_with_regions_only_no_import_bits_verifies_trivially() { + let regions = vec![RegionEntry { + name: "Player".into(), + fields: vec![scalar_field("hp", WasmTy::I32)], + region_byte_size: 4, + }]; + let bytes = module_with_sections(Some(regions), None); + assert_eq!(verify_region_imports_from_module(&bytes).unwrap(), vec![]); + } + + #[test] + fn import_section_without_regions_is_missing_dependent_carrier() { + let imports = vec![RegionImportEntry { + producer_module_name: "world".into(), + region_name: "Player".into(), + expected_fields: vec![scalar_import_field("hp", WasmTy::I32)], + }]; + let bytes = module_with_sections(None, Some(imports)); + let errors = verify_region_imports_from_module(&bytes).unwrap(); + assert_eq!(errors, vec![RegionImportsError::MissingDependentRegions]); + } + + #[test] + fn import_bit_without_import_section_is_flagged_once() { + // Two pointer fields claiming imports, but no region-imports + // section to resolve them. Emitter reports the first dangling + // value only — further would spam. + let regions = vec![RegionEntry { + name: "Caller".into(), + fields: vec![ + ptr_field_to_import("a", 0), + ptr_field_to_import("b", 1), + ], + region_byte_size: 8, + }]; + let bytes = module_with_sections(Some(regions), None); + let errors = verify_region_imports_from_module(&bytes).unwrap(); + assert_eq!(errors.len(), 1); + assert!(matches!( + errors[0], + RegionImportsError::MissingDependentRegionImports { target_region } + if target_region == IMPORT_TABLE_BASE + )); + } + + #[test] + fn well_formed_import_table_verifies_clean() { + let regions = vec![RegionEntry { + name: "Caller".into(), + fields: vec![ + scalar_field("local", WasmTy::I32), + ptr_field_to_import("remote", 0), + ], + region_byte_size: 8, + }]; + let imports = vec![RegionImportEntry { + producer_module_name: "world".into(), + region_name: "Player".into(), + expected_fields: vec![scalar_import_field("hp", WasmTy::I32)], + }]; + let bytes = module_with_sections(Some(regions), Some(imports)); + assert_eq!(verify_region_imports_from_module(&bytes).unwrap(), vec![]); + } + + #[test] + fn duplicate_import_is_flagged() { + let regions = vec![RegionEntry { + name: "R".into(), + fields: vec![scalar_field("f", WasmTy::I32)], + region_byte_size: 4, + }]; + let imports = vec![ + RegionImportEntry { + producer_module_name: "world".into(), + region_name: "Player".into(), + expected_fields: vec![], + }, + RegionImportEntry { + producer_module_name: "world".into(), + region_name: "Player".into(), + expected_fields: vec![], + }, + ]; + let bytes = module_with_sections(Some(regions), Some(imports)); + let errors = verify_region_imports_from_module(&bytes).unwrap(); + assert!(errors.iter().any(|e| matches!( + e, + RegionImportsError::DuplicateImport { + first_idx: 0, + duplicate_idx: 1, + .. + } + ))); + } + + #[test] + fn pointer_in_import_is_flagged() { + let regions = vec![RegionEntry { + name: "R".into(), + fields: vec![scalar_field("f", WasmTy::I32)], + region_byte_size: 4, + }]; + let imports = vec![RegionImportEntry { + producer_module_name: "world".into(), + region_name: "Container".into(), + expected_fields: vec![ptr_import_field("child", FieldKind::PtrOwning)], + }]; + let bytes = module_with_sections(Some(regions), Some(imports)); + let errors = verify_region_imports_from_module(&bytes).unwrap(); + assert!(errors.iter().any(|e| matches!( + e, + RegionImportsError::PointerInImportNotSupportedInV1 { + import_idx: 0, + field_idx: 0, + kind: FieldKind::PtrOwning, + .. + } + ))); + } + + #[test] + fn import_target_out_of_range_is_flagged() { + // Regions points to import-table index 5, but only 1 import declared. + let regions = vec![RegionEntry { + name: "Caller".into(), + fields: vec![ptr_field_to_import("remote", 5)], + region_byte_size: 4, + }]; + let imports = vec![RegionImportEntry { + producer_module_name: "world".into(), + region_name: "Player".into(), + expected_fields: vec![], + }]; + let bytes = module_with_sections(Some(regions), Some(imports)); + let errors = verify_region_imports_from_module(&bytes).unwrap(); + assert!(errors.iter().any(|e| matches!( + e, + RegionImportsError::ImportTargetOutOfRange { + region_idx: 0, + field_idx: 0, + resolved_idx: 5, + import_count: 1, + .. + } + ))); + } + + #[test] + fn no_target_region_sentinel_is_not_flagged_as_import() { + // A Scalar field carries target_region = NO_TARGET_REGION (0xFFFFFFFF), + // which is numerically >= IMPORT_TABLE_BASE. The verifier must + // skip Scalar fields when checking the import-bit convention. + let regions = vec![RegionEntry { + name: "R".into(), + fields: vec![scalar_field("f", WasmTy::I32)], + region_byte_size: 4, + }]; + let bytes = module_with_sections(Some(regions), None); + // No import section and no import-bit fields → trivial verify. + assert_eq!(verify_region_imports_from_module(&bytes).unwrap(), vec![]); + } + + #[test] + fn empty_import_table_verifies_clean() { + let regions = vec![RegionEntry { + name: "R".into(), + fields: vec![scalar_field("f", WasmTy::I32)], + region_byte_size: 4, + }]; + let imports: Vec = vec![]; + let bytes = module_with_sections(Some(regions), Some(imports)); + // Empty import_count = 0 is wasteful but legal (proposal 0003 + // §"Open questions" #6). + assert_eq!(verify_region_imports_from_module(&bytes).unwrap(), vec![]); + } +}