feat(verify): cross-module boundary verifier (C4)#22
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tw_interface.extract_exports+Tw_interface.verify_cross_modulefromhyperpolymath/affinescript/lib/tw_interface.ml.extract_exports/verify_cross_modulestubs — the full L7+L10 boundary check is now live.(min, max)frame-stack machinery via a newCallOf(import_idx)OpCounter— no algorithm duplication.How the boundary check works
verify_cross_module(callee_iface, caller_bytes):name → &FuncInterfacemap fromcallee_iface.(slot, name).count_op_rangewithCallOf(slot):max == 0min == 0, max ≥ 1LinearImportDroppedOnSomePathmax > 1LinearImportCalledMultiple { count }min == 0, max > 1VerifyError::Cross(Vec<CrossError>)orOk(()).Test coverage (11 new)
extract_exports(3)Unrestrictedwith emptyparam_kinds(matches OCaml fallback)verify_cross_moduleend-to-end (8)linear_import_called_exactly_once_is_cleanlinear_import_called_twice_errors→LinearImportCalledMultiple{count:2}linear_import_dropped_on_some_path_errors→LinearImportDroppedOnSomePathlinear_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
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
hyperpolymath/ephapaxconsumer wiring (separate PRs there)Closes #39 once merged.