M13.12: plug-in ABI hardening (memory limit, manifest length, slider schema) and tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 09:42:44 -04:00
co-authored by Claude Opus 5.5
parent c3532df7ef
commit 88384197f9
4 changed files with 236 additions and 8 deletions
+10
View File
@@ -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(())
}
+55
View File
@@ -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<String> = 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);
}
+158 -3
View File
@@ -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<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);
}
+13 -5
View File
@@ -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<StoreLimits>,
instance: Instance,
memory: Memory,
alloc: TypedFunc<i32, i32>,
@@ -37,9 +41,10 @@ fn err(e: impl std::fmt::Display) -> String {
}
fn instantiate(engine: &Engine, module: &Module) -> Result<Inst, String> {
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::<StoreLimits>::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::<i32, i32>(&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()?;