diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 48ba5b61..ee771276 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -114,7 +114,7 @@ fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() { let (layers, undo) = (layer_count(&s), undo_len(&s)); let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"})); assert_eq!(out["output"], json!([a.0]), "the plugin read the selection"); - assert_eq!(out["calls"], json!(4)); + assert_eq!(out["calls"], json!(6)); assert_eq!(node(&s, a).name, "Tidy"); assert_eq!(layer_count(&s), layers + 1); assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run"); @@ -197,9 +197,11 @@ fn traps_endless_loops_and_memory_hogs_roll_back() { let a = rect(&mut s, 0.0); ex(&mut s, "select.set", json!({"ids": [a]})); let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))]; + let bulk_output = "(call $set_output (i32.const 0) (i32.const 65536))".repeat(32); let cases = [ ("org.test.trap", "unreachable", "crashed"), ("org.test.loop", "(loop $l (br $l))", "instruction budget"), + ("org.test.bulk", bulk_output.as_str(), "instruction budget"), ("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"), ]; for (id, tail, want) in cases { diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 37dc60b3..22eb0af0 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -63,7 +63,8 @@ const IMPORTS: &[(&str, usize, usize)] = &[ /// What a run may use. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Limits { - /// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more. + /// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more, + /// plus one fuel per byte copied out of or into guest memory. pub fuel: u64, pub call_fuel: u64, /// Linear memory cap in bytes. @@ -281,10 +282,10 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result i32`"))); } Ok(module) @@ -399,11 +400,7 @@ fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error { /// Charge a host call: fuel and the call count. fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> { let cost = caller.data().limits.call_fuel; - let left = caller.get_fuel()?; - if left < cost { - return Err(abort(caller, PluginError::OutOfFuel)); - } - caller.set_fuel(left - cost)?; + spend_fuel(caller, cost)?; let st = caller.data_mut(); st.out.calls = st.out.calls.saturating_add(1); if st.out.calls > st.limits.max_calls { @@ -413,12 +410,21 @@ fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> { Ok(()) } +/// Reserve work before copying or allocating, including when call overhead is disabled. +fn spend_fuel(caller: &mut Ctx<'_>, cost: u64) -> Result<(), wasmi::Error> { + let Some(left) = caller.get_fuel()?.checked_sub(cost) else { + return Err(abort(caller, PluginError::OutOfFuel)); + }; + caller.set_fuel(left) +} + /// The bytes at `(ptr, len)` in the plugin's memory. fn read_bytes(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result, wasmi::Error> { let (ptr, len) = (ptr as u32 as usize, len as u32 as usize); if len > caller.data().limits.max_message { return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long")))); } + spend_fuel(caller, len as u64)?; let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else { return Err(abort(caller, PluginError::InvalidModule("no memory".into()))); }; @@ -472,14 +478,20 @@ fn linker(engine: &Engine) -> Result, PluginError> { Err(ask(&mut c, Request::Selection)) }) .map_err(err)?; - l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?; + l.func_wrap(IMPORT_MODULE, "reply_len", |mut c: Ctx<'_>| -> Result { + charge(&mut c)?; + Ok(i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX)) + }) + .map_err(err)?; l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result { + charge(&mut c)?; let (ptr, want) = (ptr as u32 as usize, len as u32 as usize); + let n = want.min(c.data().reply.len()); + spend_fuel(&mut c, n as u64)?; let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else { return Err(abort(&mut c, PluginError::InvalidModule("no memory".into()))); }; let (data, st) = mem.data_and_store_mut(&mut c); - let n = want.min(st.reply.len()); let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?; let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?; dst.copy_from_slice(src); @@ -500,6 +512,7 @@ fn linker(engine: &Engine) -> Result, PluginError> { }) .map_err(err)?; l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> { + charge(&mut c)?; let out = read_str(&mut c, ptr, len)?; c.data_mut().out.output = out; Ok(()) diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index e72e7d5b..5d094ab4 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -81,7 +81,7 @@ fn echoes_input_calls_commands_and_reads_replies() { assert_eq!(host.logs, out.logs); assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]); assert_eq!(host.notes, vec!["made it".to_string()]); - assert_eq!(out.calls, 3); + assert_eq!(out.calls, 7); assert!(out.fuel > 0); } @@ -142,7 +142,9 @@ fn logs_over_the_default_byte_budget_fail_before_forwarding() { (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $logs (i32.lt_u (local.get $i) (i32.const 129)))) (i32.const 0)", ); - let (result, host) = go(&m, "{}"); + // The default fuel: copying a megabyte of log bytes out of the plugin is itself charged. + let mut host = Mock::default(); + let result = run(&m, "{}", &Limits::default(), &mut host); assert!(matches!(result, Err(PluginError::Limit(_))), "logs over 1 MiB must fail the run"); assert_eq!(host.logs.len(), 128); assert_eq!(host.logs.iter().map(String::len).sum::(), 1024 * 1024); @@ -254,6 +256,41 @@ fn logs_still_observe_the_general_message_limit() { assert!(host.logs.is_empty()); } +#[test] +fn reply_and_output_imports_obey_host_call_limit() { + for call in + ["(drop (call $reply_len))", "(drop (call $reply_read (i32.const 0) (i32.const 2)))", "(call $set_output (i32.const 0) (i32.const 2))"] + { + let m = module(r#"(data (i32.const 0) "ok")"#, &format!("{call} {call} (i32.const 0)")); + let limits = Limits { max_calls: 1, ..small() }; + assert!(matches!(run(&m, "{}", &limits, &mut Mock::default()), Err(PluginError::Limit(_))), "{call}"); + let limits = Limits { max_calls: 2, ..small() }; + assert_eq!(run(&m, "{}", &limits, &mut Mock::default()).unwrap().calls, 2, "{call}"); + } +} + +#[test] +fn repeated_bulk_imports_spend_fuel_for_bytes_even_without_call_overhead() { + for call in ["(drop (call $reply_read (i32.const 0) (i32.const 4096)))", "(call $set_output (i32.const 0) (i32.const 4096))"] { + let m = module("", &format!("{} (i32.const 0)", call.repeat(16))); + let limits = Limits { fuel: 40_000, call_fuel: 0, ..small() }; + assert_eq!(run(&m, &"x".repeat(4096), &limits, &mut Mock::default()), Err(PluginError::OutOfFuel), "{call}"); + } +} + +#[test] +fn reply_copy_charges_actual_bytes_and_each_callback_gets_fresh_limits() { + let m = module("", "(call $set_output (i32.const 0) (call $reply_read (i32.const 0) (i32.const 2147483647))) (i32.const 0)"); + let limits = Limits { max_calls: 2, fuel: 1000, call_fuel: 0, ..small() }; + let mut instance = crate::Instance::new(&m, &limits).unwrap(); + assert_eq!(instance.call(&"x".repeat(2048), &mut Mock::default()), Err(PluginError::OutOfFuel)); + for input in ["hello", "again"] { + let out = instance.call(input, &mut Mock::default()).unwrap(); + assert_eq!(out.output, input); + assert_eq!(out.calls, 2); + } +} + #[test] fn memory_is_capped() { // Declared larger than the cap: refused at instantiation. diff --git a/docs/plugins.md b/docs/plugins.md index 212e5fde..6f23e6ec 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -251,12 +251,17 @@ it can handle): Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls -(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to +(each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to 16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and its images 1 MB each, 2 MB together and 4096 pixels on a side. +Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the +allocation or copy. This also applies when the configured per-call overhead is zero. A reply +read is charged for the bytes actually returned, not the requested buffer size. `calls` counts +every ABI import, including calls which only query the reply length. + ## ABI (version 3) Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2