From f9f4f65b7f8cc36fda81e95a4150190f76eb1b70 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 3 Sep 2026 07:39:08 +0200 Subject: [PATCH 1/2] RQ-62-TABLEDANGLE (#1102 residual): refuse a funcref table naming a DECLINED function on the host-linked paths (#1138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red-first, measured per backend on the unfixed binary (a dynamic-index call_indirect over (elem $good $bad) where $bad declines everywhere): - ARM Thumb-2 --relocatable: exit 0, object ships, arm-none-eabi-ld links it CLEAN — the R11 table region is embedder-populated and the declined function's code and symbol are in NO object, so slot 1 is unpopulatable. THE LIVE HOLE. - A32 cortex-r5 --relocatable: identical shape, exit 0. - ARM --cortex-m self-contained: already refuses (#275 broken-dispatch- table bail, slot-precise, test-pinned) — the new gate stands down there. - RV32: call_indirect itself loud-declines, the dispatching export is skipped, #952 exits 1 — table path unreachable upstream (pinned bidirectionally by the new test so an RV32 call_indirect capability re-opens the question loudly). - aarch64: already refused via the ELF builder's #851/#1013 Err on the substrate table's b func_N trampoline; the driver gate now fires first with the uniform message, the builder refusal staying defense-in-depth. The gate sits beside the #1102 one (where skipped_funcs, compiled_funcs and the decoded funcref slots already meet for all four backends), keys on the decoded funcref-slot image + the wasm op stream — the #1116 'direct- call relocations are always index-labelled' completeness claim is neither reused nor extended — and is scoped to a RETAINED call_indirect, so a skipped elem target nothing dispatches through stays a routine partial- object skip. NOT waived by --allow-skipped-exports (the #1102 reason). Sweep-shape control: all 171 scripts/repro fixtures compiled under the arm_corpus_sweep_973 leg (cortex-m4f --relocatable --all-exports --embedder-*) — 0 hit the new gate, so EXPECTED_DECLINES is untouched. Refs #1102 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- crates/synth-cli/src/main.rs | 78 +++++ .../synth-cli/tests/tabledangle_1102_elem.rs | 329 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 crates/synth-cli/tests/tabledangle_1102_elem.rs diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index 888334db..52e00c3c 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -4509,6 +4509,84 @@ fn compile_all_exports( } } + // RQ-62-TABLEDANGLE (#1102 residual): a DECLINED function reachable ONLY + // through the funcref TABLE — an `elem` segment names it, no direct call + // does — slips the #1102 gate above, because no relocation carries its + // index label. Measured red-first per backend (v0.61, this fixture: a + // dynamic-index `call_indirect` over `(elem $good $bad)` where `$bad` + // declines): + // - ARM Thumb-2 / A32 `--relocatable`: exit 0, object ships, links + // CLEAN — the dispatch reads the R11 table region the EMBEDDER + // populates, and the declined function's code and symbol are simply + // ABSENT from the object. The embedder cannot honor the table + // contract for that slot (the #1046 argument: the function's code is + // not in the artifact, so there is nothing an embedder could point + // the slot at) — `call_indirect` to it becomes whatever garbage the + // region holds. That is the live hole this gate closes. + // - ARM/A32 self-contained (`--cortex-m` family, no imports): already + // refuses — the image builder's #275 "broken dispatch table" bail is + // slot-precise and test-pinned, so this gate deliberately stands + // DOWN there (`self_contained_funcref_table`) rather than shadowing + // a pinned refusal. + // - RV32: `call_indirect` itself is a loud per-function decline, so + // the dispatching export is skipped and #952 exits non-zero. + // - aarch64: the substrate table's `b func_N` trampoline reloc hits + // the ELF builder's #851/#1013 refusal. This gate now fires first + // with the uniform message; the builder refusal stays as + // defense-in-depth (the same layering `dangling_declined_callee_1102` + // documents for the direct-call class). + // Scoped to a RETAINED dispatch: if no compiled function performs + // `call_indirect`, no shipped code reads the table, and a skipped elem + // target is an ordinary partial-object skip (warned, and covered by #952 + // when it is an export) — refusing there would red every corpus sweep + // for a table nothing dispatches through. Deliberately NOT waived by + // `--allow-skipped-exports`, for the #1102 reason: that flag accepts a + // PARTIAL object, not one whose dispatch table cannot be populated. + if !config.self_contained_funcref_table { + let skipped_idx: std::collections::BTreeSet = + skipped_funcs.iter().map(|(_, _, _, i)| *i).collect(); + let retained_dispatch = compiled_funcs.iter().any(|cf| { + all_exports.iter().any(|f| { + f.index == cf.wasm_index + && f.ops + .iter() + .any(|op| matches!(op, WasmOp::CallIndirect { .. })) + }) + }); + if retained_dispatch && !skipped_idx.is_empty() { + let dead_slots: Vec = all_funcref_slots + .iter() + .enumerate() + .filter_map(|(slot, entry)| match entry { + Some(fidx) if skipped_idx.contains(fidx) => { + Some(format!("slot {slot} -> function {fidx}")) + } + _ => None, + }) + .collect(); + if !dead_slots.is_empty() { + anyhow::bail!( + "#1102/RQ-62-TABLEDANGLE: {} funcref-table slot(s) name \ + function(s) this compile DECLINED ({}), and a retained \ + function dispatches through the table (`call_indirect`). \ + The declined function's code is in NO object, so no \ + embedder or linker input can ever populate that slot — \ + dispatching to it would execute whatever the table \ + region holds. Refusing to emit the object rather than \ + shipping an unpopulatable dispatch table with exit 0 \ + (the #275 self-contained broken-table refusal applied \ + to the host-linked paths). See the preceding 'skipping \ + function' warning(s) for each decline reason. \ + --allow-skipped-exports does not cover this: that flag \ + accepts a PARTIAL object, not one whose dispatch table \ + cannot be populated.", + dead_slots.len(), + dead_slots.join(", ") + ); + } + } + } + // Check if any function has relocations (import calls) let has_relocations = compiled_funcs.iter().any(|f| !f.relocations.is_empty()); diff --git a/crates/synth-cli/tests/tabledangle_1102_elem.rs b/crates/synth-cli/tests/tabledangle_1102_elem.rs new file mode 100644 index 00000000..9d4a1591 --- /dev/null +++ b/crates/synth-cli/tests/tabledangle_1102_elem.rs @@ -0,0 +1,329 @@ +//! RQ-62-TABLEDANGLE (#1102 residual) — a DECLINED function reachable ONLY +//! through the funcref TABLE must fail the compile loudly on every backend. +//! +//! #1102's fix matches relocation symbols against the skipped function's +//! index labels, which is complete for DIRECT calls (verified in #1116). An +//! `elem` segment naming a declined function puts it in the table with NO +//! direct call site, so no relocation carries its index label and the #1102 +//! gate sees nothing. +//! +//! Measured red-first on the UNFIXED binary (main @ b552fb4c, v0.61), with +//! the fixture below (`$bad` declines everywhere via a v128 op the decoder +//! loud-marks; a DYNAMIC-index `call_indirect` so the dispatch cannot be +//! devirtualized — with `i32.const 0` the ARM relocatable path folds the +//! dispatch to a direct call and the table never materializes): +//! +//! | path | exit | object | notes | +//! |-------------------------------|------|--------|--------------------------------| +//! | ARM Thumb-2 `--relocatable` | 0 | ships | links CLEAN (`arm-none-eabi-ld`| +//! | | | | exit 0) — no symbol at all for | +//! | | | | the declined slot; the R11 | +//! | | | | table region is embedder- | +//! | | | | populated and slot 1 is | +//! | | | | UNPOPULATABLE (code in no | +//! | | | | object). THE LIVE HOLE. | +//! | A32 cortex-r5 `--relocatable` | 0 | ships | identical shape | +//! | ARM `--cortex-m` (self-cont.) | 1 | none | #275 broken-dispatch-table bail| +//! | RV32 (esp32c3/rv32imac) | 1 | none | `call_indirect` itself declines| +//! | | | | -> export skipped -> #952 | +//! | aarch64 | 1 | none | substrate table's `b func_N` | +//! | | | | reloc hits the ELF builder's | +//! | | | | #851/#1013 refusal | +//! +//! The fix is a driver-level gate beside the #1102 one (the site where +//! `skipped_funcs`, `compiled_funcs` and the decoded funcref slots already +//! meet for all four backends): a RETAINED function performing +//! `call_indirect` + a funcref slot naming a skipped function refuses the +//! compile. It stands DOWN on the self-contained path, whose image builder's +//! #275 slot bail is pinned by `call_indirect_275_selfcontained.rs`; on +//! aarch64 it fires before the builder, whose #851 refusal stays as +//! defense-in-depth (the same layering as the direct-call class). It is +//! scoped to a retained dispatch: a skipped elem target in a module whose +//! compiled code never dispatches is the ordinary partial-object skip. +//! +//! NOT widened speculatively: the gate keys on the decoded funcref-slot +//! image (`funcref_region_slots`) and the wasm op stream, not on any new +//! relocation-label pattern — so the #1116 "direct-call relocations are +//! always index-labelled" completeness claim is neither reused nor extended. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn synth() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_synth")) +} + +fn workdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("synth-1102-elem-{tag}")); + std::fs::create_dir_all(&d).expect("temp dir"); + d +} + +/// The red-first shape: `$bad` is in the table (slot 1) with NO direct call +/// site, and declines on EVERY backend (the decoder marks the v128 op, each +/// backend refuses the marked function). The dispatch index is a runtime +/// parameter so no path can devirtualize the `call_indirect` away. +const ELEM_DECLINED_DYN: &str = r#"(module + (type $t (func (result i32))) + (table 2 funcref) + (func $good (type $t) i32.const 7) + (func $bad (type $t) + v128.const i64x2 0 0 + i64x2.extract_lane 0 + i32.wrap_i64) + (func (export "run") (param i32) (result i32) + local.get 0 + call_indirect (type $t)) + (elem (i32.const 0) $good $bad)) +"#; + +/// Negative control: identical shape, every table function compiles. +const ELEM_ALL_GOOD: &str = r#"(module + (type $t (func (result i32))) + (table 2 funcref) + (func $good (type $t) i32.const 7) + (func $also_good (type $t) i32.const 9) + (func (export "run") (param i32) (result i32) + local.get 0 + call_indirect (type $t)) + (elem (i32.const 0) $good $also_good)) +"#; + +/// Precision control: a declined function EXISTS and a retained dispatch +/// EXISTS, but the declined function is NOT in the table — the gate must not +/// fire (under `--allow-skipped-exports`, the declined export is a routine +/// partial-object skip). +const DECLINED_NOT_IN_TABLE: &str = r#"(module + (type $t (func (result i32))) + (table 1 funcref) + (func $good (type $t) i32.const 7) + (func (export "bad") (type $t) + v128.const i64x2 0 0 + i64x2.extract_lane 0 + i32.wrap_i64) + (func (export "run") (param i32) (result i32) + local.get 0 + call_indirect (type $t)) + (elem (i32.const 0) $good)) +"#; + +fn compile(dir: &std::path::Path, wat: &str, out_name: &str, args: &[&str]) -> Output { + let src = dir.join("m.wat"); + std::fs::write(&src, wat).expect("write wat"); + let obj = dir.join(out_name); + // The temp workdir persists across runs — a stale object from an earlier + // run would make the "no object left behind" assertions vacuous. + let _ = std::fs::remove_file(&obj); + let mut c = Command::new(synth()); + c.arg("compile").arg(src.to_str().unwrap()); + c.args(args); + c.args(["-o", obj.to_str().unwrap()]); + c.output().expect("run synth compile") +} + +fn stderr(o: &Output) -> String { + String::from_utf8_lossy(&o.stderr).into_owned() +} + +/// The refusal contract for the table class: the decline anchor fired (so +/// the assertions judge THIS defect), the exit is the clean-error 1, the +/// reason names the class and the dead slot, and no object is left behind. +fn assert_table_refusal(out: &Output, dir: &std::path::Path, out_name: &str) { + let err = stderr(out); + assert!( + err.contains("skipping function 'func_1'") && err.contains("#680"), + "fixture no longer trips the v128 decline this test depends on — \ + premise gone, revisit rather than pass on some other error.\nstderr:\n{err}" + ); + assert_eq!( + out.status.code(), + Some(1), + "expected the clean refusal (exit 1); 0 means an object with an \ + unpopulatable dispatch table was shipped, 101 means a panic.\nstderr:\n{err}" + ); + assert!( + !err.contains("panicked at") && !err.contains("RUST_BACKTRACE"), + "refusal was delivered via panic, not a clean error.\nstderr:\n{err}" + ); + assert!( + err.contains("RQ-62-TABLEDANGLE") && err.contains("slot 1 -> function 1"), + "refusal must name the table class and the dead slot.\nstderr:\n{err}" + ); + assert!( + !dir.join(out_name).exists(), + "refused compile still wrote an output object" + ); +} + +/// RED on the unfixed binary (exit 0, object shipped, linked clean with the +/// declined function's code in no object): ARM Thumb-2 `--relocatable`. +#[test] +fn arm_thumb2_relocatable_refuses_table_dangle() { + let dir = workdir("arm-rel"); + let out = compile( + &dir, + ELEM_DECLINED_DYN, + "a.o", + &["--target", "cortex-m3", "--relocatable"], + ); + assert_table_refusal(&out, &dir, "a.o"); +} + +/// RED on the unfixed binary (identical shape to Thumb-2): A32 cortex-r5. +#[test] +fn a32_cortex_r5_relocatable_refuses_table_dangle() { + let dir = workdir("a32-rel"); + let out = compile( + &dir, + ELEM_DECLINED_DYN, + "r.o", + &["--target", "cortex-r5", "--relocatable"], + ); + assert_table_refusal(&out, &dir, "r.o"); +} + +/// `--allow-skipped-exports` must NOT waive the refusal — that flag accepts +/// a PARTIAL object, not one whose dispatch table cannot be populated. +#[test] +fn allow_skipped_exports_does_not_waive_table_refusal() { + let dir = workdir("arm-rel-waive"); + let out = compile( + &dir, + ELEM_DECLINED_DYN, + "w.o", + &[ + "--target", + "cortex-m3", + "--relocatable", + "--allow-skipped-exports", + ], + ); + assert_table_refusal(&out, &dir, "w.o"); +} + +/// aarch64 already refused (the substrate table's `b func_N` trampoline hit +/// the ELF builder's #851/#1013 refusal, exit 1); the driver gate now fires +/// first with the uniform message, the builder refusal staying as +/// defense-in-depth. This leg pins the driver gate; a fall-through to the +/// builder message would mean the gate stopped covering aarch64. +#[test] +fn aarch64_refuses_table_dangle_at_driver() { + let dir = workdir("a64"); + let out = compile(&dir, ELEM_DECLINED_DYN, "a64.o", &["-b", "aarch64"]); + assert_table_refusal(&out, &dir, "a64.o"); +} + +/// RV32's table path is unreachable UPSTREAM: `call_indirect` itself is a +/// loud per-function decline, so the dispatching export is skipped and #952 +/// exits non-zero. This leg pins that upstream guard BIDIRECTIONALLY — if +/// RV32 ever gains `call_indirect`, the #952 anchor disappears and this test +/// goes red, forcing the table-dangle question to be re-answered for RV32 +/// rather than silently inheriting an unverified "covered". +#[test] +fn rv32_upstream_call_indirect_decline_guards_table_path() { + let dir = workdir("rv32"); + let out = compile( + &dir, + ELEM_DECLINED_DYN, + "rv.o", + &["-b", "riscv", "--target", "esp32c3", "--relocatable"], + ); + let err = stderr(&out); + assert!( + err.contains("skipping function 'run'") && err.contains("CallIndirect"), + "RV32 no longer declines call_indirect — the table-dangle gate's \ + RV32 coverage rested on this upstream decline; re-verify the table \ + path red-first before trusting it.\nstderr:\n{err}" + ); + assert_eq!( + out.status.code(), + Some(1), + "expected #952 to refuse the skipped export.\nstderr:\n{err}" + ); + assert!( + err.contains("#952"), + "expected the #952 gate.\nstderr:\n{err}" + ); + assert!( + !dir.join("rv.o").exists(), + "refused compile still wrote an output object" + ); +} + +/// The self-contained path keeps its own pinned refusal: the cortex-m image +/// builder's #275 broken-dispatch-table bail (the driver gate stands down +/// there so the slot-precise, test-pinned message is preserved). +#[test] +fn arm_selfcontained_keeps_275_bail() { + let dir = workdir("arm-sc"); + let out = compile( + &dir, + ELEM_DECLINED_DYN, + "sc.elf", + &["--cortex-m", "--target", "cortex-m3"], + ); + let err = stderr(&out); + assert_eq!(out.status.code(), Some(1), "stderr:\n{err}"); + assert!( + err.contains("refusing to link a broken dispatch table"), + "the self-contained path must keep its #275 bail.\nstderr:\n{err}" + ); + assert!(!dir.join("sc.elf").exists()); +} + +/// Negative control: every table function compiles — all paths that accepted +/// the module before the gate still accept it. +#[test] +fn all_good_table_still_compiles() { + for (tag, args) in [ + ( + "good-arm-rel", + &["--target", "cortex-m3", "--relocatable"][..], + ), + ("good-a64", &["-b", "aarch64"][..]), + ("good-arm-sc", &["--cortex-m", "--target", "cortex-m3"][..]), + ] { + let dir = workdir(tag); + let out = compile(&dir, ELEM_ALL_GOOD, "g.elf", args); + let err = stderr(&out); + assert_eq!( + out.status.code(), + Some(0), + "control module must still compile ({tag}).\nstderr:\n{err}" + ); + assert!( + dir.join("g.elf").exists(), + "control object was not emitted ({tag})" + ); + } +} + +/// Precision control: a decline + a retained dispatch, but the declined +/// function is NOT in the table — the gate must not fire. +#[test] +fn declined_function_outside_table_does_not_trip_gate() { + let dir = workdir("outside"); + let out = compile( + &dir, + DECLINED_NOT_IN_TABLE, + "o.o", + &[ + "--target", + "cortex-m3", + "--relocatable", + "--allow-skipped-exports", + ], + ); + let err = stderr(&out); + assert!( + err.contains("skipping function 'bad'"), + "control's decline premise gone.\nstderr:\n{err}" + ); + assert_eq!( + out.status.code(), + Some(0), + "a declined function outside the table must stay a routine \ + partial-object skip.\nstderr:\n{err}" + ); + assert!(dir.join("o.o").exists(), "control object was not emitted"); +} From 8bf84b5e6e1372974bb6f0337a16d45fd65d43af Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 3 Sep 2026 07:44:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(rivet):=20record=20RQ-62-TABLEDANGLE?= =?UTF-8?q?=20increment=20=E2=80=94=20landed=20in=20PR=20#1138,=20status?= =?UTF-8?q?=20stays=20proposed=20pending=20release=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1102 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L --- artifacts/release-v0.62/RQ-62-TABLEDANGLE.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/artifacts/release-v0.62/RQ-62-TABLEDANGLE.yaml b/artifacts/release-v0.62/RQ-62-TABLEDANGLE.yaml index d20623ee..dee8bf4c 100644 --- a/artifacts/release-v0.62/RQ-62-TABLEDANGLE.yaml +++ b/artifacts/release-v0.62/RQ-62-TABLEDANGLE.yaml @@ -45,4 +45,5 @@ artifacts: priority: must verification-track: differential issue: "#1102" + landed: "#1138" done-when: "manual: a module whose elem segment names a DECLINED function with no direct call site either links or refuses loudly on all four backends — demonstrated red-first per backend, never inferred from the direct-call path"