mirror of
https://github.com/aphrody-labs/vectorcraft.git
synced 2026-10-14 11:29:03 +00:00
VectorCraft had an Additional Plug-ins Folder preference but nothing loaded from it. Port PhotoCraft's wasmi plug-in host (same sandbox, manifest and parameter schema) as the new vectorcraft-plugins crate (L2), extended for vector documents: - Object filters read the selected paths and compound paths as JSON (native PathData and Paint forms: geometry, fills, strokes, gradients, opacity) and return the objects to keep, change, add or delete; the output is validated in full and applied as one undo step (plugin.run). A failing plug-in leaves the document untouched. - Live-effect plug-ins are geometry effects in the appearance stack (effect id plugin.<id>), re-evaluated (and cached) when the object or parameters change, editable, exported and baked by Expand Appearance; a missing plug-in leaves the geometry as is. - plugin.list / info / run / install / remove / reload; the preference folder installs every *.wasm at startup and when it changes; File > Open of a .wasm installs it (web). - Object > Plug-ins and Effect > Plug-ins menus with dialogs generated from the parameter schema (live preview), the Appearance panel's fx menu, docs/plugins.md, an original example plug-in (Desaturate, a Rust crate outside the workspace) and fuzzing of the plug-in boundary (engine/tests/plugin_fuzz.rs). Tool plug-ins and paint-changing live effects are left for later (docs/plugins.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
11 KiB
Rust
272 lines
11 KiB
Rust
//! 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")?;
|
||
}
|
||
}
|