mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 22:28:09 +00:00
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user