Files
vectorcraft/crates/plugins/tests/abi.rs
T
Louise FoxandClaude Opus 5.5 0bd011bd18 Plug-ins: compiling a plug-in's code takes none of a run's instructions
wasmi compiles each function of a plug-in when it is first called and,
by default, charges the run that calls it 7 instructions for each byte
of the function. The host hands a run its budget in 20 M-instruction
slices and resumes it when a slice runs out. But when a slice runs out
while a function compiles, wasmi returns an error from the call, not an
out-of-fuel pause, and the host reports it as Limit("instruction
budget"). So a plug-in's first run could fail with most of its budget
left, depending on where the slices fell, and the next run, with its
functions compiled, succeeded.

StitchCraft's PES exporter hit it on a design of 98 × 148 mm. In
VectorCraft 0.17.0's host its first export failed with 10 G and with
275 G instructions alike, and its second succeeded, taking under 1 G.
Run on wasmi directly, with the host's slices, the first export failed
in its 36th slice ("required_fuel=174139"), and with compiling free it
finished in 49.

Compiling now costs nothing: wasmi's fuel costs with no cost for
translating or validating, and its default cost for copying. A run pays
for its own instructions only, as many on its first run as on the next.
Compiling every function when the plug-in loads (CompilationMode::Eager)
also fixes it, but loads a 1.4 MB module in 42 ms instead of 10.

The new test fails without the change: a run that takes 1,040
instructions once its functions are compiled failed within them on a
plug-in just loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-26 08:18:24 +07:00

325 lines
18 KiB
Rust

//! The plug-in ABI end to end: the example Desaturate plug-in, and hostile modules (infinite
//! loops, memory bombs, out-of-bounds access, bad manifests and outputs…), which must all fail
//! with an error, quickly.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl};
/// The example Desaturate plug-in, built from `crates/plugins/example`.
const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm");
const MANIFEST: &str = r#"{"id":"test.plugin","name":"Test","version":"1","kind":"filter"}"#;
fn build(src: &str) -> Vec<u8> {
wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}"))
}
/// A filter whose `vc_run` body is `run`, with `extra` module fields.
fn module(manifest: &str, run: &str, extra: &str) -> Vec<u8> {
build(&tpl::module(manifest, run, extra, 1))
}
fn ok_module(run: &str) -> Vec<u8> {
module(MANIFEST, run, "")
}
const INPUT: &str = r#"{"objects":[]}"#;
/// Runs `bytes` on an empty input; returns the error and how long it took.
fn run_err(bytes: &[u8], limits: Limits) -> (Error, Duration) {
let t = Instant::now();
let r = Plugin::load(bytes, limits).and_then(|p| p.run(INPUT.as_bytes(), b"{}"));
(r.expect_err("hostile module must fail"), t.elapsed())
}
#[test]
fn example_desaturate_loads_and_reports_its_manifest() {
let p = Plugin::load(DESATURATE, Limits::default()).unwrap();
let m = p.manifest();
assert_eq!(m.id, "org.vectorcraft.example.desaturate");
assert_eq!(m.name, "Desaturate");
assert_eq!(m.kind, Kind::Filter);
assert_eq!(m.params_doc(), "{amount: 0..100 (100)}");
}
#[test]
fn example_desaturate_moves_every_colour_model_to_grey() {
let p = Plugin::load(DESATURATE, Limits::default()).unwrap();
let input = json!({"objects": [{"id": 3, "type": "path", "fills": [
{"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}},
{"type": "gradient", "gradient": {"kind": "Linear", "stops": [
{"offset": 0.0, "color": {"model": "lab", "l": 50.0, "a": 40.0, "b": -20.0}},
{"offset": 1.0, "color": {"model": "cmyk", "c": 0.9, "m": 0.0, "y": 0.3, "k": 0.1}}]}}]}]});
let out = p.run(input.to_string().as_bytes(), br#"{"amount": 100}"#).unwrap();
let out: Value = serde_json::from_slice(&out).unwrap();
let fills = &out["objects"][0]["fills"];
let rgb = &fills[0]["color"];
assert!((rgb["r"].as_f64().unwrap() - 0.2126).abs() < 1e-9 && rgb["r"] == rgb["g"] && rgb["g"] == rgb["b"], "{rgb}");
let stops = &fills[1]["gradient"]["stops"];
assert_eq!((stops[0]["color"]["a"].as_f64(), stops[0]["color"]["b"].as_f64()), (Some(0.0), Some(0.0)));
let cmyk = &stops[1]["color"];
assert!((cmyk["c"].as_f64().unwrap() - 0.4).abs() < 1e-9 && cmyk["k"] == 0.1, "{cmyk}");
assert_eq!(out["objects"][0]["id"], 3, "ids come back, so the objects are updated in place");
// Half way.
let half: Value = serde_json::from_slice(&p.run(input.to_string().as_bytes(), br#"{"amount": 50}"#).unwrap()).unwrap();
assert!((half["objects"][0]["fills"][0]["color"]["r"].as_f64().unwrap() - 0.6063).abs() < 1e-9);
// Nothing to work on: the plug-in's own message.
let none = p.run(INPUT.as_bytes(), b"{}").unwrap();
assert!(matches!(vectorcraft_plugins::objects::decode(&none), Err(Error::Failed(m)) if m.contains("Select some paths")));
}
#[test]
fn input_and_params_reach_the_plugin_and_output_comes_back() {
let p = Plugin::load(&build(&tpl::module(MANIFEST, tpl::ECHO, "", 1)), Limits::default()).unwrap();
let input = br#"{"objects":[{"id":1}]}"#;
assert_eq!(p.run(input, b"{}").unwrap(), input);
// Returns the parameters instead.
let params = module(MANIFEST, "(i64.or (i64.shl (i64.extend_i32_u (local.get $pl)) (i64.const 32)) (i64.extend_i32_u (local.get $pp)))", "");
let p = Plugin::load(&params, Limits::default()).unwrap();
assert_eq!(p.run(input, br#"{"amount":5}"#).unwrap(), br#"{"amount":5}"#);
let fixed = Plugin::load(&build(&tpl::returning(MANIFEST, r#"{"objects":[]}"#)), Limits::default()).unwrap();
assert_eq!(fixed.run(input, b"{}").unwrap(), br#"{"objects":[]}"#);
}
#[test]
fn hostile_modules_fail_quickly() {
let quick = Duration::from_secs(5);
let cases: Vec<(&str, Vec<u8>)> = vec![
("infinite loop", ok_module("(loop $l (br $l)) (i64.const 0)")),
("memory.grow bomb, then trap", ok_module("(loop $l (br_if $l (i32.ne (memory.grow (i32.const 512)) (i32.const -1)))) unreachable")),
("memory.grow forever", ok_module("(loop $l (drop (memory.grow (i32.const 1))) (br $l)) (i64.const 0)")),
("out-of-bounds write", ok_module("(i32.store (i32.const -4) (i32.const 0)) (i64.const 0)")),
("trap", ok_module("unreachable")),
("division by zero", ok_module("(drop (i32.div_u (i32.const 1) (i32.const 0))) (i64.const 0)")),
("deep recursion", module(MANIFEST, "(call $r) (i64.const 0)", "(func $r (call $r))")),
("error code", ok_module("(i64.const -7)")),
("output outside memory", ok_module("(i64.const 0x00000100fffffff0)")),
("huge output", ok_module("(i64.const 0x7fffffff00000000)")),
(
"wrong ABI version",
build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace("(result i32) (i32.const 1))", "(result i32) (i32.const 2))")),
),
("bad manifest", module("not json", "(i64.const 0)", "")),
("manifest with a bad id", module(r#"{"id":"a b","name":"x","kind":"filter"}"#, "(i64.const 0)", "")),
("manifest of the wrong kind", module(r#"{"id":"a","name":"x","kind":"panel"}"#, "(i64.const 0)", "")),
("start function loops", module(MANIFEST, "(i64.const 0)", "(func $spin (loop $l (br $l))) (start $spin)")),
(
"WASI import",
module(MANIFEST, "(i64.const 0)", r#"(import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#),
),
];
for (name, bytes) in cases {
let (e, t) = run_err(&bytes, Limits::default());
assert!(t < quick, "{name} took {t:?}");
eprintln!("{name}: {e} ({t:?})");
}
}
#[test]
fn malformed_and_oversized_modules_are_refused() {
let half = &DESATURATE[..DESATURATE.len() / 2];
for (name, bytes) in [("empty", &b""[..]), ("garbage", &b"\0asm\x01\0\0\0\xff\xff\xff"[..]), ("not wasm", &b"MZ\x90\0"[..]), ("truncated", half)]
{
assert!(matches!(Plugin::load(bytes, Limits::default()), Err(Error::Module(_))), "{name}");
}
let tiny = Limits { max_module_bytes: 100, ..Limits::default() };
assert!(matches!(Plugin::load(DESATURATE, tiny), Err(Error::Module(_))));
// Huge initial memory, manifest outside memory, missing exports, bogus allocations.
let huge = build(r#"(module (memory (export "memory") 60000))"#);
assert!(Plugin::load(&huge, Limits::default()).is_err());
let no_run = build(
r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1))
(func (export "vc_manifest") (result i64) (i64.const 0)))"#,
);
assert!(Plugin::load(&no_run, Limits::default()).is_err());
let src = r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1))
(func (export "vc_manifest") (result i64) (i64.const 0x00000064ffffff00))
(func (export "vc_alloc") (param i32) (result i32) (i32.const 16))
(func (export "vc_run") (param i32 i32 i32 i32) (result i64) (i64.const 0)))"#;
assert!(matches!(Plugin::load(&build(src), Limits::default()), Err(Error::Abi(_))));
let bogus_alloc = build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace(
"(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32)",
"(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32) (return (i32.const -16))",
));
let (e, _) = run_err(&bogus_alloc, Limits::default());
assert!(matches!(e, Error::Abi(_)), "{e}");
}
#[test]
fn budgets_stop_runaway_plugins() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, fuel_per_byte: 10, ..Limits::default() });
assert_eq!(e, Error::Limit("instruction budget".into()));
}
#[test]
fn wall_clock_budget_stops_runaway_plugins() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
// Wall-clock budget, with an effectively unlimited instruction budget.
let (e, t) = run_err(&spin, Limits { fuel_base: u64::MAX / 4, wall_time_ms: 300, ..Limits::default() });
assert_eq!(e, Error::Limit("time budget".into()));
assert!(t < Duration::from_secs(3), "{t:?}");
}
/// Cancelling stops a run where one slice of its budget ends: an endless loop given all but
/// unlimited instructions comes back `Cancelled` within seconds, where outside a cancelled scope the
/// same module runs until its budget is spent.
#[test]
fn cancelling_stops_a_run_between_two_slices_of_its_budget() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
let p = Plugin::load(&spin, Limits { fuel_base: u64::MAX / 4, ..Limits::default() }).unwrap();
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let t = Instant::now();
let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}"));
assert_eq!(r, Err(Error::Cancelled));
assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
// Outside a cancelled scope the same module still runs to its budget.
let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, ..Limits::default() });
assert_eq!(e, Error::Limit("instruction budget".into()));
}
/// Compiling a plug-in's code takes none of a run's instructions, so a first run takes as many as the
/// next, though wasmi compiles each function only when it is first called. When the compiling was the
/// run's to pay for, and the slice of the budget the run was in ran out while a function compiled,
/// wasmi ended the run with an error the host can't resume: it failed with most of its budget left.
/// Here `vc_run` calls a function about 100 KB long, a `br_table` of 100,000 labels.
#[test]
fn a_first_run_takes_no_more_instructions_than_the_next() {
let big = format!("(func $big (param $i i32) (block (br_table {}0 (local.get $i))))", "0 ".repeat(100_000));
let calling = module(MANIFEST, &format!("(call $big (i32.const 0)) {}", tpl::ECHO), &big);
let with = |fuel_base| Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() };
let warm = Plugin::load(&calling, Limits::default()).unwrap();
assert_eq!(warm.run(INPUT.as_bytes(), b"{}"), Ok(INPUT.as_bytes().to_vec()));
// The fewest instructions a run takes once the functions are compiled.
let (mut lo, mut hi) = (0, 1 << 20);
assert!(warm.run_limited(INPUT.as_bytes(), b"{}", &with(hi)).is_ok());
while hi - lo > 1 {
let mid = (lo + hi) / 2;
if warm.run_limited(INPUT.as_bytes(), b"{}", &with(mid)).is_ok() { hi = mid } else { lo = mid }
}
let first = Plugin::load(&calling, Limits::default()).unwrap();
assert_eq!(first.run_limited(INPUT.as_bytes(), b"{}", &with(hi)), Ok(INPUT.as_bytes().to_vec()), "a first run within {hi} instructions");
}
/// A filter that asks for a heavy budget gets 20 times the instructions and five times the time of
/// a light one, so it finishes work a light one can't: here 100 million steps of a loop. The
/// budget is its own: what it returns can't add to it, since the host learns that only at the end.
#[test]
fn a_heavy_filter_finishes_what_a_light_one_cannot() {
let heavy = r#"{"id":"test.heavy","name":"Heavy","version":"1","kind":"filter","budget":"heavy"}"#;
let (light, heavy) = (build(&tpl::spinning(MANIFEST, 100_000_000, INPUT)), build(&tpl::spinning(heavy, 100_000_000, INPUT)));
assert_eq!(run_err(&light, Limits::default()).0, Error::Limit("instruction budget".into()));
let p = Plugin::load(&heavy, Limits::default()).unwrap();
assert_eq!((p.limits().fuel_base, p.limits().wall_time_ms), (HEAVY_FUEL, HEAVY_WALL_TIME_MS));
assert_eq!(heavy_terms(), (5, 20));
assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes());
}
#[test]
fn an_exporter_returns_its_file_with_an_export_s_budget() {
use vectorcraft_plugins::export::{self, Target};
let target = Target { extension: "tst", name: "Poster" };
let manifest = tpl::exporter("test.exporter", r#"["tst"]"#, r#"{"level":{"type":"int","min":1,"max":3,"default":2}}"#);
let p = Plugin::load(&build(&tpl::returning(&manifest, r#"{"dataBase64":"SGk=","warnings":["note"]}"#)), Limits::default()).unwrap();
assert_eq!((p.manifest().kind, p.manifest().extensions.clone()), (Kind::Exporter, vec!["tst".to_string()]));
let out = export::run(&p, br#"{"format":"vectorcraft"}"#, &json!({"level": 9}), target).unwrap();
assert_eq!((out.data_base64.as_str(), out.warnings), ("SGk=", vec!["note".to_string()]));
let limits = export::limits(&p);
assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS));
// Its parameters are checked, and a filter is not an exporter.
assert!(matches!(export::run(&p, b"{}", &json!({"level": "high"}), target), Err(Error::Params(_))));
let filter = Plugin::load(&ok_module("(i64.const 0)"), Limits::default()).unwrap();
assert!(matches!(export::run(&filter, b"{}", &Value::Null, target), Err(Error::Params(_))));
}
/// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is
/// its input, or its parameters, byte for byte, whatever their length modulo three.
#[test]
fn the_echo_exporter_writes_what_it_is_given() {
use vectorcraft_plugins::export::{self, Target};
let manifest = tpl::exporter("test.echo", r#"["txt"]"#, "{}");
let input = Plugin::load(&build(&tpl::echo_exporter(&manifest, tpl::Echo::Input)), Limits::default()).unwrap();
let params = Plugin::load(&build(&tpl::echo_exporter(&manifest, tpl::Echo::Params)), Limits::default()).unwrap();
let target = Target { extension: "txt", name: "a" };
let decoded = |out: export::Exported| String::from_utf8(base64_decode(&out.data_base64)).unwrap();
for doc in ["{}", r#"{"a":1}"#, r#"{"ab":12}"#, r#"{"format":"vectorcraft","é":"\u0000"}"#] {
assert_eq!(decoded(export::run(&input, doc.as_bytes(), &Value::Null, target).unwrap()), doc);
}
let sent: Value = serde_json::from_str(&decoded(export::run(&params, b"{}", &Value::Null, target).unwrap())).unwrap();
assert_eq!(sent, json!({"_context": {"mode": "export", "extension": "txt", "name": "a"}}));
}
/// Standard base64 (RFC 4648, padded), decoded here so the test needs no other crate.
fn base64_decode(s: &str) -> Vec<u8> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let sextets: Vec<u32> = s.bytes().filter(|b| *b != b'=').map(|b| ALPHABET.iter().position(|a| *a == b).unwrap() as u32).collect();
let mut out = Vec::new();
for group in sextets.chunks(4) {
let bits = group.iter().enumerate().fold(0u32, |acc, (i, v)| acc | v << (18 - 6 * i));
out.extend(bits.to_be_bytes()[1..].iter().take(group.len() - 1));
}
out
}
#[test]
fn memory_cap_limits_input_and_growth() {
let echo = build(&tpl::module(MANIFEST, tpl::ECHO, "", 1));
let p = Plugin::load(&echo, Limits { max_memory_bytes: 1 << 20, ..Limits::default() }).unwrap();
assert!(p.run(&vec![b' '; 300 << 10], b"{}").is_ok(), "fits in half the memory");
assert!(matches!(p.run(&vec![b' '; 600 << 10], b"{}"), Err(Error::Limit(_))), "doesn't");
// Growing past the cap fails inside the module (memory.grow returns -1): vc_alloc gives 0.
let p = Plugin::load(&echo, Limits { max_memory_bytes: 64 << 10, ..Limits::default() }).unwrap();
assert!(matches!(p.run(b"{}", b"{}"), Err(Error::Failed(m)) if m.contains("vc_alloc")));
}
#[test]
fn registry_install_list_remove() {
// Its own id: the registry is process-wide and tests run in parallel.
let id = "test.registry";
let p = registry::install_bytes(&module(r#"{"id":"test.registry","name":"R","kind":"filter"}"#, "(i64.const 0)", "")).unwrap();
assert_eq!(p.id(), id);
let rev = registry::revision();
assert!(registry::list().iter().any(|p| p.id() == id));
assert!(registry::get(id).is_some());
assert!(registry::remove(id));
assert!(!registry::remove(id));
assert!(registry::get(id).is_none());
assert!(registry::revision() > rev);
assert!(std::ptr::eq(registry::intern("Abc"), registry::intern("Abc")), "interned once");
}
#[test]
fn folder_loading_skips_bad_files() {
let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a-desaturate.wasm"), DESATURATE).unwrap();
std::fs::write(dir.join("b-broken.wasm"), b"\0asm junk").unwrap();
std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
let r = registry::load_folder(&dir).unwrap();
assert_eq!(r.loaded, ["org.vectorcraft.example.desaturate"]);
assert_eq!(r.failed.len(), 1);
assert!(r.failed[0].0.ends_with("b-broken.wasm"));
assert!(registry::load_folder(&dir.join("missing")).is_err());
assert!(registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
std::fs::remove_dir_all(&dir).ok();
}
/// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped
/// and reported.
#[test]
fn folder_loading_skips_heavy_filters() {
let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-heavy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let manifest = r#"{"id":"test.folder.heavy","name":"Heavy","kind":"filter","budget":"heavy"}"#;
std::fs::write(dir.join("heavy.wasm"), build(&tpl::spinning(manifest, 1, INPUT))).unwrap();
let r = registry::load_folder(&dir).unwrap();
assert!(r.loaded.is_empty());
assert!(r.failed.len() == 1 && r.failed[0].1.contains("heavy budget"), "{:?}", r.failed);
assert!(registry::get("test.folder.heavy").is_none());
std::fs::remove_dir_all(&dir).ok();
}