fix(safety-bounds): #377 — software bounds enforced on the optimized path; inline UDF trap fixes the direct path's fallthrough no-op - #640
Merged
Conversation
…path; inline UDF trap replaces the fallthrough Bhs Trap_Handler --safety-bounds software/mask were SILENT NO-OPS on the optimized codegen path (ir_to_arm) — the path that lowers the bulk of a flight loop's i32 loads/stores compiled byte-identical to `none` while the safety manifest claimed enforcement. Worse, the direct selector's existing check never trapped either: its `Bhs Trap_Handler` label branch resolved to an offset-0 fallthrough no-op in a self-contained image. Policy per mode: - software: inline per-access guard on the optimized path (`ADD R12,addr,#off+size-1; CMP R12,R10; BLO +0; UDF #0`) mirroring the direct selector's end-of-access check — same pre-resolved numeric-guard geometry as the DivS/DivU zero traps, so resolved_branch_geometry (#604/#607) maps it unchanged. Direct selector's 8 access shapes switch from `Bhs Trap_Handler` to the same inline `BLO +0; UDF #0` (#374 bulk-memory pattern), so the trap is real in self-contained images and under --relocatable alike. Base-CSE is disabled under software mode (folded [R11,#imm] accesses would bypass the register-shape guard). - mask: optimized path DECLINES memory-accessing functions to the direct selector (in-place `AND addr,R10` is unsound on the non-single-use optimized path) — honest degradation, never a silent drop. - mpu/none: byte-identical to before (pinned by tests). Estimator: new high-rd `ADD #imm` arm (32-bit ADD.W) in estimate_arm_byte_size — the guard's end-address compute previously fell to the `_ => 2` default and drifted every spanning branch by 2 bytes; pinned in estimator_encoder_agreement (3 new cases). Verification: - scripts/repro/safety_bounds_377_differential.py: wasmtime ground truth vs unicorn on BOTH paths x 13 vectors (in-bounds / exact-boundary / first-OOB / far-OOB across all four memory opcode shapes). origin/main (086968a): 14 MISMATCHES (all 7 OOB vectors leak on both paths). This branch: 26/26 PASS. - 8 new unit tests (guard shape/count, end-offset, mask decline, mpu parity, flag-off byte-identity); estimator agreement green; full workspace 105 suites green; frozen fixtures untouched (none use --safety-bounds; flag-off stream pinned byte-identical). Salvage provenance: work recovered from an interrupted session (agent hit its session limit at 170 tool uses in worktree lane-377); this commit completes verification and lands the on-disk state. Closes #377 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
…folded before the AND, final byte clamped) (#654) --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>
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.
Closes #377. Flight-safety: jess pins
--safety-bounds softwarefor PX4.The bug (two halves)
--safety-bounds software/maskwere silent no-ops on the optimized codegen path (ir_to_arm) — the path that lowers the bulk of a flight loop's i32 loads/stores compiled byte-identical tononewhile the safety manifest claimed enforcement.Bhs Trap_Handlerlabel branch resolves to an offset-0 fallthrough no-op in a self-contained image (the label is external, soresolve_label_branchesleaves the placeholder). The CMP ran; the trap didn't.Policy chosen, per mode
softwareADD R12,addr,#off+size-1; CMP R12,R10; BLO +0; UDF #0Bhs Trap_Handler→ inlineBLO +0; UDF #0in all 8 access shapesmaskAND addr,R10is unsound on the non-single-use optimized path)mpunoneon both paths)noneWhy
software= implement (not decline): it's the mode jess pins, and declining every memory-accessing function would forfeit the optimized path exactly where PX4 needs it. The guard mirrors the direct selector's end-of-access check (addr+offset+size-1 < R10) and uses the same pre-resolved numeric-guard geometry (BLO +0skipping a 2-byteUDF) as the DivS/DivU zero traps, whichresolved_branch_geometry(#604/#607) already maps — no new branch class. R12 is the encoder scratch (never allocator-assigned, #212) and is re-materialized after the guard. Base-CSE is disabled undersoftware(its folded[R11,#imm]arm bypasses the register-shape guard); safety mode trades the size win for uniform checking. InlineUDFis the #374 bulk-memory/div-trap pattern and works identically under--relocatable(UDF → UsageFault → platform handler).Estimator: the guard's
ADD R12,…,#imm(high rd → always 32-bit ADD.W) was anestimate_arm_byte_sizehole (_ => 2default) that drifted every spanning branch by 2 bytes; new arm added + 3 pinned cases inestimator_encoder_agreement(the #511 oracle).Red → green evidence
scripts/repro/safety_bounds_377_differential.py— wasmtime ground truth vs unicorn running synth's Thumb-2, both paths (optimized path asserted non-vacuous viaSYNTH_PATH_DEBUG), 13 vectors covering in-bounds / exact-boundary (must NOT trap) / first-OOB-byte / far-OOB across all four memory opcode shapes (MemStore/MemLoad/MemStoreSubword/MemLoadSubword). A far-OOB access that unicorn faults on (UNMAPPED) is reported ERR, never a match — only a genuineUDFcounts as the trap.086968a(v0.32.1)Verification
off+size-1, mask declines with the --safety-bounds software/mpu are no-ops on the optimized codegen path (flight-safety: jess pins software for PX4) #377 message (pure functions stay optimized), mpu ≡ none bytes on both paths, flag-off stream byte-identical (frozen fixtures don't use--safety-bounds)estimator_encoder_agreementgreen (incl. 3 new high-rd ADD-imm cases)cargo fmt --check+cargo clippy --workspace --all-targets -- -D warningscleanSalvage provenance
Recovered from an interrupted session (agent hit its session limit at 170 tool uses, worktree
lane-377, uncommitted). This PR completes the verification (baseline red run, workspace tests, fmt/clippy) and lands the on-disk state; rebased onto current origin/main.🤖 Generated with Claude Code