Skip to main content

synth_core/
wasm_stack_check.rs

1//! Pre-flight wasm value-stack underflow detector.
2//!
3//! Real wasm input is validated by the decoder (wasmparser). This module is
4//! a safety net for *direct callers* of the lowering pipeline that feed in
5//! raw `Vec<WasmOp>` without going through the validator — most notably the
6//! fuzz harnesses, which intentionally generate malformed sequences to
7//! prove the contract that lowering returns `Err`, not panics.
8//!
9//! The check is best-effort: control-flow ops (`Block`, `Loop`, `If`/`Else`,
10//! `End`, `Br`/`BrIf`/`BrTable`, `Return`, `Call`) have stack effects that
11//! depend on block types and function signatures we don't have here. When
12//! the input contains any such op, validation gracefully bails out with
13//! `Ok(())` rather than reporting a spurious underflow. This keeps the
14//! check conservative — it never rejects valid input — at the cost of
15//! catching only the underflow cases that don't involve control flow.
16//!
17//! The bug this was written for ([PR #113 fuzz harness wasm_ops_lower_or_error,
18//! input `[I32DivS]` with empty initial stack]) sits squarely inside the
19//! modeled subset, which is the common case.
20//!
21//! ## Scope
22//!
23//! The validator does *not* enforce wasm type checking — it only tracks
24//! stack *depth*. So `i32.const ; i64.add` will pass even though it's
25//! type-invalid. Type errors fall to the lowering pipeline, which now
26//! raises them as `Err` (per PR #117 — the same audit pass).
27//!
28//! ## Why not just call wasmparser?
29//!
30//! Two reasons:
31//! * The lowering pipeline accepts `Vec<WasmOp>` (its own enum), not raw
32//!   wasm bytes. Threading wasmparser back would require a re-encoder.
33//! * The harnesses *want* to feed malformed input. We want a cheap local
34//!   check that returns Err rather than panics, not full re-validation.
35//!
36//! See PR #117 for the original fuzz crash that motivated this module.
37//!
38//! Note: `Select` is modeled as `pop 3, push 1` — wasm's `select` consumes
39//! two values and a condition. `MemoryGrow` pops a page count and pushes
40//! the previous size (or -1). `MemorySize` is a pure push.
41
42use crate::Error;
43use crate::wasm_op::WasmOp;
44
45/// Pre-flight check: returns `Err(Error::validation(...))` if any modeled
46/// op would underflow the wasm value stack. If the sequence contains
47/// control-flow ops we don't model, returns `Ok(())` (bails conservatively).
48pub fn check_no_underflow(wasm_ops: &[WasmOp]) -> crate::Result<()> {
49    let mut depth: i64 = 0;
50    for (idx, op) in wasm_ops.iter().enumerate() {
51        match stack_effect_or_bail(op) {
52            StackEffect::Modeled { pops, pushes } => {
53                if depth < pops as i64 {
54                    return Err(Error::validation(format!(
55                        "wasm value-stack underflow at op {idx} ({op:?}): \
56                         would pop {pops} from depth {depth}"
57                    )));
58                }
59                depth -= pops as i64;
60                depth += pushes as i64;
61            }
62            StackEffect::Bail => return Ok(()),
63        }
64    }
65    Ok(())
66}
67
68enum StackEffect {
69    Modeled { pops: u32, pushes: u32 },
70    Bail,
71}
72
73fn modeled(pops: u32, pushes: u32) -> StackEffect {
74    StackEffect::Modeled { pops, pushes }
75}
76
77#[allow(clippy::too_many_lines)]
78fn stack_effect_or_bail(op: &WasmOp) -> StackEffect {
79    use WasmOp::*;
80    match op {
81        // ---- pushes (constants, reads) -----------------------------------
82        I32Const(_) | I64Const(_) | F32Const(_) | F64Const(_) | V128Const(_) | LocalGet(_)
83        | GlobalGet(_) | MemorySize(_) => modeled(0, 1),
84
85        // ---- i32 binary (pop 2, push 1) ----------------------------------
86        I32Add | I32Sub | I32Mul | I32DivS | I32DivU | I32RemS | I32RemU | I32And | I32Or
87        | I32Xor | I32Shl | I32ShrS | I32ShrU | I32Rotl | I32Rotr | I32Eq | I32Ne | I32LtS
88        | I32LtU | I32LeS | I32LeU | I32GtS | I32GtU | I32GeS | I32GeU => modeled(2, 1),
89
90        // ---- i32 unary (pop 1, push 1) -----------------------------------
91        I32Clz | I32Ctz | I32Popcnt | I32Eqz | I32Extend8S | I32Extend16S | I32WrapI64 => {
92            modeled(1, 1)
93        }
94
95        // ---- i64 binary (pop 2, push 1) ----------------------------------
96        I64Add | I64Sub | I64Mul | I64DivS | I64DivU | I64RemS | I64RemU | I64And | I64Or
97        | I64Xor | I64Shl | I64ShrS | I64ShrU | I64Rotl | I64Rotr | I64Eq | I64Ne | I64LtS
98        | I64LtU | I64LeS | I64LeU | I64GtS | I64GtU | I64GeS | I64GeU => modeled(2, 1),
99
100        // ---- i64 unary (pop 1, push 1) -----------------------------------
101        I64Clz | I64Ctz | I64Popcnt | I64Eqz | I64Extend8S | I64Extend16S | I64Extend32S
102        | I64ExtendI32S | I64ExtendI32U => modeled(1, 1),
103
104        // ---- f32 binary --------------------------------------------------
105        F32Add | F32Sub | F32Mul | F32Div | F32Eq | F32Ne | F32Lt | F32Le | F32Gt | F32Ge
106        | F32Min | F32Max | F32Copysign => modeled(2, 1),
107
108        // ---- f32 unary ---------------------------------------------------
109        F32Abs | F32Neg | F32Ceil | F32Floor | F32Trunc | F32Nearest | F32Sqrt => modeled(1, 1),
110
111        // ---- f64 binary --------------------------------------------------
112        F64Add | F64Sub | F64Mul | F64Div | F64Eq | F64Ne | F64Lt | F64Le | F64Gt | F64Ge
113        | F64Min | F64Max | F64Copysign => modeled(2, 1),
114
115        // ---- f64 unary ---------------------------------------------------
116        F64Abs | F64Neg | F64Ceil | F64Floor | F64Trunc | F64Nearest | F64Sqrt => modeled(1, 1),
117
118        // ---- f32 ↔ f64 / int conversions (pop 1, push 1) -----------------
119        F32ConvertI32S | F32ConvertI32U | F32ConvertI64S | F32ConvertI64U | F32DemoteF64
120        | F32ReinterpretI32 | I32ReinterpretF32 | I32TruncF32S | I32TruncF32U | F64ConvertI32S
121        | F64ConvertI32U | F64ConvertI64S | F64ConvertI64U | F64PromoteF32 | F64ReinterpretI64
122        | I64ReinterpretF64 | I64TruncF64S | I64TruncF64U | I32TruncF64S | I32TruncF64U => {
123            modeled(1, 1)
124        }
125
126        // ---- pop-only ----------------------------------------------------
127        LocalSet(_) | GlobalSet(_) | Drop => modeled(1, 0),
128
129        // ---- pop-modify-push (peek-write) --------------------------------
130        LocalTee(_) => modeled(1, 1),
131
132        // ---- memory ------------------------------------------------------
133        // load: pops address, pushes value
134        I32Load { .. }
135        | I32Load8S { .. }
136        | I32Load8U { .. }
137        | I32Load16S { .. }
138        | I32Load16U { .. }
139        | I64Load { .. }
140        | I64Load8S { .. }
141        | I64Load8U { .. }
142        | I64Load16S { .. }
143        | I64Load16U { .. }
144        | I64Load32S { .. }
145        | I64Load32U { .. }
146        | F32Load { .. }
147        | F64Load { .. } => modeled(1, 1),
148        // store: pops value, pops address
149        I32Store { .. }
150        | I32Store8 { .. }
151        | I32Store16 { .. }
152        | I64Store { .. }
153        | I64Store8 { .. }
154        | I64Store16 { .. }
155        | I64Store32 { .. }
156        | F32Store { .. }
157        | F64Store { .. } => modeled(2, 0),
158        // memory.grow: pops page count, pushes previous size or -1
159        MemoryGrow(_) => modeled(1, 1),
160
161        // ---- bulk memory (#374) -----------------------------------------
162        // memory.copy(dst, src, len) and memory.fill(dst, val, len) each pop
163        // three i32 operands and push nothing.
164        MemoryCopy | MemoryFill => modeled(3, 0),
165
166        // ---- select / nop / unreachable ---------------------------------
167        // select: pops two values and a condition (i32), pushes one value
168        Select => modeled(3, 1),
169        Nop => modeled(0, 0),
170        // `unreachable` is wasm's stack-polymorphic terminator: the wasm
171        // validator treats subsequent ops in the same block as type-checking
172        // against an infinite-depth polymorphic stack. We don't model that
173        // (we'd need a real type system). Pragmatically we keep tracking
174        // with `pops: 0, pushes: 0` so dead-code shapes that would crash
175        // `wasm_to_ir` (e.g. `[Unreachable, I32GeS]` from PR #117 fuzz
176        // follow-up — I32GeS would underflow at depth 0) get rejected with
177        // a typed Err instead of triggering the unmapped-vreg panic.
178        //
179        // Cost: formally-valid wasm with code-after-Unreachable that doesn't
180        // re-push values (e.g. `(unreachable) (i32.ge_s)`) is rejected. Real
181        // compilers don't emit this shape — wasmparser-decoded production
182        // input always has `i32.const`/`local.get` between the `unreachable`
183        // and any binary op, so depth is non-zero when the op fires and the
184        // check passes. The pathological-input case is a fuzz-harness
185        // construction, not a real wasm pattern.
186        Unreachable => modeled(0, 0),
187
188        // ---- terminators (stack-polymorphic in wasm spec) ----------------
189        // Same reasoning as `Unreachable`: model as stack-neutral so the
190        // pre-flight catches subsequent ops that would underflow `wasm_to_ir`'s
191        // mechanical IR generation. The fuzz harness found follow-up crashes
192        // on `[Return, I64Eqz, ...]` (PR #117 second-round) — Return was
193        // bailing the same way Unreachable did. `Br`/`BrTable` have the same
194        // shape semantically.
195        Return | Br(_) | BrTable { .. } => modeled(0, 0),
196        // BrIf pops the condition (i32) but doesn't terminate — fall-through
197        // path keeps executing. After it, the stack lost the condition.
198        BrIf(_) => modeled(1, 0),
199        // Block / Loop / If / Else / End — control region delimiters. Their
200        // stack effect depends on block type, which we don't have. Treat as
201        // stack-neutral; if a real underflow lurks past one of these, we
202        // accept it (matches the pre-flight's "best-effort safety net" intent).
203        Block | Loop | If | Else | End => modeled(0, 0),
204        // Call — pops N args, pushes M results. Without the callee's
205        // signature we can't compute this. Yield to upstream validation.
206        Call(_) => StackEffect::Bail,
207
208        // ---- SIMD lane ops, etc. — bail ---------------------------------
209        // The selector doesn't fully support these yet; their stack effects
210        // are well-defined but we don't enumerate them here. Bail.
211        _ => StackEffect::Bail,
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn binary_op_at_empty_stack_is_underflow() {
221        // This is the exact crash input from PR #113's fuzz harness:
222        // FuzzInput { num_params: 1, ops: [I32DivS] }
223        let err = check_no_underflow(&[WasmOp::I32DivS]).unwrap_err();
224        assert!(matches!(err, Error::ValidationError(_)), "got: {err:?}");
225        let msg = format!("{err}");
226        assert!(msg.contains("underflow"));
227        assert!(msg.contains("I32DivS"));
228    }
229
230    #[test]
231    fn well_formed_add_passes() {
232        let ops = vec![WasmOp::I32Const(1), WasmOp::I32Const(2), WasmOp::I32Add];
233        assert!(check_no_underflow(&ops).is_ok());
234    }
235
236    #[test]
237    fn unary_op_at_empty_stack_is_underflow() {
238        let err = check_no_underflow(&[WasmOp::I32Eqz]).unwrap_err();
239        assert!(matches!(err, Error::ValidationError(_)));
240    }
241
242    #[test]
243    fn drop_at_empty_stack_is_underflow() {
244        let err = check_no_underflow(&[WasmOp::Drop]).unwrap_err();
245        assert!(matches!(err, Error::ValidationError(_)));
246    }
247
248    #[test]
249    fn bulk_memory_pops_three_374() {
250        // memory.copy / memory.fill pop 3, push 0: three pushed operands then the
251        // op must balance to depth 0.
252        for op in [WasmOp::MemoryCopy, WasmOp::MemoryFill] {
253            let ok = vec![
254                WasmOp::I32Const(0),
255                WasmOp::I32Const(0),
256                WasmOp::I32Const(0),
257                op.clone(),
258            ];
259            assert!(check_no_underflow(&ok).is_ok(), "{op:?} with 3 operands");
260            // only two operands -> underflow
261            let bad = vec![WasmOp::I32Const(0), WasmOp::I32Const(0), op.clone()];
262            assert!(
263                matches!(
264                    check_no_underflow(&bad).unwrap_err(),
265                    Error::ValidationError(_)
266                ),
267                "{op:?} with 2 operands must underflow"
268            );
269        }
270    }
271
272    #[test]
273    fn store_at_empty_stack_is_underflow() {
274        let err = check_no_underflow(&[WasmOp::I32Store {
275            offset: 0,
276            align: 2,
277        }])
278        .unwrap_err();
279        assert!(matches!(err, Error::ValidationError(_)));
280    }
281
282    #[test]
283    fn select_needs_three_operands() {
284        // select with only 2 operands underflows.
285        let ops = vec![WasmOp::I32Const(1), WasmOp::I32Const(2), WasmOp::Select];
286        let err = check_no_underflow(&ops).unwrap_err();
287        assert!(matches!(err, Error::ValidationError(_)));
288    }
289
290    #[test]
291    fn select_with_three_operands_passes() {
292        let ops = vec![
293            WasmOp::I32Const(1),
294            WasmOp::I32Const(2),
295            WasmOp::I32Const(0),
296            WasmOp::Select,
297        ];
298        assert!(check_no_underflow(&ops).is_ok());
299    }
300
301    #[test]
302    fn call_bails_conservatively() {
303        // Call(_) has a callee-signature-dependent stack effect we can't
304        // compute here, so we bail (accept). Upstream wasm validation
305        // catches real signature mismatches.
306        let ops = vec![WasmOp::Call(0), WasmOp::I32Add];
307        assert!(check_no_underflow(&ops).is_ok());
308    }
309
310    #[test]
311    fn return_then_binary_op_at_depth_zero_is_underflow() {
312        // PR #117 second follow-up crash: `[Return, I64Eqz, I32Const(0)]`
313        // had the same shape as the Unreachable crash — Return was bailing
314        // and letting the subsequent op slip through to wasm_to_ir.
315        let ops = vec![WasmOp::Return, WasmOp::I64Eqz];
316        let err = check_no_underflow(&ops).unwrap_err();
317        assert!(matches!(err, Error::ValidationError(_)));
318    }
319
320    #[test]
321    fn br_then_binary_op_at_depth_zero_is_underflow() {
322        // Mirror of the Return case for unconditional branch.
323        let ops = vec![WasmOp::Br(0), WasmOp::I32Add];
324        let err = check_no_underflow(&ops).unwrap_err();
325        assert!(matches!(err, Error::ValidationError(_)));
326    }
327
328    #[test]
329    fn br_if_pops_condition() {
330        // BrIf pops one (the i32 condition). At depth 0, the BrIf itself
331        // underflows.
332        let ops = vec![WasmOp::BrIf(0)];
333        let err = check_no_underflow(&ops).unwrap_err();
334        assert!(matches!(err, Error::ValidationError(_)));
335    }
336
337    #[test]
338    fn br_if_with_condition_then_op_is_ok() {
339        // BrIf pops 1 (the condition), then I32Const pushes 1, then
340        // I32Eqz pops 1 / pushes 1 — no underflow.
341        let ops = vec![
342            WasmOp::I32Const(1),
343            WasmOp::BrIf(0),
344            WasmOp::I32Const(0),
345            WasmOp::I32Eqz,
346        ];
347        assert!(check_no_underflow(&ops).is_ok());
348    }
349
350    #[test]
351    fn unreachable_then_binary_op_at_depth_zero_is_underflow() {
352        // The PR #117 CI follow-up crash: `[Unreachable, I32GeS]` would
353        // crash `wasm_to_ir` (the i32.ge_s after a depth-0 unreachable
354        // generates IR referencing unmapped vregs). With `Unreachable` now
355        // modeled as `pops: 0, pushes: 0`, the subsequent binary op sees
356        // depth 0 and is correctly rejected as an underflow.
357        let ops = vec![WasmOp::Unreachable, WasmOp::I32GeS];
358        let err = check_no_underflow(&ops).unwrap_err();
359        assert!(matches!(err, Error::ValidationError(_)));
360    }
361
362    #[test]
363    fn unreachable_then_consts_then_binary_op_is_ok() {
364        // Formally-valid wasm pattern: after `unreachable` the wasm spec
365        // makes the stack polymorphic, but a real compiler always re-pushes
366        // values before any binary op. Our check accepts this shape because
367        // the consts lift depth back above the op's pop count.
368        let ops = vec![
369            WasmOp::Unreachable,
370            WasmOp::I32Const(1),
371            WasmOp::I32Const(2),
372            WasmOp::I32GeS,
373        ];
374        assert!(check_no_underflow(&ops).is_ok());
375    }
376
377    #[test]
378    fn const_then_unary_then_binary() {
379        // const → eqz → const → const → add — last add needs 2, has 3.
380        let ops = vec![
381            WasmOp::I32Const(0),
382            WasmOp::I32Eqz,
383            WasmOp::I32Const(1),
384            WasmOp::I32Const(2),
385            WasmOp::I32Add,
386        ];
387        assert!(check_no_underflow(&ops).is_ok());
388    }
389
390    #[test]
391    fn empty_input_is_ok() {
392        assert!(check_no_underflow(&[]).is_ok());
393    }
394}