Files
photocraft/crates/plugins/tests/abi.rs
T
Hartmut NörenbergandClaude Opus 5.5 e16450d4e2 fix(plugins): the install error names the format the file actually is (#10362) (#10633)
* 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>
2026-11-03 15:22:58 +01:00

475 lines
23 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.
//! 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);
}