//! 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. //! //! `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_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 { 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 { let leaf = prop_oneof![ Just(Value::Null), any::().prop_map(Value::from), any::().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 { 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 { 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 { 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::()).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 { 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 { 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(), ] } /// 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)> { 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, Value)], inputs: &[u64], junk: Option) -> String { if let Some(j) = junk { return j.to_string(); } let list: Vec = 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::(), ) { let mut s = rich_session(); s.execute("select.all", &json!({})).unwrap(); let st = s.doc().unwrap(); let inputs: Vec = 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 mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8), cut in prop::option::of(any::()), garbage in prop::collection::vec(any::(), 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")?; } }