mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
* fix(plugins): the install error names the format the file actually is (#10362) Plug-ins > Install Plug-in pointed at an old Photoshop plug-in (.8bt) answered with "not a valid plug-in module: not a WebAssembly binary (missing \0asm header)". That was one fixed message for every file that is not wasm: it said what we wanted and nothing about the file the person is holding. format_hint in runtime.rs now reads the magic number and the error says what the file looks like. For a Windows module the message is (one line in the code, wrapped here): not a valid plug-in module: not a WebAssembly binary (missing \0asm header); this file looks like a Windows native executable or library (a .dll, or a Photoshop plug-in such as .8bf/.8bt) - PhotoCraft plug-ins are WebAssembly modules (.wasm) 7F 45 4C 46 says "a Linux native library (ELF)", CE FA ED FE / CF FA ED FE / CA FE BA BE / BE BA FE CA say "a macOS native binary (Mach-O, thin or fat)", 50 4B 03 04 / 50 4B 05 06 say "a zip archive". Any other header keeps today's message byte for byte, so nothing keyed on it changes. The signatures are public format facts, read with starts_with/get so a short or hostile file cannot panic. The error variant, the enum, the ABI check, check_code_section and the wasmi setup are untouched, and native-code loading stays out of scope (PR #7907). Fail-first, before the production change: cargo test -p photocraft-plugins --lib --tests lib: test result: ok. 10 passed; 0 failed abi: test result: FAILED. 19 passed; 1 failed; 0 ignored; 0 measured assertion line: "windows dll or .8bt: not a valid plug-in module: not a WebAssembly binary (missing \0asm header)" (the MZ fixture that was already in the file) cargo test -p photocraft-engine --lib -- plugin test result: FAILED. 7 passed; 1 failed; 0 ignored; 0 measured; 2518 filtered out After: cargo test -p photocraft-plugins lib 10 passed; abi 20 passed; doc-tests 0 cargo test -p photocraft-engine --lib -- plugin test result: ok. 8 passed; 0 failed; 2518 filtered out The new engine test drives the real plugin.install command with a `path` whose file starts with MZ bytes, so it covers the menu path (registry::load_file) end to end, not only the library check. Gates: cargo clippy -p photocraft-plugins --all-targets -- -D warnings and cargo clippy -p photocraft-engine --all-targets -- -D warnings (clean; the only stderr noise is the pre-existing "patch `egui_*` was not used in the crate graph" cargo warnings); cargo xtask layers ("OK: 31 crates, no layering violations."); cargo xtask wasm (" ok 24 crates", " ok photocraft- codecs --features heif", no FAIL line); cargo fmt -p photocraft-plugins -- --check and cargo fmt -p photocraft-engine -- --check (silent, exit 0). The whole engine --lib suite is not run here: it fails on clean main in this checkout for font/i18n reasons (no CRAFT_FONTS_DIR). No doc quoted the old message (git grep "WebAssembly binary" finds only this crate), so nothing to update there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(plugins): one install-error test per recognised format, tempfile in the engine test (#10362) Review feedback on #10633. The single looped test in abi.rs is now one test per signature (PE/COFF MZ, ELF, Mach-O in all four byte orders, zip), one for an empty or unrecognised file (including signatures cut short, which keep the old message byte for byte) and one showing a valid WebAssembly module still loads. The engine test uses tempfile::tempdir (already a dependency of photocraft-engine) so the directory is removed on a panic, and asserts the message does not contain the temp path: the hint is static text, so the message adds no path. Gates: cargo test -p photocraft-plugins --lib --tests (10 lib, 26 tests); cargo test -p photocraft-engine --lib -- plugin (8 passed); clippy -D warnings on both crates; cargo fmt --all -- --check; cargo xtask layers (OK). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(engine): keep the non-wasm install-error test off wasm32, as tempfile is (#10362) tempfile is a non-wasm32 dependency of photocraft-engine, so the test that uses it is gated like the crate's other file-system tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
23 KiB
Rust
475 lines
23 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}");
|
||
}
|
||
|
||
/// The message `Plugin::load` gives a file that is not WebAssembly and not recognised (#10362).
|
||
const NOT_WASM: &str = "not a valid plug-in module: not a WebAssembly binary (missing \\0asm header)";
|
||
|
||
/// Loads `bytes`, which must be refused as a module, and returns the message.
|
||
fn refusal(bytes: &[u8]) -> String {
|
||
let Err(e) = Plugin::load(bytes, Limits::default()) else { panic!("{bytes:?} was accepted") };
|
||
assert!(matches!(e, Error::Module(_)), "{e}");
|
||
e.to_string()
|
||
}
|
||
|
||
/// The message names the format (`format`), says plug-ins are WebAssembly, and stays on one line
|
||
/// (`Plugin::load` has only bytes, so the hint cannot carry a path).
|
||
fn assert_names(bytes: &[u8], format: &str) {
|
||
let m = refusal(bytes);
|
||
assert!(m.starts_with(NOT_WASM), "{m}");
|
||
assert!(m.contains(format), "{m}");
|
||
assert!(m.contains("PhotoCraft plug-ins are WebAssembly modules"), "{m}");
|
||
assert!(!m.contains('\n'), "{m}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_pe_coff_file_is_named_as_a_windows_binary() {
|
||
// #10362: an old Photoshop plug-in (.8bt) is a native Windows module.
|
||
assert_names(b"MZ\x90\x00\x03\0", "Windows");
|
||
assert!(refusal(b"MZ").contains("8bf"));
|
||
}
|
||
|
||
#[test]
|
||
fn an_elf_file_is_named_as_a_linux_library() {
|
||
assert_names(&[0x7f, b'E', b'L', b'F', 2, 1, 1], "Linux");
|
||
}
|
||
|
||
#[test]
|
||
fn a_mach_o_file_is_named_as_a_macos_binary() {
|
||
for head in [[0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xbe, 0xba, 0xfe, 0xca]] {
|
||
assert_names(&[&head[..], &[7, 1][..]].concat(), "macOS");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_zip_file_is_named_as_a_zip_archive() {
|
||
assert_names(b"PK\x03\x04\x14\0", "zip");
|
||
assert_names(b"PK\x05\x06\0\0", "zip");
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_or_unrecognised_file_keeps_the_plain_message() {
|
||
// Nothing recognised (including a signature cut short): today's message, exactly.
|
||
for bytes in [&b""[..], &b"x"[..], &b"junkjunk"[..], &[0x7f, b'E'][..], &b"PK"[..]] {
|
||
assert_eq!(refusal(bytes), NOT_WASM, "{bytes:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_valid_wasm_module_still_loads_unchanged() {
|
||
assert!(Plugin::load(INVERT, Limits::default()).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn a_code_section_claiming_no_functions_but_many_bytes_is_refused() {
|
||
// Header, then a code section (id 10) declaring 2000 bytes whose function count is 0, cut off
|
||
// after the count (a truncated download) or padded to its full size. The spec makes the
|
||
// count's own byte the whole section; wasmi's strict limits divided the size by the count.
|
||
let truncated = b"\0asm\x01\0\0\0\x0a\xd0\x0f\x00".to_vec();
|
||
let mut padded = truncated.clone();
|
||
padded.resize(padded.len() + 1999, 0);
|
||
// The same section after a valid empty one: a duplicate reaches the division before validation.
|
||
let mut second = b"\0asm\x01\0\0\0\x0a\x01\x00".to_vec();
|
||
second.extend_from_slice(&padded[8..]);
|
||
for (name, bytes) in [("truncated", truncated), ("padded", padded), ("second", second)] {
|
||
assert!(matches!(Plugin::load(&bytes, Limits::default()), Err(Error::Module(_))), "{name}");
|
||
}
|
||
// An empty code section (count 0, nothing after it) is valid; it fails only for the missing ABI exports.
|
||
let empty = b"\0asm\x01\0\0\0\x0a\x01\x00";
|
||
assert!(matches!(Plugin::load(empty, Limits::default()), Err(Error::Abi(_))));
|
||
}
|
||
|
||
#[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();
|
||
}
|
||
|
||
/// #8603: a run whose memory budget holds a single instance still covers every band on a
|
||
/// many-worker pool, one band at a time, with the same pixels as a run of parallel bands.
|
||
#[test]
|
||
fn one_band_at_a_time_matches_parallel_bands() {
|
||
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
|
||
let s = surface(PixelFormat::RGBA8, 600, 300);
|
||
let canvas = Rect::new(0, 0, 600, 300);
|
||
let run = |limits: Limits| {
|
||
let p = Plugin::load(INVERT, limits).unwrap();
|
||
pool.install(|| p.apply(&s, canvas, None, &json!({}))).unwrap()
|
||
};
|
||
let parallel = run(Limits { band_bytes: 1, ..Limits::default() });
|
||
let serial = run(Limits { band_bytes: 1, max_total_memory_bytes: 512 << 20, ..Limits::default() });
|
||
assert_eq!(serial, parallel);
|
||
assert_ne!(serial, s, "the filter ran");
|
||
}
|
||
|
||
/// #8603: one run on 16 workers must not hold 16 instances at the 512 MiB cap (8 GiB). With the
|
||
/// default limits it holds at most four (2 GiB); a smaller per-instance cap lets every worker
|
||
/// run, and a budget under one cap still runs one band at a time rather than none.
|
||
#[test]
|
||
fn parallel_bands_fit_the_total_memory_budget() {
|
||
let d = Limits::default();
|
||
assert_eq!(d.parallel_bands(16), 4);
|
||
assert!(d.parallel_bands(64) * d.max_memory_bytes <= d.max_total_memory_bytes);
|
||
assert_eq!(d.parallel_bands(2), 2, "fewer workers than the budget allows");
|
||
let small = Limits { max_memory_bytes: 64 << 20, ..Limits::default() };
|
||
assert_eq!(small.parallel_bands(16), 16, "small instances keep every worker busy");
|
||
let tight = Limits { max_total_memory_bytes: 1 << 20, ..Limits::default() };
|
||
assert_eq!(tight.parallel_bands(16), 1, "at least one band runs");
|
||
assert_eq!(d.parallel_bands(0), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_start_function_that_spins_is_cut_off_at_one_slice() {
|
||
// `fuel_base` is what a call may use; the start function cannot be interrupted to check the
|
||
// clock, so it gets one slice of it at most.
|
||
let spin = "(func $spin (loop $l (br $l))) (start $spin)";
|
||
let limits = Limits { fuel_base: u64::MAX / 4, ..Limits::default() };
|
||
let (error, took) = run_err(&module(MANIFEST, 1, "(i32.const 0)", spin), limits);
|
||
assert!(matches!(error, Error::Trap(_) | Error::Limit(_)), "{error:?}");
|
||
assert!(took < Duration::from_secs(30), "{took:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn content_spread_over_millions_of_bands_is_refused_before_planning_them() {
|
||
let p = Plugin::load(INVERT, Limits::default()).unwrap();
|
||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||
s.write_pixel(0, 0, &[0.1, 0.2, 0.3, 1.0]);
|
||
s.write_pixel(4_000_000, 4_000_000, &[0.1, 0.2, 0.3, 1.0]);
|
||
let t = Instant::now();
|
||
let e = p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({})).expect_err("a 15600 × 15600 tile grid is not one run");
|
||
assert!(matches!(e, Error::Limit(_)), "{e:?}");
|
||
assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn a_fifo_in_place_of_a_module_file_does_not_block_loading() {
|
||
let dir = std::env::temp_dir().join(format!("photocraft-plugin-fifo-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("evil.wasm");
|
||
let _ = std::fs::remove_file(&path);
|
||
// `rustix::fs::mknodat` is unavailable on Apple targets. `mkfifo` is the
|
||
// POSIX utility for creating this special file and keeps this regression
|
||
// test available on macOS as well as other Unix platforms.
|
||
let status = std::process::Command::new("mkfifo").arg(&path).status().unwrap();
|
||
assert!(status.success(), "mkfifo failed with {status}");
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
let p = path.clone();
|
||
std::thread::spawn(move || {
|
||
let _ = tx.send(photocraft_plugins::registry::load_file(&p, Limits::default()).map(|_| ()));
|
||
});
|
||
let r = rx.recv_timeout(Duration::from_secs(10)).expect("loading a FIFO must not block");
|
||
assert!(matches!(r, Err(Error::Io(_))), "{r:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|