mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 11:18:11 +02:00
When one band failed, its siblings stopped with a generic "another band failed" error, which could win the collection order and hide the real cause. The abort flag now carries the first error, so every band reports it; tests cover traps, error codes and the instruction budget across parallel bands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
16 KiB
Rust
320 lines
16 KiB
Rust
//! The plug-in ABI end to end: the example Invert plug-in, and hostile modules (infinite loops,
|
|
//! memory bombs, out-of-bounds access, bad manifests…), which must all fail with an error, quickly.
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use photocraft_color::{PixelFormat, SampleType};
|
|
use photocraft_geom::Rect;
|
|
use photocraft_plugins::{Error, Limits, Plugin};
|
|
use photocraft_raster::Surface;
|
|
use serde_json::json;
|
|
|
|
const INVERT: &[u8] = include_bytes!("fixtures/invert.wasm");
|
|
|
|
/// A module implementing the ABI whose pieces can be swapped: `manifest` JSON, `version`,
|
|
/// `filter` body (params `$buf $len $w $h $ch $fmt $pp $pl`, result i32) and extra items.
|
|
fn module(manifest: &str, version: i32, filter: &str, extra: &str) -> Vec<u8> {
|
|
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
|
|
let src = format!(
|
|
r#"(module
|
|
{extra}
|
|
(memory (export "memory") 1)
|
|
(data (i32.const 16) "{escaped}")
|
|
(func (export "pc_abi_version") (result i32) (i32.const {version}))
|
|
(func (export "pc_manifest") (result i64)
|
|
(i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16)))
|
|
(func (export "pc_alloc") (param $n i32) (result i32) (local $old i32)
|
|
(local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16))))
|
|
(if (result i32) (i32.eq (local.get $old) (i32.const -1))
|
|
(then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536)))))
|
|
(func (export "pc_filter") (param $buf i32) (param $len i32) (param $w i32) (param $h i32)
|
|
(param $ch i32) (param $fmt i32) (param $pp i32) (param $pl i32) (result i32)
|
|
{filter})
|
|
)"#,
|
|
len = manifest.len()
|
|
);
|
|
wat::parse_str(&src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}"))
|
|
}
|
|
|
|
const MANIFEST: &str = r#"{"id":"test.plugin","name":"Test","version":"1","kind":"filter"}"#;
|
|
|
|
fn ok_module(filter: &str) -> Vec<u8> {
|
|
module(MANIFEST, 1, filter, "")
|
|
}
|
|
|
|
fn surface(fmt: PixelFormat, w: i32, h: i32) -> Surface {
|
|
let mut s = Surface::new(fmt);
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let v = ((x * 7 + y * 13) % 31) as f32 / 30.0;
|
|
let a = if (x + y) % 9 == 0 { 0.0 } else { 0.25 + 0.75 * ((x % 4) as f32 / 3.0) };
|
|
s.write_pixel(x, y, &[v, 1.0 - v, (x % 5) as f32 / 4.0, a]);
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Runs `bytes` as a filter on a small image; 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| {
|
|
let s = surface(PixelFormat::RGBA8, 32, 24);
|
|
p.apply(&s, Rect::new(0, 0, 32, 24), None, &json!({}))
|
|
});
|
|
(r.expect_err("hostile module must fail"), t.elapsed())
|
|
}
|
|
|
|
#[test]
|
|
fn example_invert_loads_and_reports_its_manifest() {
|
|
let p = Plugin::load(INVERT, Limits::default()).unwrap();
|
|
let m = p.manifest();
|
|
assert_eq!(m.id, "org.photocraft.example.invert");
|
|
assert_eq!(m.name, "Invert (WebAssembly)");
|
|
assert_eq!(m.kind, photocraft_plugins::Kind::Filter);
|
|
assert!(m.params.is_empty());
|
|
assert!(p.size() < 4096, "the example stays tiny: {} bytes", p.size());
|
|
}
|
|
|
|
#[test]
|
|
fn example_invert_inverts_colour_at_every_depth() {
|
|
let p = Plugin::load(INVERT, Limits::default()).unwrap();
|
|
for sample in SampleType::ALL {
|
|
let fmt = PixelFormat { sample, ..PixelFormat::RGBA8 };
|
|
let s = surface(fmt, 40, 30);
|
|
let out = p.apply(&s, Rect::new(0, 0, 40, 30), None, &json!({})).unwrap();
|
|
for (x, y) in [(1, 1), (0, 0), (17, 3), (39, 29)] {
|
|
let (a, b) = (s.pixel(x, y), out.pixel(x, y));
|
|
if a[3] <= 0.0 {
|
|
assert_eq!(a, b, "transparent pixels are left alone");
|
|
continue;
|
|
}
|
|
for c in 0..3 {
|
|
assert!((b[c] - (1.0 - a[c])).abs() < 1e-6, "{sample:?} ({x},{y}) c{c}: {a:?} -> {b:?}");
|
|
}
|
|
assert_eq!(a[3], b[3], "alpha is kept");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bands_are_seamless_and_selection_blends() {
|
|
// Tiny bands: many instances, same result as one band.
|
|
let small = Limits { band_bytes: 40 * 4 * 4 * 3, ..Limits::default() };
|
|
let p1 = Plugin::load(INVERT, Limits::default()).unwrap();
|
|
let p2 = Plugin::load(INVERT, small).unwrap();
|
|
let s = surface(PixelFormat::RGBA16, 40, 30);
|
|
let canvas = Rect::new(0, 0, 40, 30);
|
|
assert_eq!(p1.apply(&s, canvas, None, &json!({})).unwrap(), p2.apply(&s, canvas, None, &json!({})).unwrap());
|
|
|
|
// A half-strength selection over the left half.
|
|
let mut sel = Surface::new(PixelFormat::GRAY8);
|
|
sel.fill_rect(Rect::new(0, 0, 20, 30), &[1.0]);
|
|
sel.fill_rect(Rect::new(0, 0, 20, 10), &[0.5]);
|
|
let out = p1.apply(&s, canvas, Some(&sel), &json!({})).unwrap();
|
|
assert_eq!(out.pixel(30, 20), s.pixel(30, 20), "outside the selection");
|
|
let (a, b) = (s.pixel(4, 20), out.pixel(4, 20));
|
|
assert!((b[0] - (1.0 - a[0])).abs() < 1e-4, "fully selected");
|
|
let (a, b) = (s.pixel(5, 5), out.pixel(5, 5));
|
|
let k = sel.pixel(5, 5)[0];
|
|
let half = a[0] + (1.0 - a[0] - a[0]) * k;
|
|
assert!((b[0] - half).abs() < 2.0 / 65535.0, "half selected: {a:?} {b:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn params_and_image_context_reach_the_plugin() {
|
|
// Writes params_len / 1000 into every sample, so the host must pass the JSON through.
|
|
let fill = r#"(local $i i32)
|
|
(block $done (loop $l
|
|
(br_if $done (i32.ge_u (local.get $i) (local.get $len)))
|
|
(f32.store (i32.add (local.get $buf) (local.get $i))
|
|
(f32.div (f32.convert_i32_u (local.get $pl)) (f32.const 1000)))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 4)))
|
|
(br $l)))
|
|
(i32.const 0)"#;
|
|
let man = r#"{"id":"test.params","name":"P","kind":"filter","params":{"amount":{"type":"number","min":0,"max":10,"default":5}}}"#;
|
|
let p = Plugin::load(&module(man, 1, fill, ""), Limits::default()).unwrap();
|
|
assert_eq!(p.manifest().params_notation(), r#"{"amount":0..10=5}"#);
|
|
let s = surface(PixelFormat::RGBA32F, 8, 8);
|
|
let out = p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({"amount": 3})).unwrap();
|
|
let v = out.pixel(1, 1)[0] * 1000.0;
|
|
assert!(v > 100.0 && v < 600.0, "params JSON length {v}");
|
|
// Wrong-typed params are refused before the plug-in runs.
|
|
assert!(matches!(p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({"amount": "x"})), Err(Error::Params(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn nan_and_out_of_range_output_is_sanitised() {
|
|
// Fills the buffer with NaN: every pixel keeps its value.
|
|
let nan = r#"(local $i i32)
|
|
(block $done (loop $l
|
|
(br_if $done (i32.ge_u (local.get $i) (local.get $len)))
|
|
(f32.store (i32.add (local.get $buf) (local.get $i)) (f32.const nan))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 4)))
|
|
(br $l)))
|
|
(i32.const 0)"#;
|
|
let p = Plugin::load(&ok_module(nan), Limits::default()).unwrap();
|
|
let s = surface(PixelFormat::RGBA32F, 8, 8);
|
|
assert_eq!(p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({})).unwrap(), s);
|
|
}
|
|
|
|
#[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)) (i32.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)) (i32.const 0)")),
|
|
("out-of-bounds write", ok_module("(i32.store (i32.const -4) (i32.const 0)) (i32.const 0)")),
|
|
("trap", ok_module("unreachable")),
|
|
("division by zero", ok_module("(i32.div_u (i32.const 1) (local.get $ch)) (drop) (i32.div_u (i32.const 1) (i32.const 0))")),
|
|
("deep recursion", module(MANIFEST, 1, "(call $r) (i32.const 0)", "(func $r (call $r))")),
|
|
("error code", ok_module("(i32.const 7)")),
|
|
("wrong ABI version", module(MANIFEST, 2, "(i32.const 0)", "")),
|
|
("bad manifest", module("not json", 1, "(i32.const 0)", "")),
|
|
("manifest with a bad id", module(r#"{"id":"a b","name":"x","kind":"filter"}"#, 1, "(i32.const 0)", "")),
|
|
("manifest of the wrong kind", module(r#"{"id":"a","name":"x","kind":"panel"}"#, 1, "(i32.const 0)", "")),
|
|
("start function loops", module(MANIFEST, 1, "(i32.const 0)", "(func $spin (loop $l (br $l))) (start $spin)")),
|
|
("WASI import", module(MANIFEST, 1, "(i32.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 = &INVERT[..INVERT.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(INVERT, tiny), Err(Error::Module(_))));
|
|
// Huge initial memory, manifest outside memory, missing exports, bogus allocations.
|
|
let huge = wat::parse_str(r#"(module (memory (export "memory") 60000))"#).unwrap();
|
|
assert!(Plugin::load(&huge, Limits::default()).is_err());
|
|
let no_filter = wat::parse_str(
|
|
r#"(module (memory (export "memory") 1) (func (export "pc_abi_version") (result i32) (i32.const 1))
|
|
(func (export "pc_manifest") (result i64) (i64.const 0)))"#,
|
|
)
|
|
.unwrap();
|
|
assert!(Plugin::load(&no_filter, Limits::default()).is_err());
|
|
let src = r#"(module (memory (export "memory") 1) (func (export "pc_abi_version") (result i32) (i32.const 1))
|
|
(func (export "pc_manifest") (result i64) (i64.const 0x00000064ffffff00))
|
|
(func (export "pc_alloc") (param i32) (result i32) (i32.const 16))
|
|
(func (export "pc_filter") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) (i32.const 0)))"#;
|
|
assert!(matches!(Plugin::load(&wat::parse_str(src).unwrap(), Limits::default()), Err(Error::Abi(_))));
|
|
let bogus_alloc = wat::parse_str(format!(
|
|
r#"(module (memory (export "memory") 1) (data (i32.const 16) "{}")
|
|
(func (export "pc_abi_version") (result i32) (i32.const 1))
|
|
(func (export "pc_manifest") (result i64) (i64.const {}))
|
|
(func (export "pc_alloc") (param i32) (result i32) (i32.const -16))
|
|
(func (export "pc_filter") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) (i32.const 0)))"#,
|
|
MANIFEST.replace('"', "\\\""),
|
|
((MANIFEST.len() as i64) << 32) | 16
|
|
))
|
|
.unwrap();
|
|
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)) (i32.const 0)");
|
|
// Instruction budget.
|
|
let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, fuel_per_sample: 10, ..Limits::default() });
|
|
assert_eq!(e, Error::Limit("instruction budget".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn parallel_bands_preserve_the_originating_error() {
|
|
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
|
|
let mut s = Surface::new(PixelFormat::RGBA8);
|
|
s.fill_rect(Rect::new(0, 0, 256, 256), &[0.0, 0.0, 0.0, 1.0]);
|
|
s.fill_rect(Rect::new(0, 256, 256, 512), &[1.0, 0.0, 0.0, 1.0]);
|
|
for (body, expected) in [
|
|
("(i32.const 7)", Error::Failed("pc_filter returned error code 7".into())),
|
|
("unreachable", Error::Trap("wasm `unreachable` instruction executed".into())),
|
|
] {
|
|
// The later band fails immediately while the earlier one is still running (or hasn't
|
|
// started). Collecting in band order must not turn that failure into sibling cancellation.
|
|
let filter = format!(
|
|
"(if (result i32) (f32.gt (f32.load (local.get $buf)) (f32.const 0.5)) \
|
|
(then {body}) (else (loop $spin (br $spin)) (i32.const 0)))"
|
|
);
|
|
let limits = Limits { band_bytes: 1, fuel_base: 100_000_000, fuel_per_sample: 0, ..Limits::default() };
|
|
let p = Plugin::load(&ok_module(&filter), limits).unwrap();
|
|
let before = s.clone();
|
|
let e = pool.install(|| p.apply(&s, Rect::new(0, 0, 256, 512), None, &json!({}))).unwrap_err();
|
|
assert_eq!(e, expected);
|
|
assert_eq!(s, before, "a failed run must not change the input");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parallel_bands_preserve_instruction_budget() {
|
|
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
|
let p = Plugin::load(
|
|
&ok_module("(loop $spin (br $spin)) (i32.const 0)"),
|
|
Limits { band_bytes: 1, fuel_base: 1_000_000, fuel_per_sample: 0, ..Limits::default() },
|
|
)
|
|
.unwrap();
|
|
let mut s = Surface::new(PixelFormat::RGBA8);
|
|
s.fill_rect(Rect::new(0, 0, 768, 512), &[0.25, 0.5, 0.75, 1.0]);
|
|
for _ in 0..8 {
|
|
let e = pool.install(|| p.apply(&s, Rect::new(0, 0, 768, 512), None, &json!({}))).unwrap_err();
|
|
assert_eq!(e, Error::Limit("instruction budget".into()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wall_clock_budget_stops_runaway_plugins() {
|
|
let spin = ok_module("(loop $l (br $l)) (i32.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:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn memory_cap_limits_bands() {
|
|
// Memory cap: a band that can't fit is refused up front.
|
|
let s = surface(PixelFormat::RGBA8, 64, 8);
|
|
let p = Plugin::load(INVERT, Limits { max_memory_bytes: 4 << 20, band_bytes: 64 << 20, ..Limits::default() }).unwrap();
|
|
assert!(p.apply(&s, Rect::new(0, 0, 64, 8), None, &json!({})).is_ok(), "small bands still fit");
|
|
let p = Plugin::load(INVERT, Limits { max_memory_bytes: 1024, ..Limits::default() });
|
|
assert!(p.is_err() || p.unwrap().apply(&s, Rect::new(0, 0, 64, 8), None, &json!({})).is_err());
|
|
}
|
|
|
|
#[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 = photocraft_plugins::registry::install_bytes(&module(r#"{"id":"test.registry","name":"R","kind":"filter"}"#, 1, "(i32.const 0)", "")).unwrap();
|
|
assert_eq!(p.id(), id);
|
|
let rev = photocraft_plugins::registry::revision();
|
|
assert!(photocraft_plugins::registry::list().iter().any(|p| p.id() == id));
|
|
assert!(photocraft_plugins::registry::get(id).is_some());
|
|
assert!(photocraft_plugins::registry::remove(id));
|
|
assert!(!photocraft_plugins::registry::remove(id));
|
|
assert!(photocraft_plugins::registry::get(id).is_none());
|
|
assert!(photocraft_plugins::registry::revision() > rev);
|
|
}
|
|
|
|
#[test]
|
|
fn folder_loading_skips_bad_files() {
|
|
let dir = std::env::temp_dir().join(format!("photocraft-plugins-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("a-invert.wasm"), INVERT).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 = photocraft_plugins::registry::load_folder(&dir).unwrap();
|
|
assert_eq!(r.loaded, ["org.photocraft.example.invert"]);
|
|
assert_eq!(r.failed.len(), 1);
|
|
assert!(r.failed[0].0.ends_with("b-broken.wasm"));
|
|
assert!(photocraft_plugins::registry::load_folder(&dir.join("missing")).is_err());
|
|
assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|