Skip to content

fix(safety-bounds): #651 — mask bounds the EFFECTIVE address (offset folded before the AND, final byte clamped)#654

Merged
avrabe merged 1 commit into
mainfrom
fix/651-mask-offset-order
Jul 8, 2026
Merged

fix(safety-bounds): #651 — mask bounds the EFFECTIVE address (offset folded before the AND, final byte clamped)#654
avrabe merged 1 commit into
mainfrom
fix/651-mask-offset-order

Conversation

@avrabe

@avrabe avrabe commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #651.

The bug (two, in one lowering)

--safety-bounds mask (direct selector, thumb-2) emitted, for (i32.load offset=0xffff0000 (local.get 0)):

and.w  r0, r0, r10        ; operand & R10   — mask applied to the OPERAND
movw   r12, #0x0
movt   r12, #0xffff
add.w  r12, r0, r12       ; offset re-added AFTER the mask
ldr.w  r0, [r11, r12]     ; escapes the bound by up to ~4 GiB
  1. Offset ordering (the filed issue): WASM's effective address is 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.
  2. Mask value (latent, surfaced by the oracle): the AND used R10 = memory SIZE in bytes, not size-1. For the default 64 KiB memory, addr & 0x10000 keeps 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 computes

masked = min((operand + offset) & (size - 1), size - access_size)
[movw/movt r12, #offset]   ; offsets > 0xFF materialized via the R12 scratch
[add  addr, addr, r12]     ; fold the static offset FIRST
 sub  r12, r10, #1         ; mask = size-1, derived per access (R10 keeps its
 and  addr, addr, r12      ;   memory.size contract)
[sub  r12, r12, #(s-1)     ; clamp the start to size - access_size so the
 cmp  addr, r12            ;   access's FINAL byte stays inside the bound
 it hi; movhi addr, r12]   ;   (skipped for byte accesses — cannot overhang)
 ldr/str [r11, addr]       ; offset 0 — nothing is re-added after the mask

Soundness argument for ADD-then-AND (large offsets do NOT decline)

ea = operand + offset < 2^33 (both u32). The ARM ADD computes ea mod 2^32; every set bit of the mask size-1 < 2^32 lies 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 - 1 software-guard rule): the AND bounds only the first byte; a multi-byte access starting in the last access_size-1 bytes would overhang, so the CMP/IT-HI clamp caps the start at size - access_size, giving start + access_size - 1 ≤ size - 1 unconditionally. The clamp never alters a wasm-defined access: a masked start above size - access_size for an in-range ea implies ea + 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) since AND (size-1) on a non-power-of-two size would silently remap in-bounds addresses.

Sites audited (no wildcard survives)

Red → green

crates/synth-synthesis/tests/issue_651_mask_effective_address.rs — executable differential: a mini ARM interpreter (the semantic_correctness.rs pattern) runs the selected ops with R10 = 64 KiB and checks the wasm-side address of the actual R11-based access against the mask-semantics model.

  • origin/main (5f8c24c): 8/8 FAILED — e.g. access [0xffff0000, 0xffff0003] escapes the 0x10000-byte bound (the filed escape) and masked access at 0x0, mask semantics require 0xffff (the latent R10-is-size bug remapping an in-bounds byte load).
  • this branch: 8/8 ok — huge-offset escape, small-offset escape, store variant, in-bounds address preservation (exact), word/i64 final-byte clamp, byte-access exactness at size-1, halfword boundary.

End-to-end (CLI, -t cortex-m3 --safety-bounds mask, force-thumb objdump): peek/poke emit exactly the sequence above; a 3-page (non-power-of-two) memory now declines with an actionable error.

Gates

🤖 Generated with Claude Code

…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

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.94624% with 28 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-synthesis/src/instruction_selector.rs 87.00% 23 Missing ⚠️
crates/synth-backend/src/arm_backend.rs 44.44% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@avrabe avrabe merged commit f8c6826 into main Jul 8, 2026
30 of 31 checks passed
@avrabe avrabe deleted the fix/651-mask-offset-order branch July 8, 2026 16:24
avrabe added a commit that referenced this pull request Jul 8, 2026
…sound mask bounds (#648/#652/#653/#654) (#657)

Pin sweep 0.33.1 -> 0.34.0 + CHANGELOG.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
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.

thumb-2: --safety-bounds mask applies the mask before adding the static offset — offset escapes the bound

1 participant