320 lines
15 KiB
Rust
320 lines
15 KiB
Rust
//! 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: ¶ms, 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: ¶ms, 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);
|
||
}
|