Skip to main content

martensite_plugin/
runtime.rs

1//! Wasmtime-powered sandboxed runtime for Martensite plugins.
2//!
3//! Plugins are compiled as `wasm32-wasip1` WebAssembly modules and executed
4//! with a fuel budget and epoch interruption enabled. The runtime embeds a
5//! WASIp1 context with no filesystem or network capabilities by default, and
6//! injects host functions that validate every call against a
7//! [`CapabilitySet`](crate::security::CapabilitySet).
8
9use std::fmt;
10use std::path::PathBuf;
11
12use wasmtime::{
13    Caller, Config, Engine, Extern, Instance, Linker, Module, Store, Trap, WasmParams, WasmResults,
14};
15use wasmtime_wasi::preview1::{self, WasiP1Ctx};
16use wasmtime_wasi::WasiCtxBuilder;
17
18use crate::ring_buffer::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
19use crate::security::{Capability, CapabilitySet};
20use martensite_reactive::SignalId;
21
22/// Default fuel budget allocated to each plugin instance.
23///
24/// This is a target budget for a roughly 5 ms execution slice, not a portable
25/// time measurement. Wasmtime fuel costs are instruction-relative, so hosts
26/// must calibrate this value for each target architecture and workload. Rogue
27/// or stuck plugins are terminated when fuel is exhausted.
28pub const DEFAULT_FUEL_BUDGET: u64 = 200_000;
29
30/// Namespace used for Martensite-specific host functions exposed to plugins.
31const HOST_NS: &str = "martensite";
32
33/// Per-instance host state shared with the Wasmtime store.
34///
35/// Contains the WASIp1 context and the capability grants for the current
36/// plugin instance.
37pub struct PluginState {
38    wasi: WasiP1Ctx,
39    caps: CapabilitySet,
40    ring_buffer: Vec<u8>,
41}
42
43impl fmt::Debug for PluginState {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.debug_struct("PluginState")
46            .field("capabilities", &self.caps)
47            .finish_non_exhaustive()
48    }
49}
50
51impl PluginState {
52    /// Returns a reference to the capability set for this instance.
53    pub fn capabilities(&self) -> &CapabilitySet {
54        &self.caps
55    }
56}
57
58/// Errors that can occur while configuring or running a plugin.
59#[derive(Debug)]
60pub enum PluginError {
61    /// An underlying Wasmtime error.
62    Wasmtime(wasmtime::Error),
63    /// The requested export was not found or had the wrong type.
64    MissingExport(String),
65    /// The plugin exhausted its fuel budget.
66    OutOfFuel,
67}
68
69impl fmt::Display for PluginError {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            PluginError::Wasmtime(e) => write!(f, "wasmtime error: {e}"),
73            PluginError::MissingExport(name) => write!(f, "missing export: {name}"),
74            PluginError::OutOfFuel => write!(f, "plugin ran out of fuel"),
75        }
76    }
77}
78
79impl std::error::Error for PluginError {}
80
81impl From<wasmtime::Error> for PluginError {
82    fn from(err: wasmtime::Error) -> Self {
83        if err
84            .downcast_ref::<Trap>()
85            .is_some_and(|t| matches!(t, Trap::OutOfFuel))
86        {
87            PluginError::OutOfFuel
88        } else {
89            PluginError::Wasmtime(err)
90        }
91    }
92}
93
94/// Preconfigured Wasmtime runtime environment for loading plugins.
95///
96/// The engine is shared across instances; the linker is configured once with
97/// the WASIp1 imports and Martensite host functions.
98///
99/// # Examples
100///
101/// ```
102/// use martensite_plugin::{CapabilitySet, PluginRuntime};
103///
104/// let runtime = PluginRuntime::new().unwrap();
105/// // The empty capability set gives the plugin no host access.
106/// let _ = runtime.load(b"\0asm\x01\0\0\0", CapabilitySet::empty());
107/// // (Loading a real wasm module would succeed; a minimal header is shown here.)
108/// ```
109pub struct PluginRuntime {
110    engine: Engine,
111    linker: Linker<PluginState>,
112    fuel_budget: u64,
113}
114
115impl PluginRuntime {
116    /// Creates a new runtime with the default fuel budget.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the Wasmtime engine cannot be initialized.
121    pub fn new() -> Result<Self, PluginError> {
122        Self::with_fuel_budget(DEFAULT_FUEL_BUDGET)
123    }
124
125    /// Creates a new runtime with a custom fuel budget.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the Wasmtime engine cannot be initialized.
130    pub fn with_fuel_budget(fuel_budget: u64) -> Result<Self, PluginError> {
131        let mut config = Config::new();
132        config.consume_fuel(true);
133        config.epoch_interruption(true);
134        let engine = Engine::new(&config)?;
135
136        let mut linker = Linker::<PluginState>::new(&engine);
137        preview1::add_to_linker_sync(&mut linker, |state: &mut PluginState| &mut state.wasi)?;
138        Self::add_host_functions(&mut linker)?;
139
140        Ok(Self {
141            engine,
142            linker,
143            fuel_budget,
144        })
145    }
146
147    fn add_host_functions(linker: &mut Linker<PluginState>) -> Result<(), PluginError> {
148        linker.func_wrap(
149            HOST_NS,
150            "signal_read",
151            |caller: Caller<'_, PluginState>, id: i64| {
152                let state = caller.data();
153                let cap = Capability::SignalRead(SignalId(id as u64));
154                if state.caps.contains(&cap) {
155                    Ok(())
156                } else {
157                    Err(wasmtime::Error::msg("unauthorized signal read"))
158                }
159            },
160        )?;
161
162        linker.func_wrap(
163            HOST_NS,
164            "signal_write",
165            |caller: Caller<'_, PluginState>, id: i64| {
166                let state = caller.data();
167                let cap = Capability::SignalWrite(SignalId(id as u64));
168                if state.caps.contains(&cap) {
169                    Ok(())
170                } else {
171                    Err(wasmtime::Error::msg("unauthorized signal write"))
172                }
173            },
174        )?;
175
176        linker.func_wrap(
177            HOST_NS,
178            "file_read",
179            |mut caller: Caller<'_, PluginState>,
180             path_ptr: i32,
181             path_len: i32,
182             _buf_ptr: i32,
183             _buf_len: i32| {
184                let path_ptr = usize::try_from(path_ptr)
185                    .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
186                let path_len = usize::try_from(path_len)
187                    .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
188                let memory = match caller.get_export("memory") {
189                    Some(Extern::Memory(memory)) => memory,
190                    _ => return Err(wasmtime::Error::msg("missing guest memory")),
191                };
192                let mut path_bytes = vec![0; path_len];
193                memory.read(&caller, path_ptr, &mut path_bytes)?;
194                let path = std::str::from_utf8(&path_bytes)
195                    .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
196                if !caller
197                    .data()
198                    .caps
199                    .contains(&Capability::FileRead(PathBuf::from(path)))
200                {
201                    return Err(wasmtime::Error::msg("unauthorized file read"));
202                }
203
204                // Actual filesystem I/O will be delegated to a future WasiCtx
205                // preopen; this host function currently enforces authorization.
206                Ok(())
207            },
208        )?;
209
210        linker.func_wrap(
211            HOST_NS,
212            "network_open",
213            |caller: Caller<'_, PluginState>| {
214                if caller.data().caps.contains(&Capability::Network) {
215                    Ok(())
216                } else {
217                    Err(wasmtime::Error::msg("unauthorized network open"))
218                }
219            },
220        )?;
221
222        linker.func_wrap(HOST_NS, "ring_buffer_ptr", || -> i64 { 0 })?;
223        linker.func_wrap(HOST_NS, "ring_buffer_capacity", || -> i32 {
224            DEFAULT_CAPACITY as i32
225        })?;
226
227        Ok(())
228    }
229
230    /// Returns the Wasmtime engine used by this runtime.
231    ///
232    /// Epoch interruption is configured for every plugin store. The host must
233    /// call [`Engine::increment_epoch`] on this engine on a 5 ms cadence to
234    /// enforce the wall-clock deadline in addition to the fuel budget.
235    pub fn engine(&self) -> &Engine {
236        &self.engine
237    }
238
239    /// Loads and instantiates a plugin with the given capability set.
240    ///
241    /// The returned [`PluginInstance`] is independent from the runtime and can
242    /// be invoked repeatedly until its fuel budget is exhausted.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if compilation, instantiation, or fuel setup fails.
247    pub fn load(
248        &self,
249        wasm_bytes: &[u8],
250        caps: CapabilitySet,
251    ) -> Result<PluginInstance, PluginError> {
252        let module = Module::new(&self.engine, wasm_bytes)?;
253
254        let wasi = WasiCtxBuilder::new().build_p1();
255        let state = PluginState {
256            wasi,
257            caps,
258            ring_buffer: vec![0; DEFAULT_CAPACITY],
259        };
260        let mut store = Store::new(&self.engine, state);
261        store.set_fuel(self.fuel_budget)?;
262        store.set_epoch_deadline(1);
263
264        let instance = self.linker.instantiate(&mut store, &module)?;
265
266        Ok(PluginInstance { store, instance })
267    }
268}
269
270/// A running WebAssembly plugin instance.
271///
272/// Holds the Wasmtime [`Store`] and [`Instance`] for a single plugin. All
273/// calls happen in the context of this instance and consume its fuel budget.
274pub struct PluginInstance {
275    store: Store<PluginState>,
276    instance: Instance,
277}
278
279impl PluginInstance {
280    /// Invokes an exported function that takes no parameters and returns
281    /// nothing.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`PluginError::MissingExport`] if the export does not exist or
286    /// has the wrong signature, or any other plugin error if execution fails.
287    ///
288    /// # Examples
289    ///
290    /// ```ignore
291    /// use martensite_plugin::{CapabilitySet, PluginRuntime};
292    ///
293    /// let runtime = PluginRuntime::new().unwrap();
294    /// let mut plugin = runtime.load(wasm_bytes, CapabilitySet::empty()).unwrap();
295    /// plugin.invoke("run").unwrap();
296    /// ```
297    pub fn invoke(&mut self, name: &str) -> Result<(), PluginError> {
298        self.invoke_typed(name, ())
299    }
300
301    /// Invokes an exported function with a statically checked WebAssembly
302    /// signature.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`PluginError::MissingExport`] if the export does not exist or
307    /// has a signature different from `Args -> Rets`, or any execution error
308    /// produced by the plugin.
309    pub fn invoke_typed<Args, Rets>(&mut self, name: &str, args: Args) -> Result<Rets, PluginError>
310    where
311        Args: WasmParams,
312        Rets: WasmResults,
313    {
314        let func = self
315            .instance
316            .get_typed_func::<Args, Rets>(&mut self.store, name)
317            .map_err(|_| PluginError::MissingExport(name.to_string()))?;
318        Ok(func.call(&mut self.store, args)?)
319    }
320
321    /// Drains paint commands from this instance's host-side ring buffer.
322    ///
323    /// The current ABI reports offset zero to the guest and keeps the backing
324    /// allocation in host state. Mapping it directly into guest linear memory
325    /// is reserved as a future shared-memory optimization.
326    pub fn drain_paint_commands(&mut self, f: impl FnMut(&PluginPaintCmd, &[u8])) {
327        let mut ring_buffer = PluginRingBuffer::new(&mut self.store.data_mut().ring_buffer);
328        ring_buffer.drain(f);
329    }
330
331    /// Returns a reference to the capability set active for this instance.
332    pub fn capabilities(&self) -> &CapabilitySet {
333        self.store.data().capabilities()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn compile_wat(wat: &str) -> Vec<u8> {
342        wat::parse_str(wat).expect("valid WAT")
343    }
344
345    #[test]
346    fn runtime_cannot_load_invalid_wasm() {
347        let runtime = PluginRuntime::new().unwrap();
348        assert!(runtime.load(b"not wasm", CapabilitySet::empty()).is_err());
349    }
350
351    #[test]
352    fn guest_is_terminated_on_infinite_loop() {
353        let wat = r#"
354            (module
355              (func (export "run")
356                (loop (br 0))
357              )
358            )
359        "#;
360        let runtime = PluginRuntime::with_fuel_budget(10_000).unwrap();
361        let mut plugin = runtime
362            .load(&compile_wat(wat), CapabilitySet::empty())
363            .unwrap();
364        let err = plugin.invoke("run").unwrap_err();
365        assert!(
366            matches!(err, PluginError::OutOfFuel),
367            "expected OutOfFuel, got {err}"
368        );
369    }
370
371    #[test]
372    fn authorized_host_call_succeeds() {
373        let wat = r#"
374            (module
375              (import "martensite" "signal_read" (func $signal_read (param i64)))
376              (func (export "run")
377                i64.const 42
378                call $signal_read
379              )
380            )
381        "#;
382        let id = SignalId(42);
383        let caps = CapabilitySet::builder()
384            .grant(Capability::SignalRead(id))
385            .build();
386
387        let runtime = PluginRuntime::with_fuel_budget(50_000).unwrap();
388        let mut plugin = runtime.load(&compile_wat(wat), caps).unwrap();
389        plugin.invoke("run").unwrap();
390    }
391
392    #[test]
393    fn unauthorized_host_call_traps() {
394        let wat = r#"
395            (module
396              (import "martensite" "signal_read" (func $signal_read (param i64)))
397              (func (export "run")
398                i64.const 7
399                call $signal_read
400              )
401            )
402        "#;
403        let runtime = PluginRuntime::with_fuel_budget(50_000).unwrap();
404        let mut plugin = runtime
405            .load(&compile_wat(wat), CapabilitySet::empty())
406            .unwrap();
407        // The host function rejects the call because the capability was not
408        // granted, so WebAssembly execution traps and returns an error.
409        assert!(plugin.invoke("run").is_err());
410    }
411
412    #[test]
413    fn file_read_requires_matching_path_capability() {
414        let wat = r#"
415            (module
416              (import "martensite" "file_read"
417                (func $file_read (param i32 i32 i32 i32)))
418              (memory (export "memory") 1)
419              (data (i32.const 16) "/assets")
420              (func (export "run")
421                i32.const 16
422                i32.const 7
423                i32.const 0
424                i32.const 0
425                call $file_read
426              )
427            )
428        "#;
429        let runtime = PluginRuntime::new().unwrap();
430        let mut unauthorized = runtime
431            .load(&compile_wat(wat), CapabilitySet::empty())
432            .unwrap();
433        assert!(unauthorized.invoke("run").is_err());
434
435        let caps = CapabilitySet::builder()
436            .grant(Capability::FileRead(PathBuf::from("/assets")))
437            .build();
438        let mut authorized = runtime.load(&compile_wat(wat), caps).unwrap();
439        authorized.invoke("run").unwrap();
440    }
441
442    #[test]
443    fn network_open_requires_capability() {
444        let wat = r#"
445            (module
446              (import "martensite" "network_open" (func $network_open))
447              (func (export "run") call $network_open)
448            )
449        "#;
450        let runtime = PluginRuntime::new().unwrap();
451        let mut unauthorized = runtime
452            .load(&compile_wat(wat), CapabilitySet::empty())
453            .unwrap();
454        assert!(unauthorized.invoke("run").is_err());
455
456        let caps = CapabilitySet::builder().grant(Capability::Network).build();
457        let mut authorized = runtime.load(&compile_wat(wat), caps).unwrap();
458        authorized.invoke("run").unwrap();
459    }
460
461    #[test]
462    fn typed_invoke_and_ring_buffer_exports_work() {
463        let wat = r#"
464            (module
465              (import "martensite" "ring_buffer_ptr" (func $ptr (result i64)))
466              (import "martensite" "ring_buffer_capacity" (func $capacity (result i32)))
467              (func (export "add_one") (param i32) (result i32)
468                local.get 0
469                i32.const 1
470                i32.add)
471              (func (export "ptr") (result i64) call $ptr)
472              (func (export "capacity") (result i32) call $capacity)
473            )
474        "#;
475        let runtime = PluginRuntime::new().unwrap();
476        let mut plugin = runtime
477            .load(&compile_wat(wat), CapabilitySet::empty())
478            .unwrap();
479        assert_eq!(plugin.invoke_typed::<i32, i32>("add_one", 41).unwrap(), 42);
480        assert_eq!(plugin.invoke_typed::<(), i64>("ptr", ()).unwrap(), 0);
481        assert_eq!(
482            plugin.invoke_typed::<(), i32>("capacity", ()).unwrap(),
483            DEFAULT_CAPACITY as i32
484        );
485        plugin.drain_paint_commands(|_, _| panic!("new buffer must be empty"));
486        assert!(std::ptr::eq(runtime.engine(), &runtime.engine));
487    }
488
489    #[test]
490    fn empty_capability_set_is_default_for_load() {
491        let runtime = PluginRuntime::new().unwrap();
492        let wat = r#"
493            (module
494              (func (export "run"))
495            )
496        "#;
497        let mut plugin = runtime
498            .load(&compile_wat(wat), CapabilitySet::empty())
499            .unwrap();
500        plugin.invoke("run").unwrap();
501        assert!(plugin.capabilities().is_empty());
502    }
503}