fix(safety-bounds): #651 — mask bounds the EFFECTIVE address (offset folded before the AND, final byte clamped) - #654
Merged
Conversation
…folded before the AND, final byte clamped)
--safety-bounds mask (direct/thumb-2 path) applied the mask to the
OPERAND only and added the static offset AFTER the AND:
and.w r0, r0, r10 ; operand & R10
movw/movt r12, #offset
add.w r12, r0, r12 ; offset re-added AFTER the mask
ldr.w r0, [r11, r12] ; escapes the bound by up to ~4 GiB
Two bugs, one lowering:
1. offset ordering — WASM's effective address is `operand + offset`
(u33); masking the operand alone lets any non-zero offset escape.
2. mask value — the AND used R10 = memory SIZE in bytes, not `size-1`:
for the default 64 KiB memory, `addr & 0x10000` keeps only bit 16,
remapping IN-BOUNDS accesses (0xffff -> 0).
New lowering (all 8 masking-emission sites funnel through one helper,
`mask_effective_address`):
[movw/movt r12, #offset ; offsets > 0xFF materialized
add addr, addr, r12] ; fold the static offset FIRST
sub r12, r10, #1 ; mask = size-1, derived per access
and addr, addr, r12 ; ea & (size-1)
[sub r12, r12, #(size-1) ; clamp start to size - access_size so
cmp addr, r12 ; the access's FINAL byte stays inside
it hi; movhi addr, r12] ; the bound (skipped for byte accesses)
ldr/str [r11, addr] ; offset 0 — nothing re-added post-mask
Soundness of ADD-then-AND (no decline needed for large offsets): the
u33 effective address `ea = operand + offset < 2^33`; ARM's ADD gives
`ea mod 2^32`, and every set bit of `size-1 < 2^32` lies below bit 32,
so `(ea mod 2^32) & (size-1) == ea & (size-1)` exactly. The final-byte
clamp mirrors #640's `offset + access_size - 1` software-guard rule and
never alters a wasm-defined access: a masked start above
`size - access_size` implies the wasm access traps, which the mask
profile deliberately replaces with a deterministic in-bounds access
(wrap-not-trap, design doc §3.1 path C).
Sites audited: the 8 generate_{load,store,i64_load,i64_store,
i64_load_into_regs,i64_store_from_regs,subword_load,subword_store}
_with_bounds_check Masking arms (all now delegate); arm_encoder has no
mask emission; bulk-memory memory.copy/fill masking remains the
separately-documented #374 gap; the optimized path already declines
mask to the direct selector (#640).
Also: the ARM backend now declines a non-power-of-two linear-memory
size under mask loudly (mirroring the RISC-V backend) — `AND (size-1)`
would silently remap in-bounds addresses.
Oracles:
- tests/issue_651_mask_effective_address.rs — executable differential:
mini ARM interpreter runs the selected ops and checks the wasm-side
address of the actual access. 8/8 RED on origin/main (huge-offset
escape 0xffff0000, small-offset escape, store variant, in-bounds
address preservation, word/i64 final-byte clamp, byte-access
exactness, halfword boundary); 8/8 green with the fix.
- selector unit tests pin the emitted sequence (offset ADD strictly
before the AND, MOVW/MOVT materialization, offset-0 access, no clamp
for byte accesses).
- frozen anchors 10/10 (fixtures don't use --safety-bounds); full
workspace suite green; fmt + clippy -D warnings clean.
Closes #651
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
avrabe
added a commit
that referenced
this pull request
Jul 8, 2026
avrabe
added a commit
that referenced
this pull request
Jul 8, 2026
…s gain the guard (#658) RV32 twin of ARM PR #654 (the #651 class): emit_bounds_check's Mask arm masked the OPERAND and the caller re-added the static offset in the access addressing mode — any non-zero offset escaped the bound — and multi-byte accesses never clamped their FINAL byte. i64.load/i64.store additionally carried NO guard at all (Software and Mask modes alike). emit_bounds_check now returns (reg, residual_offset); the Mask arm computes masked = min((operand + offset) & (size-1), size - access_size) (offset folded FIRST, u33 ADD-then-AND soundness documented, final byte clamped via bgeu+copy, byte accesses skip the clamp) and returns residual offset 0 so nothing is re-added after the bound. Mask-mode accesses now accept arbitrary 32-bit static offsets (previously declined > 2047 in offset_to_imm). The mask value was already size-1 (built in build_options, non-power-of-two declines loudly at compile time) — pinned by the oracle. Oracle: scripts/repro/mask_bounds_655_riscv_differential.py (unicorn RV32, mask-semantics model + sentinel region + software-trap checks) — 10 failures on origin/main (offset-escape load/store, word overhang, halfword wrap at ea==size, i64 mask escapes, software-mode i64 no-trap, huge-offset decline), all green here. Plus 3 selector shape tests (rv32_mask_folds_offset_before_ and_655, rv32_mask_clamps_final_byte_for_multibyte_only_655, rv32_i64_load_store_guarded_655). Refs #651, PR #654. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…d mask profile Two defects in the #374 memory.copy/memory.fill lowering (select_with_stack), one lane: (dst/src as walking loop pointers, len as the byte buffer). LocalGet of a register-homed local (AAPCS param r0-r3, promoted local r4-r8) pushes the HOME register itself, so a local reused AFTER the op read a wild mem_base+cursor pointer or the last byte copied. Fix, mirroring the #193 reservation discipline: `bulk_mutable_operand` copies a popped operand into a fresh scratch before mutation when it is still live (live param/promoted home, duplicate vstack entry, if/block result reg, or aliased to another popped operand of the same op); a provably-dead temp is used in place, keeping the const-operand shapes byte-identical (#374 differential still 16/16). The red differential also exposed the #663-class range-realloc hole the fix then tripped over: `try_reallocate_segment` treated a pool register with NO range in a segment as free, but such a register can be LIVE-THROUGH (a param home the segment never touches — the memcpy backward path recolored its walking-pointer intermediate onto R0, the still-live dst local). Absent pool colours are now blocked with synthetic pinned interference nodes; identity colouring within the segment's present registers always exists, so no recoloring the original bytes had is lost (frozen anchors stay 10/10 bit-identical). Relaxed-exit terminal segments keep the #580 exemptions (only absent R0/R1 blocked past the bx lr). was emitted byte-identical to `none` while safety-manifest.json still attested "mask" (attestation-integrity hole). The lowering now applies the scalar #651/#654 mask_effective_address wrap-not-trap discipline: dst and src effective addresses fold with AND (size-1) and len clamps to size-dst / size-src so the FINAL byte stays in bounds — every loop access lands in [0, size), wasm-in-bounds ops are unchanged, and the manifest's mask claim is now backed by the emission (mask ≢ none proven by the pure-bulk byte-diff gate). Oracles (all run locally, red on v0.37.1 → green here): - scripts/repro/bulk_local_clobber_677_differential.py — 2/8 → 8/8 vs wasmtime under unicorn (dst/src/len reuse + const control). - scripts/repro/bulk_mask_679_differential.py — pure-bulk byte-diff (identical → differs), manifest coherence, escape/fold/clamp vectors with R10=4096 and out-of-bound containment (raw escaped writes → contained). - scripts/repro/bulk_memory_374_differential.py — 16/16 (unchanged shapes byte-identical; script gains SYNTH env override). - frozen_codegen_bytes 10/10; safety_bounds_377 13/13+13/13; unreachable_665, i32_shift_mask_682 PASS; cargo test --workspace green; fmt + clippy -D clean. - 8 new selector unit tests (677 preservation/aliasing/no-copy-when-dead, 679 fold+clamp presence, mask≠none structural). Both oracles are CI-wired in the trap-semantics job (#489 discipline). Closes #677. Closes #679. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…d mask profile Two defects in the #374 memory.copy/memory.fill lowering (select_with_stack), one lane: (dst/src as walking loop pointers, len as the byte buffer). LocalGet of a register-homed local (AAPCS param r0-r3, promoted local r4-r8) pushes the HOME register itself, so a local reused AFTER the op read a wild mem_base+cursor pointer or the last byte copied. Fix, mirroring the #193 reservation discipline: `bulk_mutable_operand` copies a popped operand into a fresh scratch before mutation when it is still live (live param/promoted home, duplicate vstack entry, if/block result reg, or aliased to another popped operand of the same op); a provably-dead temp is used in place, keeping the const-operand shapes byte-identical (#374 differential still 16/16). The red differential also exposed the #663-class range-realloc hole the fix then tripped over: `try_reallocate_segment` treated a pool register with NO range in a segment as free, but such a register can be LIVE-THROUGH (a param home the segment never touches — the memcpy backward path recolored its walking-pointer intermediate onto R0, the still-live dst local). Absent pool colours are now blocked with synthetic pinned interference nodes; identity colouring within the segment's present registers always exists, so no recoloring the original bytes had is lost (frozen anchors stay 10/10 bit-identical). Relaxed-exit terminal segments keep the #580 exemptions (only absent R0/R1 blocked past the bx lr). was emitted byte-identical to `none` while safety-manifest.json still attested "mask" (attestation-integrity hole). The lowering now applies the scalar #651/#654 mask_effective_address wrap-not-trap discipline: dst and src effective addresses fold with AND (size-1) and len clamps to size-dst / size-src so the FINAL byte stays in bounds — every loop access lands in [0, size), wasm-in-bounds ops are unchanged, and the manifest's mask claim is now backed by the emission (mask ≢ none proven by the pure-bulk byte-diff gate). Oracles (all run locally, red on v0.37.1 → green here): - scripts/repro/bulk_local_clobber_677_differential.py — 2/8 → 8/8 vs wasmtime under unicorn (dst/src/len reuse + const control). - scripts/repro/bulk_mask_679_differential.py — pure-bulk byte-diff (identical → differs), manifest coherence, escape/fold/clamp vectors with R10=4096 and out-of-bound containment (raw escaped writes → contained). - scripts/repro/bulk_memory_374_differential.py — 16/16 (unchanged shapes byte-identical; script gains SYNTH env override). - frozen_codegen_bytes 10/10; safety_bounds_377 13/13+13/13; unreachable_665, i32_shift_mask_682 PASS; cargo test --workspace green; fmt + clippy -D clean. - 8 new selector unit tests (677 preservation/aliasing/no-copy-when-dead, 679 fold+clamp presence, mask≠none structural). Both oracles are CI-wired in the trap-semantics job (#489 discipline). Closes #677. Closes #679. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…d mask profile Two defects in the #374 memory.copy/memory.fill lowering (select_with_stack), one lane: (dst/src as walking loop pointers, len as the byte buffer). LocalGet of a register-homed local (AAPCS param r0-r3, promoted local r4-r8) pushes the HOME register itself, so a local reused AFTER the op read a wild mem_base+cursor pointer or the last byte copied. Fix, mirroring the #193 reservation discipline: `bulk_mutable_operand` copies a popped operand into a fresh scratch before mutation when it is still live (live param/promoted home, duplicate vstack entry, if/block result reg, or aliased to another popped operand of the same op); a provably-dead temp is used in place, keeping the const-operand shapes byte-identical (#374 differential still 16/16). The red differential also exposed the #663-class range-realloc hole the fix then tripped over: `try_reallocate_segment` treated a pool register with NO range in a segment as free, but such a register can be LIVE-THROUGH (a param home the segment never touches — the memcpy backward path recolored its walking-pointer intermediate onto R0, the still-live dst local). Absent pool colours are now blocked with synthetic pinned interference nodes; identity colouring within the segment's present registers always exists, so no recoloring the original bytes had is lost (frozen anchors stay 10/10 bit-identical). Relaxed-exit terminal segments keep the #580 exemptions (only absent R0/R1 blocked past the bx lr). was emitted byte-identical to `none` while safety-manifest.json still attested "mask" (attestation-integrity hole). The lowering now applies the scalar #651/#654 mask_effective_address wrap-not-trap discipline: dst and src effective addresses fold with AND (size-1) and len clamps to size-dst / size-src so the FINAL byte stays in bounds — every loop access lands in [0, size), wasm-in-bounds ops are unchanged, and the manifest's mask claim is now backed by the emission (mask ≢ none proven by the pure-bulk byte-diff gate). Oracles (all run locally, red on v0.37.1 → green here): - scripts/repro/bulk_local_clobber_677_differential.py — 2/8 → 8/8 vs wasmtime under unicorn (dst/src/len reuse + const control). - scripts/repro/bulk_mask_679_differential.py — pure-bulk byte-diff (identical → differs), manifest coherence, escape/fold/clamp vectors with R10=4096 and out-of-bound containment (raw escaped writes → contained). - scripts/repro/bulk_memory_374_differential.py — 16/16 (unchanged shapes byte-identical; script gains SYNTH env override). - frozen_codegen_bytes 10/10; safety_bounds_377 13/13+13/13; unreachable_665, i32_shift_mask_682 PASS; cargo test --workspace green; fmt + clippy -D clean. - 8 new selector unit tests (677 preservation/aliasing/no-copy-when-dead, 679 fold+clamp presence, mask≠none structural). Both oracles are CI-wired in the trap-semantics job (#489 discipline). Closes #677. Closes #679. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
…copy/fill (#695) * fix(bulk-memory): #677 operand-register clobber + #679 silent-unmasked mask profile Two defects in the #374 memory.copy/memory.fill lowering (select_with_stack), one lane: (dst/src as walking loop pointers, len as the byte buffer). LocalGet of a register-homed local (AAPCS param r0-r3, promoted local r4-r8) pushes the HOME register itself, so a local reused AFTER the op read a wild mem_base+cursor pointer or the last byte copied. Fix, mirroring the #193 reservation discipline: `bulk_mutable_operand` copies a popped operand into a fresh scratch before mutation when it is still live (live param/promoted home, duplicate vstack entry, if/block result reg, or aliased to another popped operand of the same op); a provably-dead temp is used in place, keeping the const-operand shapes byte-identical (#374 differential still 16/16). The red differential also exposed the #663-class range-realloc hole the fix then tripped over: `try_reallocate_segment` treated a pool register with NO range in a segment as free, but such a register can be LIVE-THROUGH (a param home the segment never touches — the memcpy backward path recolored its walking-pointer intermediate onto R0, the still-live dst local). Absent pool colours are now blocked with synthetic pinned interference nodes; identity colouring within the segment's present registers always exists, so no recoloring the original bytes had is lost (frozen anchors stay 10/10 bit-identical). Relaxed-exit terminal segments keep the #580 exemptions (only absent R0/R1 blocked past the bx lr). was emitted byte-identical to `none` while safety-manifest.json still attested "mask" (attestation-integrity hole). The lowering now applies the scalar #651/#654 mask_effective_address wrap-not-trap discipline: dst and src effective addresses fold with AND (size-1) and len clamps to size-dst / size-src so the FINAL byte stays in bounds — every loop access lands in [0, size), wasm-in-bounds ops are unchanged, and the manifest's mask claim is now backed by the emission (mask ≢ none proven by the pure-bulk byte-diff gate). Oracles (all run locally, red on v0.37.1 → green here): - scripts/repro/bulk_local_clobber_677_differential.py — 2/8 → 8/8 vs wasmtime under unicorn (dst/src/len reuse + const control). - scripts/repro/bulk_mask_679_differential.py — pure-bulk byte-diff (identical → differs), manifest coherence, escape/fold/clamp vectors with R10=4096 and out-of-bound containment (raw escaped writes → contained). - scripts/repro/bulk_memory_374_differential.py — 16/16 (unchanged shapes byte-identical; script gains SYNTH env override). - frozen_codegen_bytes 10/10; safety_bounds_377 13/13+13/13; unreachable_665, i32_shift_mask_682 PASS; cargo test --workspace green; fmt + clippy -D clean. - 8 new selector unit tests (677 preservation/aliasing/no-copy-when-dead, 679 fold+clamp presence, mask≠none structural). Both oracles are CI-wired in the trap-semantics job (#489 discipline). Closes #677. Closes #679. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: clippy values_mut + harness reads symtab by SHT_SYMTAB type (#489 pattern) - liveness.rs: iter_mut over map → values_mut (rust-1.97 clippy). - The two #677/#679 differential harnesses read .symtab via get_section_by_name, which returns None because synth emits an unnamed SHT_SYMTAB section; switched to iterate by sh_type (the established #489 pattern all other harnesses use). Both oracles PASS locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <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.
Fixes #651.
The bug (two, in one lowering)
--safety-bounds mask(direct selector, thumb-2) emitted, for(i32.load offset=0xffff0000 (local.get 0)):operand + offset(u33). Masking the operand only lets any non-zero static offset escape the bound — the entire point of the masking profile (bounded, trap-free access) defeated.size-1. For the default 64 KiB memory,addr & 0x10000keeps only bit 16 — in-bounds accesses were remapped too (0xffff → 0).The fix
All 8 masking-emission sites now delegate to one helper,
mask_effective_address(crates/synth-synthesis/src/instruction_selector.rs), which computesSoundness argument for ADD-then-AND (large offsets do NOT decline)
ea = operand + offset < 2^33(both u32). The ARM ADD computesea mod 2^32; every set bit of the masksize-1 < 2^32lies below bit 32, so(ea mod 2^32) & (size-1) == ea & (size-1)— the u33 carry is annihilated by the AND either way. Arbitrary 32-bit offsets are therefore handled exactly (materialized via MOVW/MOVT when > 0xFF), no decline needed.Final byte (mirroring #640's
offset + access_size - 1software-guard rule): the AND bounds only the first byte; a multi-byte access starting in the lastaccess_size-1bytes would overhang, so the CMP/IT-HI clamp caps the start atsize - access_size, givingstart + access_size - 1 ≤ size - 1unconditionally. The clamp never alters a wasm-defined access: a masked start abovesize - access_sizefor an in-rangeeaimpliesea + access_size - 1 ≥ size, which wasm traps — the mask profile deliberately replaces that trap with a deterministic in-bounds access (wrap-not-trap, design doc §3.1 path C). In-bounds accesses are executed at their exact address; the profile's power-of-two contract is now enforced loudly by the ARM backend (mirroring the RISC-V backend's compile-time decline) sinceAND (size-1)on a non-power-of-two size would silently remap in-bounds addresses.Sites audited (no wildcard survives)
instruction_selector.rs: all 8generate_{load,store,i64_load,i64_store,i64_load_into_regs,i64_store_from_regs,subword_load,subword_store}_with_bounds_checkMasking arms — every one had the identical operand-first AND; all now delegate to the single helper, and the memory op itself uses offset 0 under Masking.arm_encoder.rs: no mask emission (grep-verified).memory.copy/fill): masking remains the separately-documented arm backend cannot lower memory.copy/memory.fill — 19 sites, falcon's largest remaining on-target gap #374 gap (Software is the guarded mode there) — unchanged.Masklowering masks the operand before the offset too (emit_bounds_checkreturns the masked operand, callers fold the offset into the addressing) — same class, separate backend, not touched here; will file follow-up.Red → green
crates/synth-synthesis/tests/issue_651_mask_effective_address.rs— executable differential: a mini ARM interpreter (thesemantic_correctness.rspattern) runs the selected ops withR10 = 64 KiBand checks the wasm-side address of the actual R11-based access against the mask-semantics model.access [0xffff0000, 0xffff0003] escapes the 0x10000-byte bound(the filed escape) andmasked access at 0x0, mask semantics require 0xffff(the latent R10-is-size bug remapping an in-bounds byte load).size-1, halfword boundary.End-to-end (CLI,
-t cortex-m3 --safety-bounds mask, force-thumb objdump):peek/pokeemit exactly the sequence above; a 3-page (non-power-of-two) memory now declines with an actionable error.Gates
frozen_codegen_bytes; fixtures don't use--safety-bounds)cargo test --workspacegreen (107 suites, 0 failures)cargo fmt --check+cargo clippy --workspace --all-targets -- -D warningscleanorigin/main(b471c38, post-fix(decoder): float-typed global accesses loud-skip — the last silent float path (GI-FPU-001, #369) #648)🤖 Generated with Claude Code