Skip to content

feat(verify): cross-module boundary verifier (C4)#22

Merged
hyperpolymath merged 1 commit into
mainfrom
feat/typed-wasm-verify-c4-cross-module
May 15, 2026
Merged

feat(verify): cross-module boundary verifier (C4)#22
hyperpolymath merged 1 commit into
mainfrom
feat/typed-wasm-verify-c4-cross-module

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Summary

  • Ports Tw_interface.extract_exports + Tw_interface.verify_cross_module from hyperpolymath/affinescript/lib/tw_interface.ml.
  • Replaces the C1 extract_exports / verify_cross_module stubs — the full L7+L10 boundary check is now live.
  • Reuses the C3 (min, max) frame-stack machinery via a new CallOf(import_idx) OpCounter — no algorithm duplication.
  • 40/40 tests pass (29 from C1-C3 + 11 new for C4).

How the boundary check works

verify_cross_module(callee_iface, caller_bytes):

  1. Build name → &FuncInterface map from callee_iface.
  2. Walk caller module; for each function-typed import whose name matches a callee export with a Linear param, record (slot, name).
  3. Collect every local function body in caller.
  4. For each (Linear import × local fn) pair, run count_op_range with CallOf(slot):
(min, max) Verdict
max == 0 skipped (fn doesn't call this import)
min == 0, max ≥ 1 LinearImportDroppedOnSomePath
max > 1 LinearImportCalledMultiple { count }
min == 0, max > 1 both above fire (matches OCaml)
  1. Aggregate into VerifyError::Cross(Vec<CrossError>) or Ok(()).

Test coverage (11 new)

extract_exports (3)

  • finds Linear-param export through the ownership section
  • module without ownership section → exports show as Unrestricted with empty param_kinds (matches OCaml fallback)
  • empty module → empty result

verify_cross_module end-to-end (8)

  • linear_import_called_exactly_once_is_clean
  • linear_import_called_twice_errorsLinearImportCalledMultiple{count:2}
  • linear_import_dropped_on_some_path_errorsLinearImportDroppedOnSomePath
  • linear_import_never_called_by_some_caller_fns_is_clean (3 caller fns; only the first calls the import; the others aren't flagged — fns aren't obligated to invoke every import)
  • non_linear_import_unconstrained (Unrestricted callee → caller can call freely)
  • excl_borrow_import_unconstrained_at_boundary (ExclBorrow is intra-function only by design; boundary verifier skips it — matches affinescript)
  • linear_import_unmatched_export_is_ignored (caller imports a name the callee doesn't export → trivially Ok)
  • linear_import_drop_and_dup_both_fire (if(lg0){call;call} → min=0, max=2 → both error variants emitted for the same pair)

Crate API after this PR

pub mod cross;
pub mod section;
pub mod verify;
pub use cross::{extract_exports, verify_cross_module};
pub use section::{build_ownership_section_payload, parse_ownership_section_payload, OwnershipEntry};
pub use verify::{count_uses_range, verify_function};

pub fn verify_from_module(wasm_bytes: &[u8]) -> Result<(), VerifyError>;

Stub functions are gone; every public surface from C1 is now backed by a real implementation.

Stacking

This is the first non-stacked PR in the C-series — branches off main (which already has C1+C2+C3 merged). Self-contained.

Follow-up

  • C5 — cross-compat regression test against affinescript-emitted modules (proves Rust and OCaml verifiers agree on real source fixtures)
  • C6/C7hyperpolymath/ephapax consumer wiring (separate PRs there)

Closes #39 once merged.

Ports `Tw_interface.extract_exports` + `Tw_interface.verify_cross_module`
from `hyperpolymath/affinescript/lib/tw_interface.ml`, plus a small
`CallOf(import_idx)` counter that reuses the `(min, max)` frame stack
from the C3 intra-function pass.

After this commit the C1 `extract_exports` and `verify_cross_module`
stubs are gone — the full L7+L10 boundary check is live.

What changed in `verify.rs`
---------------------------

- `OpCounter` trait and `LocalGetOf` made `pub(crate)`.
- Added `CallOf(u32)` counter (matches `Operator::Call { function_index }`).
- `count_op_range` promoted to `pub(crate)` so `cross.rs` can reuse it.

New module `cross.rs`
---------------------

  - `extract_exports(wasm_bytes) -> Result<Vec<FuncInterface>, VerifyError>`
    walks the module, filters exports to `ExternalKind::Func`, joins
    them with the ownership section by `func_idx`. Functions without an
    entry default to `(params=[], ret=Unrestricted)` — matches OCaml
    fallback.

  - `verify_cross_module(callee_iface, caller_bytes) -> Result<(), VerifyError>`:

      1. Builds a `name → &FuncInterface` lookup from `callee_iface`.
      2. Walks the caller module, tracking function-import slots in
         order (matters for the function index space). For each
         function-typed import whose name matches a callee export with
         at least one Linear param, records `(slot, name)` as a
         Linear-import-to-check.
      3. Collects every `Payload::CodeSectionEntry` body.
      4. For every (Linear import, local function body) pair, runs
         `count_op_range` with `CallOf(slot)` to compute
         `(min_calls, max_calls)`. Then:

           max_calls == 0    → skip (function doesn't call this import)
           min_calls == 0    → `LinearImportDroppedOnSomePath`
           max_calls > 1     → `LinearImportCalledMultiple { count: max_calls }`

         Both fire for `min=0, max>1` — matches OCaml.

      5. Aggregates everything into `VerifyError::Cross(Vec<CrossError>)`
         or returns `Ok(())`.

Note on the function index space: imports occupy the lowest indices, in
import-section order; local functions come after. `caller_func_idx` in
the emitted error is the **global** index (`import_count + local_idx`)
so it lines up with the callee's `func_idx` convention.

Tests
-----

40/40 unit tests pass (29 from C1-C3 + 11 new for C4):

  extract_exports
    - finds_linear_param
    - module_without_ownership_section (fallback to Unrestricted + [])
    - empty_module → []

  verify_cross_module end-to-end
    - linear_import_called_exactly_once_is_clean
    - linear_import_called_twice_errors          → LinearImportCalledMultiple{count:2}
    - linear_import_dropped_on_some_path_errors  → LinearImportDroppedOnSomePath
    - linear_import_never_called_by_some_caller_fns_is_clean
        (3 caller fns; only one calls the import; the other two are
         not flagged — functions aren't required to invoke every import)
    - non_linear_import_unconstrained
        (Unrestricted callee export; caller calls 3× → clean)
    - excl_borrow_import_unconstrained_at_boundary
        (ExclBorrow is intra-function only; the boundary verifier
         doesn't check it — matches the affinescript design)
    - linear_import_unmatched_export_is_ignored
        (caller imports a name the callee doesn't export → trivially Ok)
    - linear_import_drop_and_dup_both_fire
        (if(lg0){call;call} → min=0, max=2 → both error variants fire)

  $ cargo test -p typed-wasm-verify
  running 40 tests
  ... all pass ...
  test result: ok. 40 passed; 0 failed; 0 ignored

Crate API after this commit
---------------------------

  pub mod cross;
  pub mod section;
  pub mod verify;
  pub use cross::{extract_exports, verify_cross_module};
  pub use section::{
    build_ownership_section_payload, parse_ownership_section_payload, OwnershipEntry,
  };
  pub use verify::{count_uses_range, verify_function};

  pub fn verify_from_module(wasm_bytes: &[u8]) -> Result<(), VerifyError>;

Follow-up
---------

  C5 (#40) — cross-compat regression test against affinescript-emitted
             modules (proves Rust verifier and OCaml verifier produce
             identical verdicts on real source-level fixtures)
  C6 (#41) — ephapax-wasm emits the `affinescript.ownership` section
             from linear-typed bindings
  C7 (#42) — ephapax-cli gets `--verify-ownership`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@hyperpolymath hyperpolymath merged commit 3e9d4bc into main May 15, 2026
22 of 31 checks passed
@hyperpolymath hyperpolymath deleted the feat/typed-wasm-verify-c4-cross-module branch May 15, 2026 07:07
hyperpolymath added a commit that referenced this pull request May 15, 2026
Adds `crates/typed-wasm-verify/tests/cross_compat.rs` — a 10-fixture
integration test suite that exercises the verifier on realistic
multi-function modules modelled on what affinescript's codegen
(`hyperpolymath/affinescript:lib/codegen.ml`) emits for representative
AffineScript source programs.

Each fixture's doc comment includes:

  1. The conceptual AffineScript source it represents.
  2. A trace of `tw_verify.ml` / `tw_interface.ml` over the wasm
     structure, computing what the OCaml verifier should say.
  3. The expected Rust verdict (must match the OCaml one).

The assertion in each test pins the Rust verdict to that expected
match. Any future change to the verifier that subtly alters its
output on a realistic module fails this suite before reaching `main`.

Scope decision: synthetic fixtures, not OCaml-emitted
-----------------------------------------------------

The original C5 scope called for invoking `affinescript build` to
produce real OCaml-emitted .wasm fixtures and asserting byte-for-byte
parity. That requires a built `affinescript` binary in PATH (dune build
of hyperpolymath/affinescript). The OCaml toolchain isn't currently
wired into the typed-wasm CI surface, so this PR delivers the
regression-net value now using wasm-encoder fixtures that emit the
same byte shape affinescript produces — same `affinescript.ownership`
custom section bytes, same function-index conventions, same control-
flow patterns. When the OCaml-emitter integration lands (call it C5.1),
these synthetic fixtures stay — they cover codegen-bug edge cases that
may not appear in any AS source program — and the OCaml-emitted ones
get added alongside.

Fixture coverage
----------------

  1. Clean Linear consumer (`drop s`) — pass
  2. Duplicated Linear (`s + s`) — LinearUsedMultiple
  3. Dropped Linear in else (`if(take) drop s else ()`) — LinearDroppedOnSomePath
  4. Aliased ExclBorrow (`b.len() + b.len()`) — ExclBorrowAliased
  5. Multi-fn module, one clean + one duplicated — single LinearUsedMultiple
     on fn 1 (verifies error aggregation across functions)
  6. Cross-mod: clean Linear call (`consume(s)`) — pass
  7. Cross-mod: Linear called twice — LinearImportCalledMultiple
  8. Cross-mod: 3 caller fns, only the last misuses — single
     LinearImportCalledMultiple on caller_func_idx=3 (verifies that
     functions which never call the import are not flagged)
  9. extract_exports on a module exporting Linear + ExclBorrow +
     Unrestricted shapes — correct param_kinds per export
 10. Realistic multi-fn clean module — pass across all four functions

Builder
-------

Adds an internal `ModuleBuilder` to the test file that constructs
`affinescript`-shaped modules with multiple functions, imports,
exports, and an `affinescript.ownership` section. Keeps each fixture
~30 lines so the conceptual source / wasm mapping stays legible.

Build status
------------

  $ cargo test -p typed-wasm-verify
  ... unit tests ...
  running 40 tests
  test result: ok. 40 passed; 0 failed; 0 ignored
       Running tests/cross_compat.rs
  running 10 tests
  test fixture_aliased_excl_borrow ... ok
  test fixture_dropped_linear_in_else ... ok
  test fixture_clean_linear_consumer ... ok
  test fixture_duplicated_linear ... ok
  test fixture_extract_exports_three_shapes ... ok
  test fixture_multi_function_one_buggy ... ok
  test fixture_xmod_clean_linear_call ... ok
  test fixture_realistic_clean_module ... ok
  test fixture_xmod_linear_called_twice ... ok
  test fixture_xmod_mixed_correctness ... ok
  test result: ok. 10 passed; 0 failed; 0 ignored

Stacked on top of #22 (C4). Next: C6 — ephapax-wasm emits the
`affinescript.ownership` custom section from linear-typed bindings,
then C7 — ephapax-cli gets `--verify-ownership`.

Follow-up (separate PR, future)
-------------------------------

C5.1 — Once `affinescript build` is wired into typed-wasm CI: vendor
real OCaml-emitted .wasm files alongside these synthetic ones; assert
byte-for-byte parity in the section payload and verdict-for-verdict
parity in the verifier output. Captures any drift between the two
implementations after either evolves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
hyperpolymath added a commit that referenced this pull request May 15, 2026
Adds `crates/typed-wasm-verify/tests/cross_compat.rs` — a 10-fixture
integration test suite that exercises the verifier on realistic
multi-function modules modelled on what affinescript's codegen
(`hyperpolymath/affinescript:lib/codegen.ml`) emits for representative
AffineScript source programs.

Each fixture's doc comment includes:

  1. The conceptual AffineScript source it represents.
  2. A trace of `tw_verify.ml` / `tw_interface.ml` over the wasm
     structure, computing what the OCaml verifier should say.
  3. The expected Rust verdict (must match the OCaml one).

The assertion in each test pins the Rust verdict to that expected
match. Any future change to the verifier that subtly alters its
output on a realistic module fails this suite before reaching `main`.

Scope decision: synthetic fixtures, not OCaml-emitted
-----------------------------------------------------

The original C5 scope called for invoking `affinescript build` to
produce real OCaml-emitted .wasm fixtures and asserting byte-for-byte
parity. That requires a built `affinescript` binary in PATH (dune build
of hyperpolymath/affinescript). The OCaml toolchain isn't currently
wired into the typed-wasm CI surface, so this PR delivers the
regression-net value now using wasm-encoder fixtures that emit the
same byte shape affinescript produces — same `affinescript.ownership`
custom section bytes, same function-index conventions, same control-
flow patterns. When the OCaml-emitter integration lands (call it C5.1),
these synthetic fixtures stay — they cover codegen-bug edge cases that
may not appear in any AS source program — and the OCaml-emitted ones
get added alongside.

Fixture coverage
----------------

  1. Clean Linear consumer (`drop s`) — pass
  2. Duplicated Linear (`s + s`) — LinearUsedMultiple
  3. Dropped Linear in else (`if(take) drop s else ()`) — LinearDroppedOnSomePath
  4. Aliased ExclBorrow (`b.len() + b.len()`) — ExclBorrowAliased
  5. Multi-fn module, one clean + one duplicated — single LinearUsedMultiple
     on fn 1 (verifies error aggregation across functions)
  6. Cross-mod: clean Linear call (`consume(s)`) — pass
  7. Cross-mod: Linear called twice — LinearImportCalledMultiple
  8. Cross-mod: 3 caller fns, only the last misuses — single
     LinearImportCalledMultiple on caller_func_idx=3 (verifies that
     functions which never call the import are not flagged)
  9. extract_exports on a module exporting Linear + ExclBorrow +
     Unrestricted shapes — correct param_kinds per export
 10. Realistic multi-fn clean module — pass across all four functions

Builder
-------

Adds an internal `ModuleBuilder` to the test file that constructs
`affinescript`-shaped modules with multiple functions, imports,
exports, and an `affinescript.ownership` section. Keeps each fixture
~30 lines so the conceptual source / wasm mapping stays legible.

Build status
------------

  $ cargo test -p typed-wasm-verify
  ... unit tests ...
  running 40 tests
  test result: ok. 40 passed; 0 failed; 0 ignored
       Running tests/cross_compat.rs
  running 10 tests
  test fixture_aliased_excl_borrow ... ok
  test fixture_dropped_linear_in_else ... ok
  test fixture_clean_linear_consumer ... ok
  test fixture_duplicated_linear ... ok
  test fixture_extract_exports_three_shapes ... ok
  test fixture_multi_function_one_buggy ... ok
  test fixture_xmod_clean_linear_call ... ok
  test fixture_realistic_clean_module ... ok
  test fixture_xmod_linear_called_twice ... ok
  test fixture_xmod_mixed_correctness ... ok
  test result: ok. 10 passed; 0 failed; 0 ignored

Stacked on top of #22 (C4). Next: C6 — ephapax-wasm emits the
`affinescript.ownership` custom section from linear-typed bindings,
then C7 — ephapax-cli gets `--verify-ownership`.

Follow-up (separate PR, future)
-------------------------------

C5.1 — Once `affinescript build` is wired into typed-wasm CI: vendor
real OCaml-emitted .wasm files alongside these synthetic ones; assert
byte-for-byte parity in the section payload and verdict-for-verdict
parity in the verifier output. Captures any drift between the two
implementations after either evolves.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant