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>
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