Files
vectorcraft/crates/engine/tests/plugin_fuzz.rs
T
dotCooCoo ed5de5edcb Plug-ins: a plug-in adds menu commands of its own under Object › Plug-ins
A filter or live effect may declare up to 32 `commands` in its manifest,
each `{id, label, params?}`. While the plug-in is installed each one is the
command `plugin.<id>.<command>`: listed by `command.list` and in the
plug-in's `plugin.list` entry, run through `run`, MCP and `command.batch`
with its parameters checked before the plug-in runs, and recorded in
actions. A run goes through `vc_run` on the selection with
`_context.mode` "command" and `_context.command`, under the plug-in's
permissions, as one undo step named after the command. A failing run
leaves the document as it was, and its error starts with the plug-in's
name and the command's label. Removing or replacing the plug-in removes
its commands.

In the app, a plug-in with commands is a submenu named after it under
Object › Plug-ins (Effect › Plug-ins for a live effect), holding its own
item and then its commands; a `menu` key in a manifest is ignored. A
command with parameters opens a dialog for them from its menu item or a
shortcut. Edit › Keyboard Shortcuts lists the commands for the user to
give a shortcut; a manifest cannot set one. Labels and names are shown
with control and bidirectional characters escaped, and a malformed entry
is refused by name when the module is installed.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
2026-11-07 17:32:54 -08:00

398 lines
18 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`). 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<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(),
]
}
/// 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 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![3 => Just(json!(1)), 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"]))],
) {
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 {
let implied = vectorcraft_plugins::permissions::Permission::implied(man.kind);
prop_assert!(man.permissions().iter().all(|p| implied.contains(p)), "{}", 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");
}
/// Manifests with hostile `commands` (junk of every type, bad and repeated ids, labels with
/// control and bidirectional override characters, a `menu` naming another menu, junk
/// parameters) parse to an error or to commands whose menu labels show no hidden character and
/// that sit under Plug-ins alone; installing one and running each of its commands ends as an
/// error or an edit, never a panic, and the document then renders and exports.
#[test]
fn hostile_manifest_commands_never_crash(
kind in prop::sample::select(vec!["filter", "effect", "exporter"]),
entries in prop::collection::vec(
prop_oneof![
3 => (
prop_oneof![Just(json!("grow")), Just(json!("a_1")), Just(json!("Grow")), Just(json!("")), Just(json!("a.b")), arb_junk()],
prop_oneof![
3 => "[a-zA-Z\u{202E}\u{2066}\u{200F} ]{0,70}".prop_map(Value::from),
1 => "[a-z\u{0007}\n]{1,8}".prop_map(Value::from),
1 => arb_junk(),
],
prop_oneof![Just(Value::Null), Just(json!("File")), arb_junk()],
prop_oneof![Just(Value::Null), Just(json!({"by": {"type": "number", "min": 0, "max": 10, "default": 2}})), arb_junk()],
)
.prop_map(|(id, label, menu, params)| {
let mut c = json!({"id": id, "label": label});
if !menu.is_null() {
c["menu"] = menu;
}
if !params.is_null() {
c["params"] = params;
}
c
}),
1 => arb_junk(),
],
0..5,
),
junk in prop::option::weighted(0.1, arb_junk()),
given in prop_oneof![Just(json!({})), Just(json!({"by": 3})), arb_junk()],
) {
let mut m = json!({"id": "fuzz.commands", "name": "Fuzz \u{202E}Commands", "kind": kind});
if kind == "exporter" {
m["extensions"] = json!(["fz"]);
}
m["commands"] = junk.unwrap_or(json!(entries));
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 {
for c in &man.commands {
let shown = c.shown_label();
prop_assert!(!shown.chars().any(|ch| ch.is_control() || matches!(ch, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown);
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
}
}
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap();
let r = catch_quiet(|| {
let mut ran = vec![];
if install(&mut s, &module).is_ok() {
for c in s.commands().into_iter().filter(|c| c.id.starts_with("plugin.fuzz.commands.")) {
ran.push((c.id, c.menu.clone()));
let _ = s.execute(c.id, &given);
}
}
ran
});
let ran = r.map_err(|msg| TestCaseError::fail(format!("panicked running {text}: {msg}")))?;
prop_assert!(ran.is_empty() || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text);
for (id, menu) in &ran {
prop_assert!(menu.len() == 3 && menu[1] == "Plug-ins" && matches!(menu[0], "Object" | "Effect"), "{id}: {menu:?}");
}
renders(&s, "after hostile commands")?;
vectorcraft_plugins::registry::remove("fuzz.commands");
}
}