Merge pull request #6198 from dotCooCoo/fix/plugin-fuzz-bulk-memory

Plug-ins: the fuzz test generates bulk memory operations
This commit is contained in:
BFlat
2026-11-09 02:00:41 +08:00
committed by GitHub
+299 -3
View File
@@ -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<Value = Value> {
]
}
/// 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<Value = u32> {
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<Value = Offset> {
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::<i32>().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<Value = BulkCase> {
(
prop::sample::select(BulkOp::ALL.to_vec()),
arb_bulk_offset(),
arb_bulk_offset(),
arb_bulk_len(),
any::<u8>(),
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<Value = Fuel> {
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<Vec<u8>, 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 = (Option<usize>, 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::<prop::sample::Index>(), any::<u8>()), 0..8),