diff --git a/crates/effects/src/plugin.rs b/crates/effects/src/plugin.rs index 242f52b9..874050f4 100644 --- a/crates/effects/src/plugin.rs +++ b/crates/effects/src/plugin.rs @@ -137,6 +137,7 @@ pub struct PluginManifest { impl PluginManifest { /// Check the manifest against this API version. pub fn validate(&self) -> Result<(), String> { + const MAX_PARAMS: usize = 256; if self.api != PLUGIN_API_VERSION { return Err(format!("plug-in `{}` targets plug-in API {} but this build implements API {PLUGIN_API_VERSION}", self.id, self.api)); } @@ -147,6 +148,9 @@ impl PluginManifest { if self.name.trim().is_empty() || self.category.trim().is_empty() { return Err(format!("plug-in `{}` needs a name and a category", self.id)); } + if self.params.len() > MAX_PARAMS { + return Err(format!("plug-in `{}` has {} parameters (at most {MAX_PARAMS})", self.id, self.params.len())); + } let mut seen = std::collections::HashSet::new(); for p in &self.params { if p.id.is_empty() || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || !seen.insert(p.id.as_str()) { @@ -157,6 +161,12 @@ impl PluginManifest { { return Err(format!("plug-in `{}`: popup `{}` needs options and a default index in range", self.id, p.id)); } + if let PluginParamKind::Slider { default, min, max, slider_min, slider_max, .. } = &p.kind { + let (lo, hi) = (slider_min.unwrap_or(*min), slider_max.unwrap_or(*max)); + if [*default, *min, *max, lo, hi].iter().any(|v| !v.is_finite()) || min > max || lo > hi || default < min || default > max { + return Err(format!("plug-in `{}`: slider `{}` needs min ≤ default ≤ max (and sliderMin ≤ sliderMax)", self.id, p.id)); + } + } } Ok(()) } diff --git a/crates/host/tests/plugins.rs b/crates/host/tests/plugins.rs index c8a9b368..83891148 100644 --- a/crates/host/tests/plugins.rs +++ b/crates/host/tests/plugins.rs @@ -104,3 +104,58 @@ fn example_wasm_plugin_loads_and_renders() { let list = s.execute("effect.plugins.list", json!({})).unwrap(); assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm"))); } + +/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body. +fn wat_plugin(id: &str, api: i32, render: &str) -> String { + let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#); + let len = manifest.len(); + let escaped = manifest.replace('"', "\\\""); + format!( + r#"(module + (memory (export "memory") 2) + (data (i32.const 16) "{escaped}") + (func (export "ec_api_version") (result i32) i32.const {api}) + (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 i32) (result i32) i32.const 1024) + (func (export "ec_render") (param i32 i32 i32 i32 i32 f64 f64) (result i32) {render}))"# + ) +} + +#[test] +fn plugin_loading_reports_bad_modules_and_survives_failing_renders() { + let dir = std::env::temp_dir().join(format!("effectcraft-host-plugins-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files = [ + ("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()), + ("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()), + ("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()), + ("d-empty.wasm", vec![]), + ("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()), + ("notes.txt", b"not a plug-in".to_vec()), + ]; + for (name, bytes) in &files { + std::fs::write(dir.join(name), bytes).unwrap(); + } + let (mut s, l) = setup(); + let r = s.execute("effect.plugins.load", json!({"folder": dir.to_string_lossy()})).unwrap(); + let ids: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect(); + assert_eq!(ids, ["org.test.host.good", "org.test.host.trap"], "{r}"); + let errs: Vec = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect(); + assert_eq!(errs.len(), 3, "{r}"); + assert!(errs.iter().all(|p| !p.ends_with(".txt"))); + assert!(r["errors"].to_string().contains("API 2"), "{r}"); + // A single file: errors come back as command errors; loading twice is refused. + let good = dir.join("a-good.wasm").to_string_lossy().to_string(); + assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered")); + assert!(s.execute("effect.plugins.load", json!({"path": dir.join("missing.wasm").to_string_lossy()})).is_err()); + assert!(s.execute("effect.plugins.load", json!({"folder": dir.join("missing").to_string_lossy()})).is_err()); + assert!(s.execute("effect.plugins.load", json!({})).is_err()); + // A plug-in that traps on every frame leaves the layer as it was. + let before = px(&s); + s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.trap"})).unwrap(); + assert_eq!(px(&s), before); + s.execute("effect.apply", json!({"layer": l, "effect": "org.test.host.good"})).unwrap(); + assert_eq!(px(&s), before); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 9363ab3e..f68fde00 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -12,11 +12,20 @@ fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> Stri } } +/// 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 { - let manifest = 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]}}]}}"# - ); + 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!( @@ -162,3 +171,149 @@ fn wasm_plugins_are_sandboxed() { 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 = (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 { (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> = 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); +} diff --git a/crates/plugin/src/wasm.rs b/crates/plugin/src/wasm.rs index 8015f7ee..71f1f136 100644 --- a/crates/plugin/src/wasm.rs +++ b/crates/plugin/src/wasm.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; -use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc, WasmParams, WasmResults}; +use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults}; use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams}; @@ -10,12 +10,16 @@ use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, Plugi /// work, but a runaway loop stops instead of hanging the render. const FUEL_BASE: u64 = 50_000_000; const FUEL_PER_PIXEL: u64 = 20_000; +/// The most linear memory a plug-in instance may have (a 4K float frame is ~130 MB). +const MAX_MEMORY: usize = 1 << 30; +/// The longest manifest read. +const MAX_MANIFEST: i32 = 1 << 20; type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>; /// One instantiated module (renders running on several threads each take their own). struct Inst { - store: Store<()>, + store: Store, instance: Instance, memory: Memory, alloc: TypedFunc, @@ -37,9 +41,10 @@ fn err(e: impl std::fmt::Display) -> String { } fn instantiate(engine: &Engine, module: &Module) -> Result { - let mut store = Store::new(engine, ()); + let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build()); + store.limiter(|l| l); store.set_fuel(FUEL_BASE).map_err(err)?; - let linker = Linker::<()>::new(engine); + let linker = Linker::::new(engine); let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?; let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?; let alloc = instance.get_typed_func::(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?; @@ -74,7 +79,10 @@ impl WasmPlugin { } let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?; let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?; - let mut text = vec![0u8; len.max(0) as usize]; + if !(0..=MAX_MANIFEST).contains(&len) { + return Err(format!("bad manifest length {len}")); + } + let mut text = vec![0u8; len as usize]; inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?; manifest.validate()?;