Files
vectorcraft/crates/engine/tests/plugin_fuzz.rs
T
dotCooCoo 332e115b68 Plug-ins: the bulk memory fuzz generators follow the paint generators
Moves the bulk memory case types, strategies and their test after arb_paint, so they no longer share the lines after output where other fuzz generators are added.
2026-11-09 00:31:14 +07:00

662 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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`), 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).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
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;
const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm");
/// 64 cases each, unless `PROPTEST_CASES` asks for more.
fn config() -> ProptestConfig {
let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() };
if std::env::var_os("PROPTEST_CASES").is_none() {
c.cases = 64;
}
c
}
fn install(s: &mut Session, wasm: &[u8]) -> Result<Value, String> {
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string())
}
/// The document renders and exports without panicking.
fn renders(s: &Session, what: &str) -> Result<(), TestCaseError> {
let d = &s.doc().unwrap().doc;
let r = catch_quiet(|| {
let mut r = vectorcraft_render::Renderer::new();
if let Some(ab) = d.artboards.first() {
let _ = r.render_region(d, ab.rect, 0.25, true).to_png();
}
let _ = vectorcraft_svg::export(d, &vectorcraft_svg::ExportOptions::default());
let _ = vectorcraft_engine::export_pdf(d, &vectorcraft_pdf::PdfOptions::default());
let _ = vectorcraft_format::save_file(d);
});
r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}")))
}
/// Junk of every JSON type.
fn arb_junk() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::from),
any::<i64>().prop_map(Value::from),
(-1e3f64..1e3).prop_map(Value::from),
"[a-zA-Z#]{0,6}".prop_map(Value::from),
];
leaf.prop_recursive(2, 12, 3, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..3).prop_map(Value::Array),
prop::collection::btree_map("[a-z]{1,5}", inner, 0..3).prop_map(|m| Value::Object(m.into_iter().collect())),
]
})
}
/// Numbers that tend to break arithmetic.
fn arb_num() -> impl Strategy<Value = Value> {
prop_oneof![
6 => (-600.0f64..600.0).prop_map(Value::from),
1 => Just(json!(0)),
1 => Just(json!(-0.0)),
1 => Just(json!(1e308)),
1 => Just(json!(-1e308)),
1 => Just(json!(4.0e6)),
1 => Just(json!(4.000001e6)),
1 => Just(json!(1e-308)),
1 => Just(json!(u64::MAX)),
1 => Just(json!(i64::MIN)),
1 => arb_junk(),
]
}
fn arb_point() -> impl Strategy<Value = Value> {
prop_oneof![
8 => (arb_num(), arb_num()).prop_map(|(x, y)| json!([x, y])),
1 => (arb_num(), arb_num()).prop_map(|(x, y)| json!({"x": x, "y": y})),
1 => arb_junk(),
]
}
fn arb_path() -> impl Strategy<Value = Value> {
let anchor = (
arb_point(),
prop::option::of(arb_point()),
prop::option::of(arb_point()),
prop::option::of(prop_oneof![Just("Smooth"), Just("Corner"), Just("Weird")]),
)
.prop_map(|(p, i, o, k)| {
let mut a = json!({"p": p});
if let Some(i) = i {
a["in"] = i;
}
if let Some(o) = o {
a["out"] = o;
}
if let Some(k) = k {
a["kind"] = json!(k);
}
a
});
let sub = (prop::collection::vec(anchor, 0..6), any::<bool>()).prop_map(|(anchors, closed)| json!({"anchors": anchors, "closed": closed}));
prop_oneof![9 => prop::collection::vec(sub, 0..3).prop_map(|s| json!({"subpaths": s})), 1 => arb_junk()]
}
fn arb_color() -> impl Strategy<Value = Value> {
prop_oneof![
(arb_num(), arb_num(), arb_num()).prop_map(|(r, g, b)| json!({"model": "rgb", "r": r, "g": g, "b": b})),
(arb_num(), arb_num(), arb_num(), arb_num()).prop_map(|(c, m, y, k)| json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k})),
arb_num().prop_map(|k| json!({"model": "gray", "k": k})),
(arb_num(), arb_num(), arb_num()).prop_map(|(l, a, b)| json!({"model": "lab", "l": l, "a": a, "b": b})),
arb_junk(),
]
}
fn arb_paint() -> impl Strategy<Value = Value> {
let stop = (arb_num(), arb_color(), arb_num(), arb_num()).prop_map(|(o, c, a, m)| json!({"offset": o, "color": c, "opacity": a, "midpoint": m}));
let freeform = (
prop::collection::vec((arb_point(), arb_color(), arb_num()).prop_map(|(at, c, s)| json!({"at": at, "color": c, "spread": s})), 0..3),
arb_junk(),
)
.prop_map(|(points, lines)| json!({"points": points, "lines": if lines.is_array() { lines } else { json!([[0, 1, 9]]) }}));
let gradient = (
prop_oneof![Just("Linear"), Just("Radial"), Just("Freeform"), Just("Conic")],
prop::collection::vec(stop, 0..4),
prop::option::of((arb_point(), arb_point(), arb_num(), prop::option::of(arb_point()))),
arb_num(),
prop::option::of(freeform),
)
.prop_map(|(kind, stops, geom, angle, ff)| {
let mut g = json!({"type": "gradient", "gradient": {"kind": kind, "stops": stops}, "angle": angle});
if let Some((start, end, aspect, focal)) = geom {
g["geom"] = json!({"start": start, "end": end, "aspect": aspect, "focal": focal});
}
if let Some(ff) = ff {
g["freeform"] = ff;
}
g
});
prop_oneof![
2 => Just(Value::Null),
3 => (arb_color(), prop::option::of(prop_oneof![Just("White"), Just("Nope")]), arb_num())
.prop_map(|(c, sw, t)| json!({"type": "solid", "color": c, "swatch": sw, "tint": t})),
3 => gradient,
1 => (prop_oneof![Just("Nope"), Just("")], prop::collection::vec(arb_num(), 6)).prop_map(|(p, xf)| json!({"type": "pattern", "pattern": p, "xf": xf})),
1 => Just(json!({"type": "none"})),
1 => arb_junk(),
]
}
/// 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)> {
let stroke = (arb_paint(), prop::option::of(arb_num())).prop_map(|(p, w)| json!({"paint": p, "width": w}));
(
prop::option::of(0usize..8),
prop::option::of(prop_oneof![Just(json!("path")), Just(json!("compound")), arb_junk()]),
prop::option::of(arb_path()),
prop::option::of(prop_oneof![Just(json!("evenodd")), Just(json!("nonzero")), arb_junk()]),
prop::option::of(prop::collection::vec(arb_paint(), 0..3)),
prop::option::of(prop::collection::vec(stroke, 0..3)),
prop::option::of(arb_num()),
prop::option::of(prop_oneof![Just(json!("name")), arb_junk()]),
)
.prop_map(|(id, t, path, rule, fills, strokes, opacity, name)| {
let mut o = Map::new();
for (k, v) in [
("type", t),
("path", path),
("fillRule", rule),
("fills", fills.map(Value::from)),
("strokes", strokes.map(Value::from)),
("opacity", opacity),
("name", name),
] {
if let Some(v) = v {
o.insert(k.into(), v);
}
}
(id, Value::Object(o))
})
}
/// The output document: objects (ids resolved against `inputs`; out-of-range indices become
/// unknown ids), or junk.
fn output(objects: &[(Option<usize>, Value)], inputs: &[u64], junk: Option<Value>) -> String {
if let Some(j) = junk {
return j.to_string();
}
let list: Vec<Value> = objects
.iter()
.map(|(id, o)| {
let mut o = o.clone();
if let Some(i) = id {
o["id"] = json!(inputs.get(*i).copied().unwrap_or(9_999_999 + *i as u64));
}
o
})
.collect();
json!({ "objects": list }).to_string()
}
proptest! {
#![proptest_config(config())]
#[test]
fn hostile_filter_and_effect_output_never_crashes(
objects in prop::collection::vec(arb_object(), 0..5),
junk in prop::option::weighted(0.1, arb_junk()),
effect in any::<bool>(),
) {
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let st = s.doc().unwrap();
let inputs: Vec<u64> = vectorcraft_plugins::objects::filter_targets(&st.doc, &st.selection.objects).iter().map(|i| i.0).collect();
let out = output(&objects, &inputs, junk);
let id = if effect { "fuzz.effect" } else { "fuzz.filter" };
let src = tpl::returning(&tpl::manifest(id, if effect { "effect" } else { "filter" }, "{}"), &out);
install(&mut s, &wat::parse_str(src).unwrap()).unwrap();
let before = s.doc().unwrap().doc.clone();
let r = catch_quiet(|| {
if effect {
s.execute("effect.apply", &json!({"effect": format!("plugin.{id}")})).map(|_| ())
} else {
s.execute("plugin.run", &json!({"id": id})).map(|_| ())
}
});
match r {
Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))),
Ok(Err(_)) => prop_assert!(s.doc().unwrap().doc == before, "a failed run changed the document: {}", out),
Ok(Ok(())) => {}
}
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),
cut in prop::option::of(any::<prop::sample::Index>()),
garbage in prop::collection::vec(any::<u8>(), 0..64),
) {
let mut bytes = DESATURATE.to_vec();
for (i, b) in &flips {
let i = i.index(bytes.len());
bytes[i] = *b;
}
if let Some(c) = cut {
bytes.truncate(c.index(bytes.len()));
}
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
for module in [bytes, [b"\0asm\x01\0\0\0".as_slice(), &garbage].concat(), garbage.clone()] {
let r = catch_quiet(|| {
if let Ok(info) = install(&mut s, &module) {
let _ = s.execute("plugin.run", &json!({"id": info["id"]}));
}
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
renders(&s, "after mutated modules")?;
}
/// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated
/// permissions, junk of every type, control and bidirectional override characters) parse to
/// an error or to a manifest whose permissions fit its kind, and installing one with a junk
/// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden
/// character from the name.
#[test]
fn hostile_manifest_permissions_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
names in prop::collection::vec(
prop_oneof![
prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]),
Just("doc.read\u{202E}"),
Just("\u{0007}"),
],
0..6,
),
permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]),
api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 1 => arb_junk()],
name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"],
grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "progress"]))],
) {
let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api});
if kind == "exporter" {
m["extensions"] = json!(["fz"]);
}
m["permissions"] = match permissions {
Some(Value::Null) | None => json!(names),
Some(junk) => junk,
};
let text = m.to_string();
let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes()));
let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?;
if let Ok(man) = &parsed {
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text);
let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
}
let mut s = rich_session();
let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap();
let data = vectorcraft_format::base64_encode(&module);
let r = catch_quiet(|| {
let _ = s.execute("plugin.inspect", &json!({"dataBase64": data}));
s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok()
});
let installed = r.map_err(|msg| TestCaseError::fail(format!("panicked installing {text}: {msg}")))?;
prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
vectorcraft_plugins::registry::remove("fuzz.permissions");
vectorcraft_plugins::registry::revoke("fuzz.permissions");
}
/// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress
/// counts, any log level, pointers and lengths in and out of its memory, text that isn't
/// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps.
#[test]
fn hostile_host_import_arguments_never_crash(
calls in prop::collection::vec(
prop_oneof![
(any::<i64>(), any::<i64>()).prop_map(|(d, t)| format!("(call $vc_progress (i64.const {d}) (i64.const {t}))")),
Just("(drop (call $vc_cancelled))".to_string()),
(prop_oneof![any::<i32>(), -1..5i32], prop_oneof![any::<i32>(), 0x1ff00..0x20100i32], prop_oneof![any::<i32>(), 0..5000i32])
.prop_map(|(l, p, n)| format!("(drop (call $vc_log (i32.const {l}) (i32.const {p}) (i32.const {n})))")),
],
0..24,
),
bytes in prop::collection::vec(any::<u8>(), 0..64),
effect in any::<bool>(),
) {
use vectorcraft_plugins::host::{LOG_TRUNCATED, MAX_LOG_BYTES, MAX_LOG_LINE, MAX_LOG_LINES};
let manifest = if effect {
r#"{"id":"fuzz.host","name":"F","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#
} else {
r#"{"id":"fuzz.host","name":"F","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#
};
let text: String = bytes.iter().map(|b| format!("\\{b:02x}")).collect();
let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::HOST_IMPORTS);
let wasm = wat::parse_str(tpl::with_imports(manifest, &imports, &calls.concat(), r#"{"objects":[]}"#)).unwrap();
let r = catch_quiet(|| {
let p = vectorcraft_plugins::Plugin::load(&wasm, vectorcraft_plugins::Limits::default())?;
let token = vectorcraft_doc::cancel::CancelToken::new();
let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(|_, _, _| {});
Ok::<_, vectorcraft_plugins::Error>(vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(b"{\"objects\":[]}", b"{}", p.limits())))
});
let r = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?;
let (out, report) = r.map_err(|e| TestCaseError::fail(format!("refused at load: {e}")))?;
prop_assert!(out.is_ok(), "{:?}", out);
prop_assert!(report.lines.len() <= MAX_LOG_LINES + 1);
prop_assert!(report.lines.iter().all(|l| l.text.chars().count() <= MAX_LOG_LINE));
prop_assert!(report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum::<usize>() <= MAX_LOG_BYTES);
if let Some((done, total)) = report.progress {
prop_assert!(total == 0 || done <= total);
}
}
}