effectcraft/crates/plugin/src/tests.rs

320 lines
15 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.

//! WebAssembly plug-ins: load, register, render through the effect registry, sandbox limits.
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, Image};
use effectcraft_project::build::Ids;
use crate::load_wasm;
fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> String {
match r {
Err(e) => e,
Ok(s) => panic!("unexpectedly loaded {}", s.id),
}
}
/// The gain plug-in's manifest.
fn gain_manifest(id: &str) -> String {
format!(
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
)
}
/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`.
fn gain_module(id: &str, render_body: &str) -> String {
module_with(&gain_manifest(id), render_body)
}
/// The gain module with its own manifest JSON.
fn module_with(manifest: &str, render_body: &str) -> String {
let len = manifest.len();
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const 1)
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param $n i32) (result i32)
(local $need i32)
local.get $n
i32.const 131071
i32.add
i32.const 16
i32.shr_u
memory.size
i32.sub
local.tee $need
i32.const 0
i32.gt_s
if
local.get $need
memory.grow
drop
end
i32.const 65536)
(func (export "ec_render") (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) (param $t f64) (param $scale f64) (result i32)
(local $i i32) (local $end i32) (local $g f32) (local $on f64)
{render_body}
local.get $params
f64.load
f32.demote_f64
local.set $g
local.get $params
f64.load offset=8
local.set $on
local.get $px
local.get $w
local.get $h
i32.mul
i32.const 16
i32.mul
i32.add
local.set $end
local.get $px
local.set $i
block $done
loop $l
local.get $i
local.get $end
i32.ge_u
br_if $done
local.get $i
local.get $i
f32.load
local.get $g
f32.mul
f32.store
local.get $i
local.get $i
f32.load offset=4
local.get $g
f32.mul
f32.store offset=4
local.get $i
local.get $i
f32.load offset=8
local.get $g
f32.mul
local.get $on
local.get $params
f64.load offset=32
f64.mul
f32.demote_f64
f32.add
f32.store offset=8
local.get $i
i32.const 16
i32.add
local.set $i
br $l
end
end
i32.const 0))"#
)
}
fn render(spec: &effectcraft_effects::EffectSpec, gain: f64, on: bool) -> Image {
let mut next = 1;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [4.0, 2.0]);
let mut params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
params.values.insert("gain".into(), effectcraft_keyframe::Value::Scalar(gain));
params.values.insert("on".into(), effectcraft_keyframe::Value::Bool(on));
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let buf = Buf { img: Image::filled(4, 2, [0.8, 0.6, 0.2, 1.0]), offset: [0.0; 2], scale: 1.0 };
(spec.render)(&ctx, buf).img
}
#[test]
fn wasm_plugin_registers_and_renders() {
let spec = load_wasm(gain_module("org.test.gain", "").as_bytes(), "gain.wat").unwrap();
assert_eq!(spec.id, "org.test.gain");
assert_eq!(spec.category, "Test Plug-ins");
assert_eq!(spec.params.len(), 3);
// Found like a built-in, listed under its own category.
assert!(effectcraft_effects::find("org.test.gain").is_some());
assert!(effectcraft_effects::lookup("Gain org.test.gain").is_some());
assert!(effectcraft_effects::all().iter().any(|e| e.id == "org.test.gain"));
assert!(effectcraft_effects::categories().contains(&"Test Plug-ins"));
let img = render(spec, 0.5, false);
assert_eq!(img.get(1, 1), [0.4, 0.3, 0.1, 1.0]);
// The checkbox and colour parameters arrive flattened (tint blue = 1 → +1 on blue).
let img = render(spec, 1.0, true);
assert_eq!(img.get(3, 0), [0.8, 0.6, 1.2, 1.0]);
// Deterministic, and safe to run on many threads at once (each takes an instance).
let a: Vec<Image> = (0..8).map(|_| render(spec, 0.25, false)).collect();
std::thread::scope(|sc| {
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| render(spec, 0.25, false))).collect();
for h in hs {
assert_eq!(h.join().unwrap(), a[0]);
}
});
assert!(a.iter().all(|i| *i == a[0]));
// The same id can't be registered twice.
assert!(load_err(load_wasm(gain_module("org.test.gain", "").as_bytes(), "again.wat")).contains("already registered"));
}
#[test]
fn wasm_plugins_are_sandboxed() {
// A runaway loop runs out of fuel: the frame comes back unchanged instead of hanging.
let spec = load_wasm(gain_module("org.test.spin", "(loop $spin br $spin)").as_bytes(), "spin.wat").unwrap();
let img = render(spec, 0.5, false);
assert_eq!(img.get(0, 0), [0.8, 0.6, 0.2, 1.0]);
// No imports (files, clock, network…).
let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#;
assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import"));
// Garbage, wrong API version, bad manifests.
assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err());
let v2 = gain_module("org.test.v2", "")
.replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)");
assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2"));
let reserved = gain_module("ec.blur.mine", "");
assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved"));
}
/// `base` with `from` replaced by `to` (which must occur).
fn edit(base: &str, from: &str, to: &str) -> String {
assert!(base.contains(from), "{from}");
base.replace(from, to)
}
#[test]
fn abi_violations_fail_to_load_with_reasons() {
let base = gain_module("org.test.abi", "");
// Missing exports.
for export in ["ec_render", "ec_alloc", "ec_manifest_ptr", "ec_manifest_len", "ec_api_version"] {
let m = edit(&base, &format!("(export \"{export}\")"), "");
let e = load_err(load_wasm(m.as_bytes(), "x.wat"));
assert!(e.contains(export), "{export}: {e}");
}
let m = edit(&base, "(memory (export \"memory\") 2)", "(memory 2)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("memory"));
// Wrong signatures.
let m = edit(&base, "(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result f64) f64.const 1)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_api_version"));
let m = edit(&base, "(param $scale f64) (result i32)", "(param $scale f32) (result i32)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_render"));
// API versions other than 1.
for v in ["0", "-1", "2147483647"] {
let m = edit(
&base,
"(func (export \"ec_api_version\") (result i32) i32.const 1)",
&format!("(func (export \"ec_api_version\") (result i32) i32.const {v})"),
);
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("API {v}")), "{v}");
}
// Manifest out of bounds, negative or absurd lengths, not JSON.
let ptr = "(func (export \"ec_manifest_ptr\") (result i32) i32.const 16)";
let m = edit(&base, ptr, "(func (export \"ec_manifest_ptr\") (result i32) i32.const 200000)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("out of bounds"));
let len = gain_manifest("org.test.abi").len();
for bad in ["-5", "2000000000"] {
let m = edit(&base, &format!("(result i32) i32.const {len})"), &format!("(result i32) i32.const {bad})"));
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("manifest length"), "{bad}");
}
let m = edit(&base, ptr, "(func (export \"ec_manifest_ptr\") (result i32) i32.const 4000)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("manifest JSON"));
// Traps and runaway code while loading.
let version = "(func (export \"ec_api_version\") (result i32) i32.const 1)";
let m = edit(&base, version, "(func (export \"ec_api_version\") (result i32) unreachable)");
assert!(load_wasm(m.as_bytes(), "x.wat").is_err());
let m = edit(&base, version, "(func (export \"ec_api_version\") (result i32) (loop $l br $l) i32.const 1)");
assert!(load_wasm(m.as_bytes(), "x.wat").is_err(), "fuel runs out in ec_api_version");
let m = edit(&base, "(memory (export \"memory\") 2)", "(memory (export \"memory\") 2) (func $s (loop $l br $l)) (start $s)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("instantiation"), "fuel runs out in the start function");
// Memory beyond the sandbox's limit.
let m = edit(&base, "(memory (export \"memory\") 2)", "(memory (export \"memory\") 20000)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("instantiation"));
// Not a module at all.
for junk in [&b""[..], b"\0asm", b"\0asm\x01\0\0\0\xff\xff", b"(module", b"(module (func (export \"x\") unknown_op))"] {
assert!(load_wasm(junk, "junk").is_err());
}
}
#[test]
fn manifest_parameter_schemas_are_validated() {
let p = |params: &str| format!(r#"{{"api":1,"id":"org.test.schema","name":"Schema","category":"Test Plug-ins","version":"1","params":[{params}]}}"#);
let bad = [
(r#"{"id":"a","name":"A","type":"checkbox"},{"id":"a","name":"B","type":"checkbox"}"#, "duplicate"),
(r#"{"id":"a b","name":"A","type":"checkbox"}"#, "parameter id"),
(r#"{"id":"","name":"A","type":"checkbox"}"#, "parameter id"),
(r#"{"id":"p","name":"P","type":"popup","options":[],"default":0}"#, "popup"),
(r#"{"id":"p","name":"P","type":"popup","options":["x"],"default":3}"#, "popup"),
(r#"{"id":"s","name":"S","type":"slider","default":1,"min":5,"max":0}"#, "slider"),
(r#"{"id":"s","name":"S","type":"slider","default":9,"min":0,"max":4}"#, "slider"),
(r#"{"id":"s","name":"S","type":"slider","default":1,"min":0,"max":4,"sliderMin":3,"sliderMax":2}"#, "slider"),
(r#"{"id":"s","name":"S","type":"wobble"}"#, "manifest JSON"),
(r#"{"id":"s","name":"S","type":"slider"}"#, "manifest JSON"),
];
for (params, want) in bad {
let m = module_with(&p(params), "");
let e = load_err(load_wasm(m.as_bytes(), "schema.wat"));
assert!(e.contains(want), "{params}: {e}");
}
let many: Vec<String> = (0..300).map(|i| format!(r#"{{"id":"c{i}","name":"C","type":"checkbox"}}"#)).collect();
let e = load_err(load_wasm(module_with(&p(&many.join(",")), "").as_bytes(), "many.wat"));
assert!(e.contains("at most"), "{e}");
// Missing name, another API in the manifest.
let e = load_err(load_wasm(module_with(r#"{"api":1,"id":"org.test.noname","name":" ","category":"X","version":"1","params":[]}"#, "").as_bytes(), "x.wat"));
assert!(e.contains("name"), "{e}");
let e = load_err(load_wasm(module_with(r#"{"api":3,"id":"org.test.api3","name":"N","category":"X","version":"1","params":[]}"#, "").as_bytes(), "x.wat"));
assert!(e.contains("API 3"), "{e}");
// A valid schema loads.
let ok = p(
r#"{"id":"s","name":"S","type":"slider","default":2,"min":0,"max":4,"sliderMin":1,"sliderMax":3},{"id":"p","name":"P","type":"popup","options":["x","y"],"default":1}"#,
);
let spec = load_wasm(module_with(&ok, "").as_bytes(), "ok.wat").unwrap();
assert_eq!(spec.params.len(), 2);
}
#[test]
fn render_failures_leave_the_frame_and_recover() {
let orig = [0.8, 0.6, 0.2, 1.0];
// ec_render traps (when time > 0.5), returns an error code, or ec_alloc hands back memory
// out of bounds: the frame passes through unchanged, and the plug-in keeps working.
let trap = load_wasm(gain_module("org.test.trap", "local.get $t f64.const 0.5 f64.gt if unreachable end").as_bytes(), "trap.wat").unwrap();
let mut next = 1;
let g = effectcraft_effects::instantiate(trap, &mut Ids(&mut next), trap.name, [4.0, 2.0]);
let params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
let at = |time: f64| {
let ctx = EffectCtx { params: &params, time, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let buf = Buf { img: Image::filled(4, 2, orig), offset: [0.0; 2], scale: 1.0 };
(trap.render)(&ctx, buf).img
};
assert_eq!(at(1.0).get(0, 0), orig, "trapped");
assert_eq!(at(0.0).get(0, 0), [0.4, 0.3, 0.1, 1.0], "a fresh instance after the trap");
assert_eq!(at(1.0).get(0, 0), orig);
assert_eq!(at(0.0).get(1, 1), [0.4, 0.3, 0.1, 1.0]);
let code = load_wasm(gain_module("org.test.code", "i32.const 7 return").as_bytes(), "code.wat").unwrap();
assert_eq!(render(code, 0.5, false).get(0, 0), orig);
let oob = edit(&gain_module("org.test.oob", ""), " i32.const 65536)", " i32.const -16)");
let oob = load_wasm(oob.as_bytes(), "oob.wat").unwrap();
assert_eq!(render(oob, 0.5, false).get(0, 0), orig);
// Runaway rendering: out of fuel every time, never hangs.
let spin = load_wasm(gain_module("org.test.spin2", "(loop $spin br $spin)").as_bytes(), "spin2.wat").unwrap();
for _ in 0..3 {
assert_eq!(render(spin, 0.5, false).get(0, 0), orig);
}
}
#[test]
fn wasm_rendering_is_deterministic() {
// Float edge cases (NaN from sqrt(−x), infinities from x / 0) come out bit-identical
// across instances, threads and repeated runs.
let body = "local.get $px local.get $px f32.load f32.const -1 f32.mul f32.sqrt f32.store \
local.get $px local.get $px f32.load offset=4 f32.const 0 f32.div f32.store offset=4";
let spec = load_wasm(gain_module("org.test.det", body).as_bytes(), "det.wat").unwrap();
let bits = |img: &Image| -> Vec<u32> { (0..2).flat_map(|y| (0..4).map(move |x| (x, y))).flat_map(|(x, y)| img.get(x, y).map(f32::to_bits)).collect() };
let first = bits(&render(spec, 0.37, true));
let runs: Vec<Vec<u32>> = std::thread::scope(|sc| {
let hs: Vec<_> = (0..6).map(|_| sc.spawn(|| bits(&render(spec, 0.37, true)))).collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
for r in runs {
assert_eq!(r, first);
}
// The same module loaded under another id renders the same bits.
let twin = load_wasm(gain_module("org.test.det2", body).as_bytes(), "det2.wat").unwrap();
assert_eq!(bits(&render(twin, 0.37, true)), first);
}