diff --git a/crates/synth-backend/src/arm_backend.rs b/crates/synth-backend/src/arm_backend.rs index 57971705..7c3d1597 100644 --- a/crates/synth-backend/src/arm_backend.rs +++ b/crates/synth-backend/src/arm_backend.rs @@ -316,7 +316,25 @@ fn compile_wasm_to_arm( SafetyBounds::None => BoundsCheckConfig::None, SafetyBounds::Mpu => BoundsCheckConfig::Mpu, SafetyBounds::Software => BoundsCheckConfig::Software, - SafetyBounds::Mask => BoundsCheckConfig::Masking, + SafetyBounds::Mask => { + // #651 (mirroring the RISC-V backend's compile-time decline): + // index masking wraps `ea & (size-1)` — a modulo only when the + // linear-memory size is a power of two. With a non-power-of-two + // size the AND would silently REMAP in-bounds addresses (e.g. + // 0x18000 & 0x2FFFF = 0x8000 for a 192 KiB memory). Decline + // loudly rather than miscompile. `linear_memory_bytes == 0` + // means "unknown" (plain per-function path, no module context) + // — the startup default of one 64 KiB page is a power of two. + let bytes = config.linear_memory_bytes; + if bytes != 0 && !bytes.is_power_of_two() { + return Err(format!( + "--safety-bounds mask requires a power-of-two linear-memory \ + size, got {bytes} bytes — switch to --safety-bounds software \ + for the deterministic check (#651)" + )); + } + BoundsCheckConfig::Masking + } }; // The non-optimized (direct) instruction-selection path. Handles f32 via diff --git a/crates/synth-synthesis/src/instruction_selector.rs b/crates/synth-synthesis/src/instruction_selector.rs index 075713cd..6610e2ab 100644 --- a/crates/synth-synthesis/src/instruction_selector.rs +++ b/crates/synth-synthesis/src/instruction_selector.rs @@ -4970,6 +4970,120 @@ impl InstructionSelector { Ok(instrs) } + /// #651: compute the masked effective address for `--safety-bounds mask`. + /// + /// WASM defines the effective address of a memory access as + /// `operand + offset` evaluated in 33-bit arithmetic (both are u32). The + /// previous lowering masked the OPERAND and added the static offset + /// afterwards — `(operand & R10) + offset` — so any non-zero offset + /// escaped the bound by up to ~4 GiB. (It also ANDed with R10 = memory + /// SIZE in bytes rather than `size - 1`, which is not a mask at all: for + /// the default 64 KiB memory it kept only bit 16.) This emits + /// + /// ```text + /// masked = min((operand + offset) & (size - 1), size - access_size) + /// ``` + /// + /// derived entirely from R10 (the startup-code contract: R10 = linear- + /// memory size in bytes, shared with `memory.size`), using R12 (IP, the + /// reserved encoder scratch — #212) as the only temporary: + /// + /// ```text + /// ADD addr, addr, #offset ; fold the static offset first + /// (MOVW/MOVT R12, #offset; ADD reg ; — materialized when > 0xFF) + /// SUB R12, R10, #1 ; R12 = size-1 (the mask) + /// AND addr, addr, R12 ; addr = ea & (size-1) + /// [SUB R12, R12, #(access_size-1) ; R12 = size - access_size + /// CMP addr, R12 ; clamp the START so the access's + /// IT HI; MOVHI addr, R12] ; FINAL byte stays in-bounds + /// ``` + /// + /// Soundness of ADD-then-AND (large offsets do NOT need to decline): the + /// wasm effective address `ea = operand + offset < 2^33`; the ARM ADD + /// computes `ea mod 2^32`, and since every set bit of `size - 1 < 2^32` + /// lies below bit 32, `(ea mod 2^32) & (size-1) == ea & (size-1)` — the + /// u33 carry is annihilated by the AND either way. + /// + /// Final-byte bound (the #640-shaped `offset + access_size - 1` rule): + /// the AND bounds only the FIRST byte to `[0, size)`; a multi-byte access + /// starting in the last `access_size - 1` bytes would still overhang the + /// bound, 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: `ea & (size-1) > size - + /// access_size` for an in-range `ea` implies `ea + access_size - 1 >= + /// size`, which wasm semantics trap — the mask profile deliberately + /// replaces that trap with a deterministic in-bounds access + /// (wrap-not-trap, design doc §3.1 path C). A 1-byte access can never + /// overhang, so the clamp is skipped for `access_size == 1`. + /// + /// The profile's contract requires a power-of-two memory size (enforced + /// loudly by the ARM backend, mirroring the RISC-V backend): with a + /// non-power-of-two size, `AND (size-1)` would remap in-bounds addresses. + fn mask_effective_address(addr_reg: Reg, offset: i32, access_size: u32) -> Vec { + let mut ops = Vec::new(); + let off = offset as u32; + if off != 0 { + // Fold the static offset into the operand BEFORE masking. ADD's + // Thumb-2 T3 and A32 modified-immediate forms both encode values + // <= 0xFF directly; wider offsets are materialized via MOVW/MOVT + // into the R12 scratch (the same shape the encoder itself uses + // for wide reg+imm addressing). + if off <= 0xFF { + ops.push(ArmOp::Add { + rd: addr_reg, + rn: addr_reg, + op2: Operand2::Imm(offset), + }); + } else { + ops.push(ArmOp::Movw { + rd: Reg::R12, + imm16: (off & 0xFFFF) as u16, + }); + if off > 0xFFFF { + ops.push(ArmOp::Movt { + rd: Reg::R12, + imm16: (off >> 16) as u16, + }); + } + ops.push(ArmOp::Add { + rd: addr_reg, + rn: addr_reg, + op2: Operand2::Reg(Reg::R12), + }); + } + } + // R12 = size - 1: R10 holds the SIZE in bytes (the `memory.size` + // contract), so the mask is derived per access instead of repurposing + // R10 itself. + ops.push(ArmOp::Sub { + rd: Reg::R12, + rn: Reg::R10, + op2: Operand2::Imm(1), + }); + ops.push(ArmOp::And { + rd: addr_reg, + rn: addr_reg, + op2: Operand2::Reg(Reg::R12), + }); + if access_size > 1 { + ops.push(ArmOp::Sub { + rd: Reg::R12, + rn: Reg::R12, + op2: Operand2::Imm(access_size as i32 - 1), + }); + ops.push(ArmOp::Cmp { + rn: addr_reg, + op2: Operand2::Reg(Reg::R12), + }); + ops.push(ArmOp::SelectMove { + rd: addr_reg, + rm: Reg::R12, + cond: Condition::HI, + }); + } + ops + } + /// Generate a load with optional bounds checking /// R10 = memory size, R11 = memory base /// Bounds check verifies addr + offset + access_size - 1 < memory_size @@ -4991,9 +5105,19 @@ impl InstructionSelector { ) -> Vec { contracts::memory::verify_access_size(access_size); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + let load_op = ArmOp::Ldr { rd, - addr: MemAddr::reg_imm(Reg::R11, addr_reg, offset), + addr: MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset), }; match self.bounds_check { @@ -5032,20 +5156,18 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - load_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the load itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(load_op); + ops } } } /// Generate a store with optional bounds checking - /// R10 = memory size (or mask for masking mode), R11 = memory base + /// R10 = memory size in bytes (masking derives `size-1` per access, #651), R11 = memory base /// Bounds check verifies addr + offset + access_size - 1 < memory_size /// /// # Contract (Verus-style) @@ -5065,9 +5187,19 @@ impl InstructionSelector { ) -> Vec { contracts::memory::verify_access_size(access_size); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + let store_op = ArmOp::Str { rd: value_reg, - addr: MemAddr::reg_imm(Reg::R11, addr_reg, offset), + addr: MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset), }; match self.bounds_check { @@ -5103,20 +5235,18 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - store_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the store itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(store_op); + ops } } } /// Generate an i64 (8-byte) load with optional bounds checking. - /// R10 = memory size (or mask for masking mode), R11 = memory base. + /// R10 = memory size in bytes (masking derives `size-1` per access, #651), R11 = memory base. /// Result is loaded into R0 (low) and R1 (high). /// /// # Contract (Verus-style) @@ -5130,10 +5260,20 @@ impl InstructionSelector { let access_size: u32 = 8; contracts::memory::verify_access_size(access_size); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + let load_op = ArmOp::I64Ldr { rdlo: Reg::R0, rdhi: Reg::R1, - addr: MemAddr::reg_imm(Reg::R11, addr_reg, offset), + addr: MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset), }; match self.bounds_check { @@ -5172,20 +5312,18 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - load_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the load itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(load_op); + ops } } } /// Generate an i64 (8-byte) store with optional bounds checking. - /// R10 = memory size (or mask for masking mode), R11 = memory base. + /// R10 = memory size in bytes (masking derives `size-1` per access, #651), R11 = memory base. /// Value is stored from R0 (low) and R1 (high). /// /// # Contract (Verus-style) @@ -5199,10 +5337,20 @@ impl InstructionSelector { let access_size: u32 = 8; contracts::memory::verify_access_size(access_size); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + let store_op = ArmOp::I64Str { rdlo: Reg::R0, rdhi: Reg::R1, - addr: MemAddr::reg_imm(Reg::R11, addr_reg, offset), + addr: MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset), }; match self.bounds_check { @@ -5238,20 +5386,18 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - store_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the store itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(store_op); + ops } } } /// Generate an i64 (8-byte) load with optional bounds checking into specified registers. - /// R10 = memory size (or mask for masking mode), R11 = memory base. + /// R10 = memory size in bytes (masking derives `size-1` per access, #651), R11 = memory base. /// Result is loaded into the specified register pair (rdlo, rdhi). fn generate_i64_load_into_regs( &self, @@ -5263,10 +5409,20 @@ impl InstructionSelector { let access_size: u32 = 8; contracts::memory::verify_access_size(access_size); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + let load_op = ArmOp::I64Ldr { rdlo, rdhi, - addr: MemAddr::reg_imm(Reg::R11, addr_reg, offset), + addr: MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset), }; match self.bounds_check { @@ -5301,20 +5457,18 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - load_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the load itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(load_op); + ops } } } /// Generate an i64 (8-byte) store with optional bounds checking from specified registers. - /// R10 = memory size (or mask for masking mode), R11 = memory base. + /// R10 = memory size in bytes (masking derives `size-1` per access, #651), R11 = memory base. /// Value is stored from the specified register pair (rdlo, rdhi). fn generate_i64_store_from_regs( &self, @@ -5326,10 +5480,20 @@ impl InstructionSelector { let access_size: u32 = 8; contracts::memory::verify_access_size(access_size); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + let store_op = ArmOp::I64Str { rdlo, rdhi, - addr: MemAddr::reg_imm(Reg::R11, addr_reg, offset), + addr: MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset), }; match self.bounds_check { @@ -5364,14 +5528,12 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - store_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the store itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(store_op); + ops } } } @@ -5394,7 +5556,17 @@ impl InstructionSelector { ) -> Vec { contracts::memory::verify_access_size(access_size); - let addr = MemAddr::reg_imm(Reg::R11, addr_reg, offset); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + + let addr = MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset); let load_op = match (access_size, sign_extend) { (1, false) => ArmOp::Ldrb { rd, addr }, (1, true) => ArmOp::Ldrsb { rd, addr }, @@ -5435,14 +5607,12 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - load_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the load itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(load_op); + ops } } } @@ -5463,7 +5633,17 @@ impl InstructionSelector { ) -> Vec { contracts::memory::verify_access_size(access_size); - let addr = MemAddr::reg_imm(Reg::R11, addr_reg, offset); + // #651: masking folds the static offset into the masked effective + // address (see `mask_effective_address`), so the memory op itself + // must use offset 0 — otherwise the offset would be re-added AFTER + // the mask and escape the bound. + let mem_offset = if self.bounds_check == BoundsCheckConfig::Masking { + 0 + } else { + offset + }; + + let addr = MemAddr::reg_imm(Reg::R11, addr_reg, mem_offset); let store_op = match access_size { 1 => ArmOp::Strb { rd: value_reg, @@ -5511,14 +5691,12 @@ impl InstructionSelector { ] } BoundsCheckConfig::Masking => { - vec![ - ArmOp::And { - rd: addr_reg, - rn: addr_reg, - op2: Operand2::Reg(Reg::R10), - }, - store_op, - ] + // #651: mask the EFFECTIVE address (operand + offset) and + // clamp so the access's final byte stays inside the bound; + // the store itself uses offset 0 (folded above). + let mut ops = Self::mask_effective_address(addr_reg, offset, access_size); + ops.push(store_op); + ops } } } @@ -12662,7 +12840,9 @@ mod tests { #[test] fn test_bounds_check_masking() { - // With BoundsCheckConfig::Masking, loads should generate AND + LDR + // #651: Masking derives the mask from R10 (SUB R12, R10, #1), ANDs + // the effective address, clamps the start so the final byte stays + // in-bounds, then does the STR with offset 0. let db = RuleDatabase::new(); let mut selector = InstructionSelector::with_bounds_check(db.rules().to_vec(), BoundsCheckConfig::Masking); @@ -12673,26 +12853,142 @@ mod tests { }]; let arm_instrs = selector.select(&wasm_ops).unwrap(); - // Should be: AND addr, addr, R10; STR - assert_eq!(arm_instrs.len(), 2); + // offset == 0, access_size == 4: + // SUB R12,R10,#1; AND addr,addr,R12; SUB R12,R12,#3; CMP; IT HI MOV; STR + assert_eq!(arm_instrs.len(), 6); - // First: AND to mask address match &arm_instrs[0].op { + ArmOp::Sub { + rd: Reg::R12, + rn: Reg::R10, + op2: Operand2::Imm(1), + } => {} + other => panic!("Expected SUB R12, R10, #1 (mask = size-1), got {other:?}"), + } + match &arm_instrs[1].op { ArmOp::And { - rn: _, - op2: Operand2::Reg(Reg::R10), + op2: Operand2::Reg(Reg::R12), + .. + } => {} + other => panic!("Expected And with R12 (= size-1), got {other:?}"), + } + match &arm_instrs[2].op { + ArmOp::Sub { + rd: Reg::R12, + rn: Reg::R12, + op2: Operand2::Imm(3), + } => {} + other => panic!("Expected SUB R12, R12, #3 (size - access_size), got {other:?}"), + } + match &arm_instrs[3].op { + ArmOp::Cmp { + op2: Operand2::Reg(Reg::R12), .. } => {} - other => panic!("Expected And with R10, got {:?}", other), + other => panic!("Expected CMP addr, R12, got {other:?}"), } + match &arm_instrs[4].op { + ArmOp::SelectMove { + rm: Reg::R12, + cond: Condition::HI, + .. + } => {} + other => panic!("Expected IT HI; MOV addr, R12 clamp, got {other:?}"), + } + match &arm_instrs[5].op { + ArmOp::Str { addr, .. } => { + assert_eq!(addr.offset, 0, "masked store must use offset 0"); + } + other => panic!("Expected Str instruction, got {other:?}"), + } + } - // Second: The actual STR - match &arm_instrs[1].op { - ArmOp::Str { .. } => {} - other => panic!("Expected Str instruction, got {:?}", other), + /// #651: a non-zero static offset must be folded into the address BEFORE + /// the AND — `(addr + offset) & (size-1)` — never added after the mask. + #[test] + fn test_masking_folds_offset_before_and_651() { + let db = RuleDatabase::new(); + let mut selector = + InstructionSelector::with_bounds_check(db.rules().to_vec(), BoundsCheckConfig::Masking); + + // Large offset (gale's repro shape: offset=0xffff0000): must be + // materialized (MOVW/MOVT R12) and ADDed before the SUB/AND. + let wasm_ops = vec![WasmOp::I32Load { + offset: 0xffff_0000, + align: 4, + }]; + let arm_instrs = selector.select(&wasm_ops).unwrap(); + let ops: Vec<&ArmOp> = arm_instrs.iter().map(|i| &i.op).collect(); + + let add_pos = ops + .iter() + .position(|op| matches!(op, ArmOp::Add { .. })) + .expect("offset must be ADDed into the address"); + let and_pos = ops + .iter() + .position(|op| matches!(op, ArmOp::And { .. })) + .expect("mask AND must be present"); + assert!( + add_pos < and_pos, + "#651: the static offset must be folded BEFORE the mask AND \ + (got ADD at {add_pos}, AND at {and_pos})" + ); + // MOVW/MOVT materialization of the wide offset precedes the ADD. + assert!( + matches!( + ops[0], + ArmOp::Movw { + rd: Reg::R12, + imm16: 0 + } + ) && matches!( + ops[1], + ArmOp::Movt { + rd: Reg::R12, + imm16: 0xffff + } + ), + "wide offset must be materialized via MOVW/MOVT R12, got {:?} {:?}", + ops[0], + ops[1] + ); + // The load itself must NOT re-add the offset after the mask. + match ops.last().unwrap() { + ArmOp::Ldr { addr, .. } => { + assert_eq!( + addr.offset, 0, + "#651: masked load must use offset 0 — a non-zero MemAddr \ + offset re-adds the offset AFTER the mask and escapes the bound" + ); + } + other => panic!("Expected Ldr, got {other:?}"), } } + /// #651: a byte access can never overhang the bound, so the final-byte + /// clamp (SUB/CMP/SelectMove) is skipped for access_size == 1. + #[test] + fn test_masking_no_clamp_for_byte_access_651() { + let db = RuleDatabase::new(); + let mut selector = + InstructionSelector::with_bounds_check(db.rules().to_vec(), BoundsCheckConfig::Masking); + + let wasm_ops = vec![WasmOp::I32Load8U { + offset: 0, + align: 1, + }]; + let arm_instrs = selector.select(&wasm_ops).unwrap(); + // SUB R12,R10,#1; AND; LDRB — no CMP/SelectMove clamp. + assert_eq!(arm_instrs.len(), 3); + assert!( + !arm_instrs + .iter() + .any(|i| matches!(i.op, ArmOp::SelectMove { .. } | ArmOp::Cmp { .. })), + "byte access needs no final-byte clamp" + ); + assert!(matches!(arm_instrs[2].op, ArmOp::Ldrb { .. })); + } + // ========================================================================= // Control flow tests // ========================================================================= diff --git a/crates/synth-synthesis/tests/issue_651_mask_effective_address.rs b/crates/synth-synthesis/tests/issue_651_mask_effective_address.rs new file mode 100644 index 00000000..c0448ed3 --- /dev/null +++ b/crates/synth-synthesis/tests/issue_651_mask_effective_address.rs @@ -0,0 +1,389 @@ +//! #651: `--safety-bounds mask` must bound the EFFECTIVE address. +//! +//! WASM defines the effective address of a memory access as +//! `operand + offset` (u33). The broken lowering was +//! `(operand & R10) + offset` — the mask applied to the operand only, so any +//! non-zero static offset escaped the bound after the AND (by up to ~4 GiB +//! for `offset=0xffff0000`), and the AND used R10 = memory SIZE rather than +//! `size - 1`. The fixed lowering computes +//! +//! ```text +//! masked = min((operand + offset) & (size - 1), size - access_size) +//! ``` +//! +//! These tests are an executable oracle: they run the direct selector with +//! `BoundsCheckConfig::Masking`, interpret the emitted address-computation +//! ops with a mini ARM interpreter (the `semantic_correctness.rs` pattern), +//! and check the wasm-side address the memory op actually accesses: +//! +//! 1. every access — including its FINAL byte (`+ access_size - 1`) — stays +//! inside `[0, size)` (RED on the pre-fix lowering: the offset escapes); +//! 2. in-bounds accesses are executed at their EXACT wasm address; +//! 3. wasm-trapping accesses land where the documented wrap semantics say +//! (`ea & (size-1)`, start clamped to `size - access_size`). + +use synth_synthesis::{ + ArmInstruction, ArmOp, BoundsCheckConfig, Condition, InstructionSelector, Operand2, Reg, + RuleDatabase, WasmOp, +}; + +/// Linear-memory size used throughout: one 64 KiB wasm page (the startup +/// default), power of two per the mask profile's contract. +const MEM_SIZE: u32 = 0x1_0000; + +// --------------------------------------------------------------------------- +// Mini ARM interpreter (semantic_correctness.rs pattern): registers + NZCV, +// executed up to the first R11-based memory access, whose wasm-side effective +// address is the value under test. +// --------------------------------------------------------------------------- + +fn reg_index(r: &Reg) -> usize { + match r { + Reg::R0 => 0, + Reg::R1 => 1, + Reg::R2 => 2, + Reg::R3 => 3, + Reg::R4 => 4, + Reg::R5 => 5, + Reg::R6 => 6, + Reg::R7 => 7, + Reg::R8 => 8, + Reg::R9 => 9, + Reg::R10 => 10, + Reg::R11 => 11, + Reg::R12 => 12, + Reg::SP => 13, + Reg::LR => 14, + Reg::PC => 15, + } +} + +struct ArmState { + regs: [u32; 16], + flag_n: bool, + flag_z: bool, + flag_c: bool, + flag_v: bool, +} + +impl ArmState { + fn new() -> Self { + Self { + regs: [0u32; 16], + flag_n: false, + flag_z: false, + flag_c: false, + flag_v: false, + } + } + + fn get(&self, r: &Reg) -> u32 { + self.regs[reg_index(r)] + } + + fn set(&mut self, r: Reg, val: u32) { + self.regs[reg_index(&r)] = val; + } + + fn resolve_operand2(&self, op2: &Operand2) -> u32 { + match op2 { + Operand2::Imm(v) => *v as u32, + Operand2::Reg(r) => self.get(r), + Operand2::RegShift { rm, .. } => self.get(rm), + } + } + + fn condition_met(&self, cond: &Condition) -> bool { + match cond { + Condition::EQ => self.flag_z, + Condition::NE => !self.flag_z, + Condition::LT => self.flag_n != self.flag_v, + Condition::LE => self.flag_z || (self.flag_n != self.flag_v), + Condition::GT => !self.flag_z && (self.flag_n == self.flag_v), + Condition::GE => self.flag_n == self.flag_v, + Condition::LO => !self.flag_c, + Condition::LS => !self.flag_c || self.flag_z, + Condition::HI => self.flag_c && !self.flag_z, + Condition::HS => self.flag_c, + } + } +} + +/// A captured R11-relative memory access: wasm-side effective address (the +/// register+immediate the op accesses relative to the linear-memory base) +/// and the access width in bytes. +struct Access { + effective: u32, + size: u32, +} + +/// Interpret straight-line ops until the first R11-based load/store. +/// +/// Panics on any unhandled COMPUTATIONAL op so an interpreter gap is loud +/// (the #209 Opt-1b lesson) rather than silently mis-modelled; structural +/// ops (labels, moves handled below) are executed or skipped explicitly. +fn run_until_access(instructions: &[ArmInstruction], addr_param: u32) -> Access { + let mut state = ArmState::new(); + state.set(Reg::R0, addr_param); // param 0 (AAPCS) + state.set(Reg::R10, MEM_SIZE); // startup contract: R10 = size in bytes + + for instr in instructions { + // A memory access ends the address computation under test. + let mem = match &instr.op { + ArmOp::Ldr { addr, .. } | ArmOp::Str { addr, .. } => Some((addr, 4u32)), + ArmOp::Ldrb { addr, .. } | ArmOp::Ldrsb { addr, .. } | ArmOp::Strb { addr, .. } => { + Some((addr, 1)) + } + ArmOp::Ldrh { addr, .. } | ArmOp::Ldrsh { addr, .. } | ArmOp::Strh { addr, .. } => { + Some((addr, 2)) + } + ArmOp::I64Ldr { addr, .. } | ArmOp::I64Str { addr, .. } => Some((addr, 8)), + _ => None, + }; + if let Some((addr, size)) = mem { + assert_eq!( + addr.base, + Reg::R11, + "linear-memory access must be R11-based" + ); + let reg_part = addr.offset_reg.map(|r| state.get(&r)).unwrap_or(0); + return Access { + effective: reg_part.wrapping_add(addr.offset as u32), + size, + }; + } + + match &instr.op { + ArmOp::Movw { rd, imm16 } => state.set(*rd, *imm16 as u32), + ArmOp::Movt { rd, imm16 } => { + let low = state.get(rd) & 0xFFFF; + state.set(*rd, ((*imm16 as u32) << 16) | low); + } + ArmOp::Mov { rd, op2 } => { + let v = state.resolve_operand2(op2); + state.set(*rd, v); + } + ArmOp::Add { rd, rn, op2 } => { + let v = state.get(rn).wrapping_add(state.resolve_operand2(op2)); + state.set(*rd, v); + } + ArmOp::Sub { rd, rn, op2 } => { + let v = state.get(rn).wrapping_sub(state.resolve_operand2(op2)); + state.set(*rd, v); + } + ArmOp::And { rd, rn, op2 } => { + let v = state.get(rn) & state.resolve_operand2(op2); + state.set(*rd, v); + } + ArmOp::Cmp { rn, op2 } => { + let a = state.get(rn); + let b = state.resolve_operand2(op2); + let result = a.wrapping_sub(b); + state.flag_n = (result as i32) < 0; + state.flag_z = result == 0; + state.flag_c = a >= b; + let (sa, sb, sr) = (a as i32, b as i32, result as i32); + state.flag_v = (sa >= 0 && sb < 0 && sr < 0) || (sa < 0 && sb >= 0 && sr >= 0); + } + ArmOp::SelectMove { rd, rm, cond } => { + if state.condition_met(cond) { + let v = state.get(rm); + state.set(*rd, v); + } + } + // Structural / non-address ops the selector may emit around the + // access (prologue, labels, epilogue). + ArmOp::Label { .. } | ArmOp::Push { .. } | ArmOp::Pop { .. } | ArmOp::Bx { .. } => {} + other => panic!("interpreter gap — unhandled op before the access: {other:?}"), + } + } + panic!("no R11-based memory access found in the selected instructions"); +} + +/// Select `wasm_ops` (1 i32 param = the address operand) under Masking and +/// return the captured access for `addr_param`. +fn mask_access(wasm_ops: &[WasmOp], addr_param: u32) -> Access { + let db = RuleDatabase::new(); + let mut selector = + InstructionSelector::with_bounds_check(db.rules().to_vec(), BoundsCheckConfig::Masking); + let instrs = selector + .select_with_stack(wasm_ops, 1) + .expect("selection under Masking must succeed"); + run_until_access(&instrs, addr_param) +} + +/// The documented mask-profile semantics (wrap-not-trap, #651): +/// `min((operand + offset) & (size-1), size - access_size)` — u33-exact. +fn mask_model(operand: u32, offset: u32, access_size: u32) -> u32 { + let ea = operand as u64 + offset as u64; // u33, no truncation + let masked = (ea & (MEM_SIZE as u64 - 1)) as u32; + masked.min(MEM_SIZE - access_size) +} + +/// Assert the access obeys the bound (final byte included) and lands exactly +/// where the mask semantics say. +fn check(access: &Access, operand: u32, offset: u32) { + assert!( + access.effective as u64 + access.size as u64 - 1 < MEM_SIZE as u64, + "#651: access [{:#x}, {:#x}] escapes the {:#x}-byte bound", + access.effective, + access.effective as u64 + access.size as u64 - 1, + MEM_SIZE + ); + let expected = mask_model(operand, offset, access.size); + assert_eq!( + access.effective, expected, + "#651: masked access at {:#x}, mask semantics require {:#x} \ + (operand {operand:#x}, offset {offset:#x}, size {})", + access.effective, expected, access.size + ); +} + +// --------------------------------------------------------------------------- +// RED on the pre-#651 lowering: in-bounds operand, offset escapes the bound. +// --------------------------------------------------------------------------- + +/// gale's repro shape: `(i32.load offset=0xffff0000 (local.get 0))`. The old +/// lowering computed `(operand & R10) + 0xffff0000` — ~4 GiB past the bound. +#[test] +fn load_huge_offset_stays_bounded_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I32Load { + offset: 0xffff_0000, + align: 4, + }, + ]; + let access = mask_access(&ops, 0x100); + check(&access, 0x100, 0xffff_0000); + // The u33 fold: (0x100 + 0xffff0000) & 0xffff == 0x100. + assert_eq!(access.effective, 0x100); +} + +/// Small offset, in-bounds operand near the top: old lowering read +/// `offset` bytes past the bound. +#[test] +fn load_small_offset_wraps_not_escapes_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I32Load { + offset: 0x100, + align: 4, + }, + ]; + // operand in-bounds (0xFFFC), ea = 0x100FC >= size: wasm traps, mask wraps. + let access = mask_access(&ops, 0xFFFC); + check(&access, 0xFFFC, 0x100); +} + +/// Store variant of the escape (the write side is the dangerous one). +#[test] +fn store_offset_stays_bounded_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I32Const(0x7EAC0DE), + WasmOp::I32Store { + offset: 0xffff_0000, + align: 4, + }, + ]; + let access = mask_access(&ops, 0x40); + check(&access, 0x40, 0xffff_0000); +} + +// --------------------------------------------------------------------------- +// In-bounds accesses must be executed at their exact wasm address. +// --------------------------------------------------------------------------- + +#[test] +fn in_bounds_access_address_preserved_651() { + for (operand, offset) in [ + (0u32, 0u32), + (0x100, 0x10), + (0x100, 0xFF), // ADD-imm ceiling + (0x100, 0x100), // first materialized (MOVW) offset + (0, MEM_SIZE - 4), + (MEM_SIZE - 4, 0), // last legal word start + ] { + let ops = [WasmOp::LocalGet(0), WasmOp::I32Load { offset, align: 4 }]; + let access = mask_access(&ops, operand); + check(&access, operand, offset); + assert_eq!( + access.effective, + operand + offset, + "in-bounds access must be address-preserving" + ); + } +} + +// --------------------------------------------------------------------------- +// Final-byte boundary (`offset + access_size - 1`, the #640-shaped rule). +// --------------------------------------------------------------------------- + +/// A 4-byte load starting in the last 3 bytes would overhang the bound by up +/// to 3 bytes if only the start were masked; the clamp caps the start. +#[test] +fn word_load_final_byte_clamped_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I32Load { + offset: 0, + align: 4, + }, + ]; + for operand in [MEM_SIZE - 3, MEM_SIZE - 2, MEM_SIZE - 1] { + let access = mask_access(&ops, operand); + check(&access, operand, 0); + assert_eq!(access.effective, MEM_SIZE - 4, "start clamped to size-4"); + } +} + +/// i64 (8-byte) access: widest overhang, clamped to size-8. +#[test] +fn i64_load_final_byte_clamped_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I64Load { + offset: 0, + align: 8, + }, + ]; + let access = mask_access(&ops, MEM_SIZE - 1); + assert_eq!(access.size, 8); + check(&access, MEM_SIZE - 1, 0); + assert_eq!(access.effective, MEM_SIZE - 8); +} + +/// Byte access at the very last byte is legal and must NOT be clamped. +#[test] +fn byte_load_last_byte_exact_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I32Load8U { + offset: 0, + align: 1, + }, + ]; + let access = mask_access(&ops, MEM_SIZE - 1); + assert_eq!(access.size, 1); + check(&access, MEM_SIZE - 1, 0); + assert_eq!(access.effective, MEM_SIZE - 1); +} + +/// Subword (halfword) store boundary: offset folds + clamp to size-2. +#[test] +fn halfword_store_boundary_651() { + let ops = [ + WasmOp::LocalGet(0), + WasmOp::I32Const(0xBEEF), + WasmOp::I32Store16 { + offset: 1, + align: 2, + }, + ]; + // ea = (size-2) + 1 = size-1: final byte would be at `size` — clamped. + let access = mask_access(&ops, MEM_SIZE - 2); + assert_eq!(access.size, 2); + check(&access, MEM_SIZE - 2, 1); + assert_eq!(access.effective, MEM_SIZE - 2); +}