Skip to content

v0.91 COMPEXPORTS (#1191): refuse a WebAssembly component instead of decoding it as one flattened module - #1687

Open
avrabe wants to merge 1 commit into
mainfrom
v091-compexports
Open

avrabe wants to merge 1 commit into
mainfrom
v091-compexports

Conversation

@avrabe

@avrabe avrabe commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

v0.91 COMPEXPORTS: refuse a WebAssembly component loudly instead of decoding it as one flattened core module

Refs #1191 · RQ-91-COMPEXPORTS

What was wrong

I ran the record's refuting command first, on main 6b697bc, and the defect is LIVE. The composed toy declares import t:c/mmio and export t:c/time only, yet the object defines T t:c/sched#state (an internal interface) and has no symbol for t:c/time#now. The compile exits 0 with no warning.

The root cause is not specific to --relocatable. Both decode functions in crates/synth-core/src/wasm_decoder.rs (decode_wasm_module, decode_wasm_functions) walk the input with Parser::parse_all, which descends into every nested core module of a component. Neither had a Payload::Version arm. A component was therefore decoded as ONE module whose function, import and export index spaces were the concatenation of its nested modules'. The order of the modules decides which symptom shows:

shape what synth emitted on 6b697bc (rc 0 on every backend and mode)
1, the record's toy defines internal t:c/sched#state; nothing for the declared t:c/time#now
2, the other plug order defines internal t:c/time#now; nothing for the declared t:c/sched#state; 0 undefined for 1 declared import
3, importer's module first (the gale-nano shape) U now, which is internal wiring, beside the real U read32. The import half reproduces.
4, two plain core modules the name g binds to the wrong body: executed under unicorn it returns 2, while wasmtime's component runtime returns 101. Silent wrong code.
5, arena import served by a second module U __cabi_arena_realloc for a component that declares no imports; on self-contained Cortex-M the #418 binder rewrote the component before decode and failed with a misleading "internal (bug)" error

The change

synth_core::refuse_component_encoding uses wasmparser's Parser::is_component. It is called:

  • first in both decode functions;
  • in the CLI ahead of maybe_run_loom / arena_bind, on all three input paths.

The diagnostic names the hazard, cites #1191 and gives the supported route: meld fuse <component.wasm> -o fused.wasm, then compile the core module.

Why every component is refused, rather than a "safe subset": canonical-ABI options (string encoding, realloc, post-return) are never honoured, a lift can rename an export, one core module can be instantiated twice, and wit-component inserts shim/fixup core modules once an import needs memory. Telling a safe subset apart would take exactly the kind of hand-written analysis that produced this silent class.

Newly refused: every component-encoded input (binary layer 1, or component text) on every backend and mode. 0 pre-existing in-repo inputs are components (measured on 6b697bc). This PR adds 5 component fixtures, refused by design: they are listed in arm_corpus_sweep_973.py's REFUSED_BY_DESIGN, and the boot sweep counts them as declines.

Acceptance ladder: NOT re-measured. /Volumes/Work/corpora/wasm-243 was not mounted. The last per-stratum figures on record (v0.62, RQ-62-REACH) accepted 6 / 5 / 5 of 112 components on arm / riscv / aarch64. Those are expected to become declines, and re-measurement is pending until the corpus is mounted. A dated note now says so beside the ladder header.

Evidence: scripts/repro/component_input_1191_differential.py

The script is wired in aarch64-oracle. Each fixture's declared interface is derived from wasmtime 48's own component type, and each declared export's expected value comes from wasmtime's component runtime.

run result
--expect-red on 6b697bc, and again on current main 58a9c28 PASS on both: 35/35 shape × mode cases red, 5/5 symbol tables disagree with the declared interface, shape 4 g() executes to 2 vs 101
default on the branch PASS: 35/35 refused, rc 1, naming #1191 and meld fuse, no output file written
default on 6b697bc and on 58a9c28 FAIL (0 refusals)
--expect-red on the branch FAIL (0 red cases)

The paired controls pass on both trees:

  • shape 4's core-module twin compiles and its f/g execute to wasmtime's 1 / 101;
  • so does its frozen meld 0.9.0 fused module (sha256 pinned; its attestation's input hash equals shape 4's assembled bytes);
  • both objects are byte-identical between this branch and main (checked against 6b697bc, and again against 58a9c28).

Mutants (each restored after):

  • helper made always-Ok → oracle RED (0/35) and 2/3 new synth-core unit tests RED;
  • check removed from decode_wasm_functions only → unit test component_1191_refused_by_decode_wasm_functions RED (the oracle cannot reach that function in the default build);
  • the three CLI-level calls removed → oracle RED on shape 5 arm-self-contained (34/35).

The new oracle declares ci-checks: emulations >= 4 (4 measured via oracle_run.py: f/g of the twin and of the fused module). The branch is rebased onto main 58a9c28, which includes #1679 (EMUFLOOR), so no summed pin moves: oracle_wiring_check.py --floor-base holds each oracle's own header against the base tree and reports "205 compared, 0 raised, 0 waived, 1 new" against 58a9c28.

Local checks (on the tree rebased onto 58a9c28)

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings: clean. cargo test --workspace: 3296 passed, 0 failed.
  • Feature sets: --features riscv, --features verify (its six --test targets: 29 passed), exports_only_275_probe, and synth-verify --features z3-solver,arm --no-run all build. synth verify on a component also refuses with the --relocatable on a component: 6 undefined symbols where the component declares 2 imports, and the declared exports (gust:os/time) are not emitted #1191 diagnostic.
  • Claim Check: all 55 steps run whole under pipefail via prep/cc_sweep.py with GH_TOKEN: 55/55 pass.
  • wasmtime-py: CI's pip install wasmtime is unpinned and resolves to 50.0.1, which wraps component types in ComponentExtern. The oracle accepts both shapes and passes red and green under 48.0.0 and 50.0.1.
  • Every job in ci.yml and spec-suite.yml that reads the corpus (scripts/repro, tests/fixtures, tests/wast, tests/wat, the spec testsuite, git ls-files; 58 jobs) was run whole, step by step, on the branch. Every non-zero step is environmental, and I re-ran each one against main 58a9c28's binary on main's tree with the same result:
    • toolchain or runner asserts (ld.lld/qemu, native arm64, the clang shim for Mach-O, the WCET synth path, shellcheck/py39 variables);
    • the RV32 boot and extern-call oracles that need the riscv toolchain;
    • the shared local evidence-ledger file;
    • the instrument-independence git diff --exit-code assert, which saw my then-uncommitted pin edit.
  • Corpus pins that read scripts/repro/*:
    • The ARM corpus sweep (arm_corpus_sweep_973.py) lists the 5 component fixtures under its existing REFUSED_BY_DESIGN (the custom-page-sizes: (memory 1 1 (pagesize 1)) compiles silently with __synth_mem_size = 0x10000 — region table over-grants, no diagnostic #1315 precedent) with the #1191 needle, and passes: 223 fixtures, 196 compiled, 21 declined, 6 refused-by-design, 0 hard errors. The twin compiles and executes in it.
    • The home-alias audit corpus moves its exact pins: functions audited 7743 → 7749 and attributed instructions 244147 → 244157; homes watched stays 9312 and hits: 0. This is corpus growth only, decomposed by isolation. With component_1191_twin.wat held aside, this branch's binary prints main 58a9c28's 7743 / 9312 / 244147 exactly, which is also what main's binary prints over main's tree. The five components contribute 0 because they are refused before selection. Main's binary over this tree prints 7761 / 244191: it still audits the flattened components.
    • self_contained_boot_sweep_1189, the #1229 false-reject TOTAL, the Mach-O identity sweep (one more byte-identical module, 0 disagreements) and the spec-suite census and execution steps pass with their pins unchanged.
  • docs/status/mutation_survey.json is re-anchored (mutation_survey.py reanchor): 7 sites moved, 0 not found; the sample is unchanged at 38 drawn = 34 live + 4 retired. artifacts/status.json regenerated with no change.

Rebased onto b705cdd (after #1697, #1702, #1705, #1706, #1707)

The figures above were measured on base 58a9c28. Rebasing onto 5dc4e28 conflicted only in two measured pins, and both were re-derived, not hand-merged:

  • Home-alias audit corpus (ci.yml): MVDROP (v0.91 MVDROP: an ARM function returns its wasm stack top; a multi-result call is refused #1697) had moved main's pins to 7739 / 244173. On the combined tree this branch prints functions audited: 7745, homes watched: 9312, attributed instructions: 244183, hits: 0. Paired control: with component_1191_twin.wat held aside, the same binary prints main's own 7739 / 9312 / 244173. The pins are now 7745 and 244183, again corpus growth only (+6 / +10).
  • docs/status/mutation_survey.json: main's side was taken and mutation_survey.py reanchor re-run (7 sites moved, 0 not found).

The later rebase onto b705cdd (wasm-tools 0.263) was clean. On that head: cargo metadata --locked, fmt and clippy clean; cargo test --workspace --no-fail-fast 3302 passed / 0 failed; the audit prints 7745 / 9312 / 244183, hits: 0; component_input_1191_differential.py exit 0 (emulations 4 >= 4); reanchor moves 0 sites; Claim Check 55/55; spec compile census PASS on all five backends.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

@avrabe
avrabe force-pushed the v091-compexports branch 3 times, most recently from d280151 to 5b9c6ed Compare November 11, 2026 15:17
@codecov

codecov Bot commented Nov 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…decoding it as one flattened module

Both decode functions walked the input with Parser::parse_all, which
descends into every nested core module of a component, and had no
Payload::Version arm. A component was decoded as ONE module whose index
spaces were the concatenation of its nested modules': exit 0 on every
backend and mode, exporting internal interfaces, omitting declared ones,
leaving internal wiring undefined, and on a two-module shape binding the
export `g` to the wrong body (2 under unicorn vs 101 from wasmtime's
component runtime).

synth_core::refuse_component_encoding now refuses every component-encoded
input, first in decode_wasm_module and decode_wasm_functions and in the CLI
ahead of loom/arena_bind, naming #1191 and the `meld fuse` route.

Evidence: scripts/repro/component_input_1191_differential.py, red on
6b697bc (35/35 cases, 5/5 symbol tables wrong, g() = 2), green here
(35/35 refused), with core-module controls executing to wasmtime's values
byte-identically on both trees; three mutants each red. The new oracle
declares `emulations >= 4`, held per-oracle by --floor-base (no summed
pin). The home-alias audit pins move by the new core twin fixture only
(+6 functions, +10 attributed), decomposed by isolation. Acceptance
ladder annotated: not re-measured, corpus unmounted.

Refs #1191

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
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