diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1791555f7..514118cf3 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -1,9 +1,11 @@ //! Untrusted plug-ins never crash the app: mutated and garbage modules handed to `plugin.install`, //! and plug-ins returning hostile output (random JSON in the object model's shape: huge or //! malformed coordinates, unknown ids, broken paints and gradients, junk of every type) as object -//! filters (`plugin.run`) or live effects (`effect.apply`). Each run must end as an error that -//! leaves the document as it was, or as a document that then renders and exports, without a -//! panic. +//! filters (`plugin.run`) or live effects (`effect.apply`), and plug-ins running bulk memory +//! instructions (`memory.fill`, `memory.copy`, `memory.init`, `data.drop`) on ranges in, at and past +//! the ends of memory and the data segment, under small and default budgets. Each run must end as +//! an error that leaves the document as it was, or as a document that then renders and exports, +//! without a panic. A bulk memory run traps exactly when a range leaves its region. //! //! `PROPTEST_CASES=2000 cargo test -p vectorcraft-engine --test plugin_fuzz` runs a deeper search. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). @@ -13,6 +15,7 @@ use proptest::prelude::*; use serde_json::{Map, Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::wat as tpl; +use vectorcraft_plugins::{Error, Limits, Plugin}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::rich_session; @@ -157,6 +160,272 @@ fn arb_paint() -> impl Strategy { ] } +/// Bytes in the passive data segment that bulk memory cases initialize memory from. +const SEGMENT_LEN: u32 = 4096; + +/// Fuel the host hands a run per slice (`FUEL_SLICE` in `vectorcraft_plugins`' runtime). +const FUEL_SLICE: u64 = 20_000_000; + +/// Where a bulk memory plug-in keeps its output (the manifest sits at 16). +const BULK_OUTPUT_AT: u32 = 0x8000; + +/// The output a bulk memory plug-in returns. +const BULK_OUTPUT: &str = r#"{"objects":[]}"#; + +/// The input and parameters a bulk memory plug-in is run on directly: each takes one page, so the +/// memory then ends two pages after the module's initial pages. +const BULK_INPUT: &[u8] = BULK_OUTPUT.as_bytes(); +const BULK_PARAMS: &[u8] = b"{}"; + +/// The end of memory in WAT, read when the instruction runs. +const MEMORY_END: &str = "(i32.shl (memory.size) (i32.const 16))"; + +/// A bulk memory instruction a generated plug-in runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BulkOp { + /// `memory.fill` over the destination range. + Fill, + /// `memory.copy` from the source range to the destination range, which may overlap. + Copy, + /// `memory.init` from the data segment, then `data.drop`. + Init, + /// `data.drop`, then `memory.init` from the emptied segment. + Drop, +} + +impl BulkOp { + const ALL: [BulkOp; 4] = [BulkOp::Fill, BulkOp::Copy, BulkOp::Init, BulkOp::Drop]; + + /// The instruction a module running this operation contains. + fn opcode(self) -> &'static str { + match self { + BulkOp::Fill => "memory.fill", + BulkOp::Copy => "memory.copy", + BulkOp::Init => "memory.init", + BulkOp::Drop => "data.drop", + } + } +} + +/// What an offset is measured from: the start, the end, or the end less the length (a range that +/// ends exactly at the end). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Base { + Start, + End, + EndLessLen, +} + +/// An offset into memory or the data segment: `base` plus `delta`, in the wrapping 32-bit +/// arithmetic the module computes it in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Offset { + base: Base, + delta: i32, +} + +impl Offset { + const fn start(delta: i32) -> Self { + Offset { base: Base::Start, delta } + } + + /// The offset in a region that ends at `end`, for a range of `len` bytes. + fn value(self, end: u32, len: u32) -> u32 { + let base = match self.base { + Base::Start => 0, + Base::End => end, + Base::EndLessLen => end.wrapping_sub(len), + }; + base.wrapping_add_signed(self.delta) + } + + /// The WAT computing the offset, with `end` the WAT of the region's end. + fn wat(self, end: &str, len: u32) -> String { + let base = match self.base { + Base::Start => "(i32.const 0)".to_string(), + Base::End => end.to_string(), + Base::EndLessLen => format!("(i32.sub {end} (i32.const {len}))"), + }; + format!("(i32.add {base} (i32.const {}))", self.delta) + } +} + +/// A plug-in whose run executes one bulk memory instruction. +#[derive(Clone, Copy, Debug)] +struct BulkCase { + op: BulkOp, + /// The destination in memory. + dst: Offset, + /// The source: in memory for `Copy`, in the data segment for `Init` and `Drop`. + src: Offset, + len: u32, + /// The byte `Fill` writes. + value: u8, + /// How many times `Fill` and `Copy` run; `Init` and `Drop` run once. + repeat: u32, + /// Initial memory pages. + pages: u32, +} + +impl BulkCase { + fn runs(self) -> u32 { + match self.op { + BulkOp::Fill | BulkOp::Copy => self.repeat.max(1), + BulkOp::Init | BulkOp::Drop => 1, + } + } + + /// The memory end when the plug-in is run on [`BULK_INPUT`] and [`BULK_PARAMS`]. + fn end(self) -> u32 { + (self.pages + 2) << 16 + } + + /// Whether every range the instruction touches lies inside memory ending at `end` and inside + /// the data segment (empty once dropped). A range may end exactly at the end; an offset past + /// the end traps even for zero bytes. + fn in_bounds(self, end: u32) -> bool { + let fits = |off: u32, end: u32| u64::from(off) + u64::from(self.len) <= u64::from(end); + let dst = fits(self.dst.value(end, self.len), end); + let segment = self.src.value(SEGMENT_LEN, self.len); + match self.op { + BulkOp::Fill => dst, + BulkOp::Copy => dst && fits(self.src.value(end, self.len), end), + BulkOp::Init => dst && fits(segment, SEGMENT_LEN), + BulkOp::Drop => dst && fits(segment, 0), + } + } + + /// A generous estimate of the fuel the instructions use: a unit per 64 bytes moved, and a few + /// per pass. + fn fuel(self) -> u64 { + u64::from(self.runs()) * (u64::from(self.len) / 64 + 16) + } + + /// The plug-in module in WAT, with `manifest`. + fn wat(self, manifest: &str) -> String { + let (dst, len) = (self.dst.wat(MEMORY_END, self.len), self.len); + let segment = self.src.wat(&format!("(i32.const {SEGMENT_LEN})"), len); + let op = match self.op { + BulkOp::Fill => format!("(memory.fill {dst} (i32.const {}) (i32.const {len}))", self.value), + BulkOp::Copy => format!("(memory.copy {dst} {} (i32.const {len}))", self.src.wat(MEMORY_END, len)), + BulkOp::Init => format!("(memory.init $seg {dst} {segment} (i32.const {len})) (data.drop $seg)"), + BulkOp::Drop => format!("(data.drop $seg) (memory.init $seg {dst} {segment} (i32.const {len}))"), + }; + let run = format!( + "(local $n i32) (local.set $n (i32.const {runs})) + (block $done (loop $again + (br_if $done (i32.eqz (local.get $n))) + (local.set $n (i32.sub (local.get $n) (i32.const 1))) + {op} + (br $again))) + (i64.or (i64.shl (i64.const {out_len}) (i64.const 32)) (i64.const {BULK_OUTPUT_AT}))", + runs = self.runs(), + out_len = BULK_OUTPUT.len(), + ); + let bytes: String = (0..SEGMENT_LEN).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let extra = format!(r#"(data $seg "{}") (data (i32.const {BULK_OUTPUT_AT}) "{}")"#, bytes, tpl::string(BULK_OUTPUT)); + tpl::module(manifest, &run, &extra, self.pages) + } +} + +/// Lengths: zero, a byte, around the 64 bytes a unit of fuel pays for, the data segment's length, +/// a page, a mebibyte, or anything up to four pages. +fn arb_bulk_len() -> impl Strategy { + prop_oneof![ + 3 => prop::sample::select(vec![0u32, 1, 63, 64, 65, SEGMENT_LEN - 1, SEGMENT_LEN, SEGMENT_LEN + 1, 0xffff, 0x1_0000, 0x1_0001, 1 << 20]), + 1 => 0u32..0x4_0000, + ] +} + +/// Offsets: near the start, ending at, before or past the end, at the end, or one past it. +fn arb_bulk_offset() -> impl Strategy { + prop_oneof![ + 3 => prop::sample::select(vec![0i32, 1, 100, BULK_OUTPUT_AT as i32]).prop_map(Offset::start), + 3 => (-2i32..=2).prop_map(|delta| Offset { base: Base::EndLessLen, delta }), + 2 => (-1i32..=1).prop_map(|delta| Offset { base: Base::End, delta }), + 1 => any::().prop_map(Offset::start), + ] +} + +/// How many times `Fill` and `Copy` run. +#[derive(Clone, Copy, Debug)] +enum Repeat { + Times(u32), + /// As many passes as a slice of fuel pays for the bytes, plus this (at most 2,000 passes). + AroundSlice(i64), +} + +/// Bulk memory cases in initial memories of 1, 2 or 17 pages. +fn arb_bulk_case() -> impl Strategy { + ( + prop::sample::select(BulkOp::ALL.to_vec()), + arb_bulk_offset(), + arb_bulk_offset(), + arb_bulk_len(), + any::(), + prop_oneof![8 => Just(Repeat::Times(1)), 2 => (2u32..=4).prop_map(Repeat::Times), 1 => (-1i64..=1).prop_map(Repeat::AroundSlice)], + prop::sample::select(vec![1u32, 2, 17]), + ) + .prop_map(|(op, dst, src, len, value, repeat, pages)| { + let repeat = match repeat { + Repeat::Times(n) => n, + Repeat::AroundSlice(k) => { + let slice = (FUEL_SLICE * 64 / u64::from(len.max(1))) as i64; + (slice + k).clamp(1, 2_000) as u32 + } + }; + BulkCase { op, dst, src, len, value, repeat, pages } + }) +} + +/// The fuel a direct run gets: the default budget, or a budget of its own for each call with none +/// added per input byte: a small fixed one, or [`BulkCase::fuel`] plus or minus a little. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fuel { + Default, + Fixed(u64), + AroundCost(i64), +} + +fn arb_fuel() -> impl Strategy { + prop_oneof![ + 4 => Just(Fuel::Default), + 1 => prop::sample::select(vec![1u64, 100, 1_000, 10_000]).prop_map(Fuel::Fixed), + 2 => (-64i64..64).prop_map(Fuel::AroundCost), + ] +} + +/// Runs `case` directly under `fuel`, without the engine. +fn run_bulk(case: BulkCase, fuel: Fuel) -> Result, Error> { + let wasm = wat::parse_str(case.wat(&tpl::manifest("fuzz.bulk", "filter", "{}"))).unwrap(); + let plugin = Plugin::load(&wasm, Limits::default()).unwrap(); + let fuel_base = match fuel { + Fuel::Default => return plugin.run(BULK_INPUT, BULK_PARAMS), + Fuel::Fixed(f) => f, + Fuel::AroundCost(k) => case.fuel().saturating_add_signed(k).max(1), + }; + plugin.run_limited(BULK_INPUT, BULK_PARAMS, &Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() }) +} + +#[test] +fn bulk_memory_cases_reach_their_instruction() { + for op in BulkOp::ALL { + let base = BulkCase { op, dst: Offset::start(0x1_0000), src: Offset::start(0), len: 1, value: 7, repeat: 1, pages: 2 }; + let (inside, outside) = match op { + BulkOp::Fill | BulkOp::Copy => (base, BulkCase { dst: Offset { base: Base::End, delta: 0 }, ..base }), + BulkOp::Init => (base, BulkCase { src: Offset { base: Base::End, delta: 0 }, ..base }), + BulkOp::Drop => (BulkCase { len: 0, ..base }, base), + }; + let src = inside.wat(&tpl::manifest("fuzz.bulk", "filter", "{}")); + assert!(src.contains(op.opcode()), "{op:?} emits no {}", op.opcode()); + wat::parse_str(&src).unwrap(); + assert!(inside.in_bounds(inside.end()) && !outside.in_bounds(outside.end()), "{op:?}"); + assert!(run_bulk(inside, Fuel::Default).is_ok(), "{op:?} failed in bounds"); + let r = run_bulk(outside, Fuel::Default); + assert!(matches!(r, Err(Error::Trap(_))), "{op:?} out of bounds gave {r:?}"); + } +} + /// An output object: an input object's id (by index into the inputs) or a random one, and any of /// the object keys. fn arb_object() -> impl Strategy, Value)> { @@ -242,6 +511,33 @@ proptest! { renders(&s, &out)?; } + #[test] + fn bulk_memory_plugins_never_crash(case in arb_bulk_case(), fuel in arb_fuel()) { + let r = catch_quiet(|| run_bulk(case, fuel)).map_err(|msg| TestCaseError::fail(format!("panicked on {case:?}: {msg}")))?; + let in_bounds = case.in_bounds(case.end()); + let ample = fuel == Fuel::Default && case.fuel() <= FUEL_SLICE / 4; + match &r { + Ok(_) => prop_assert!(in_bounds, "{:?} ran out of bounds without a trap", case), + Err(Error::Trap(e)) => prop_assert!(!in_bounds && !e.contains("panicked"), "{:?} trapped: {}", case, e), + Err(Error::Limit(e)) => prop_assert!(fuel != Fuel::Default || (in_bounds && !ample), "{:?} hit a limit: {}", case, e), + Err(e) => return Err(TestCaseError::fail(format!("{case:?} failed: {e}"))), + } + if ample { + prop_assert!(r.is_ok() == in_bounds, "{:?} gave {:?}", case, r.err()); + } + + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + install(&mut s, &wat::parse_str(case.wat(&tpl::manifest("fuzz.bulk", "filter", "{}"))).unwrap()).unwrap(); + let before = s.doc().unwrap().doc.clone(); + match catch_quiet(|| s.execute("plugin.run", &json!({"id": "fuzz.bulk"})).map(|_| ())) { + Err(msg) => return Err(TestCaseError::fail(format!("panicked on {case:?}: {msg}"))), + Ok(Err(_)) => prop_assert!(s.doc().unwrap().doc == before, "a failed run changed the document: {:?}", case), + Ok(Ok(())) => {} + } + renders(&s, &format!("{case:?}"))?; + } + #[test] fn mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8),