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//!
9//! # Shared ring buffer ABI
10//!
11//! The paint-command ring buffer lives in guest-visible linear memory. A
12//! plugin obtains it in one of two ways:
13//!
14//! - **Import** `(import "martensite" "ring_memory" (memory N))` with `N`
15//!   pages covering [`RING_BUFFER_REGION_SIZE`]; the host supplies a dedicated
16//!   memory whose region starts at offset `0`.
17//! - **Export** `memory`; the host appends
18//!   `ceil(RING_BUFFER_REGION_SIZE / 65536)` pages at instantiation and
19//!   `martensite.ring_buffer_ptr` returns the region's base offset. Guests on
20//!   this path must treat that range as reserved so their allocator never
21//!   reuses it.
22//!
23//! The region layout is an 8-byte `head`/`tail` cursor header followed by
24//! [`DEFAULT_CAPACITY`] bytes of circular payload area; see
25//! [`PluginRingBuffer::new_shared`](crate::ring_buffer::PluginRingBuffer::new_shared).
26//! `martensite.ring_buffer_len`, `martensite.ring_buffer_read`, and
27//! `martensite.ring_buffer_write` host functions provide validated access for
28//! guests that prefer not to touch the region directly.
29
30use std::fmt;
31use std::io::Read;
32
33use wasmtime::{
34    Caller, Config, Engine, Extern, ExternType, Instance, Linker, Memory, MemoryType, Module,
35    Store, Trap, WasmParams, WasmResults,
36};
37use wasmtime_wasi::preview1::{self, WasiP1Ctx};
38use wasmtime_wasi::WasiCtxBuilder;
39
40use crate::ring_buffer::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY, SHARED_HEADER_SIZE};
41use crate::security::{Capability, CapabilitySet};
42use martensite_reactive::SignalId;
43
44/// Default fuel budget allocated to each plugin instance.
45///
46/// This is a target budget for a roughly 5 ms execution slice, not a portable
47/// time measurement. Wasmtime fuel costs are instruction-relative, so hosts
48/// must calibrate this value for each target architecture and workload. Rogue
49/// or stuck plugins are terminated when fuel is exhausted.
50pub const DEFAULT_FUEL_BUDGET: u64 = 200_000;
51
52/// Namespace used for Martensite-specific host functions exposed to plugins.
53const HOST_NS: &str = "martensite";
54
55/// Import name under which a plugin may request a host-provided ring buffer
56/// memory (`(import "martensite" "ring_memory" (memory N))`).
57const RING_MEMORY_IMPORT: &str = "ring_memory";
58
59/// Number of bytes in one WebAssembly linear memory page.
60const WASM_PAGE_SIZE: usize = 65_536;
61
62/// Total byte size of the shared ring buffer region.
63///
64/// The region consists of a [`SHARED_HEADER_SIZE`]-byte cursor header
65/// (`head: u32`, `tail: u32`, little-endian) followed by
66/// [`DEFAULT_CAPACITY`] bytes of circular payload area. Guests that import
67/// `martensite.ring_memory` must declare at least
68/// `ceil(RING_BUFFER_REGION_SIZE / 65536)` pages.
69///
70/// # Examples
71///
72/// ```
73/// use martensite_plugin::runtime::RING_BUFFER_REGION_SIZE;
74/// use martensite_plugin::DEFAULT_CAPACITY;
75///
76/// assert_eq!(RING_BUFFER_REGION_SIZE, DEFAULT_CAPACITY + 8);
77/// ```
78pub const RING_BUFFER_REGION_SIZE: usize = SHARED_HEADER_SIZE + DEFAULT_CAPACITY;
79
80/// Where the per-instance shared ring buffer region lives.
81#[derive(Clone, Copy, Debug)]
82enum RingLocation {
83    /// A region appended to the guest's exported `memory` at instantiation.
84    /// The payload is the byte offset of the region base.
85    GuestMemory { base: u32 },
86    /// A dedicated host-created memory imported as `martensite.ring_memory`.
87    /// The region starts at offset zero of that memory.
88    Imported(Memory),
89}
90
91/// Per-instance host state shared with the Wasmtime store.
92///
93/// Contains the WASIp1 context, the capability grants, and the shared ring
94/// buffer location for the current plugin instance.
95pub struct PluginState {
96    wasi: WasiP1Ctx,
97    caps: CapabilitySet,
98    ring: Option<RingLocation>,
99}
100
101impl fmt::Debug for PluginState {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.debug_struct("PluginState")
104            .field("capabilities", &self.caps)
105            .finish_non_exhaustive()
106    }
107}
108
109impl PluginState {
110    /// Returns a reference to the capability set for this instance.
111    pub fn capabilities(&self) -> &CapabilitySet {
112        &self.caps
113    }
114}
115
116/// Errors that can occur while configuring or running a plugin.
117#[derive(Debug)]
118pub enum PluginError {
119    /// An underlying Wasmtime error.
120    Wasmtime(wasmtime::Error),
121    /// The requested export was not found or had the wrong type.
122    MissingExport(String),
123    /// The plugin exhausted its fuel budget.
124    OutOfFuel,
125}
126
127impl fmt::Display for PluginError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            PluginError::Wasmtime(e) => write!(f, "wasmtime error: {e}"),
131            PluginError::MissingExport(name) => write!(f, "missing export: {name}"),
132            PluginError::OutOfFuel => write!(f, "plugin ran out of fuel"),
133        }
134    }
135}
136
137impl std::error::Error for PluginError {}
138
139impl From<wasmtime::Error> for PluginError {
140    fn from(err: wasmtime::Error) -> Self {
141        if err
142            .downcast_ref::<Trap>()
143            .is_some_and(|t| matches!(t, Trap::OutOfFuel))
144        {
145            PluginError::OutOfFuel
146        } else {
147            PluginError::Wasmtime(err)
148        }
149    }
150}
151
152/// Maps an [`std::io::Error`] to a distinct negative `file_read` return code.
153///
154/// - `-1` for file-not-found (and as the generic fallback),
155/// - `-3` for permission denied,
156/// - `-4` for other I/O errors.
157fn io_error_code(e: &std::io::Error) -> i32 {
158    use std::io::ErrorKind;
159    match e.kind() {
160        ErrorKind::NotFound => -1,
161        ErrorKind::PermissionDenied => -3,
162        _ => -4,
163    }
164}
165
166/// Preconfigured Wasmtime runtime environment for loading plugins.
167///
168/// The engine is shared across instances; the linker is configured once with
169/// the WASIp1 imports and Martensite host functions.
170///
171/// # Examples
172///
173/// ```
174/// use martensite_plugin::{CapabilitySet, PluginRuntime};
175///
176/// let runtime = PluginRuntime::new().unwrap();
177/// // The empty capability set gives the plugin no host access.
178/// let _ = runtime.load(b"\0asm\x01\0\0\0", CapabilitySet::empty());
179/// // (Loading a real wasm module would succeed; a minimal header is shown here.)
180/// ```
181pub struct PluginRuntime {
182    engine: Engine,
183    linker: Linker<PluginState>,
184    fuel_budget: u64,
185}
186
187impl PluginRuntime {
188    /// Creates a new runtime with the default fuel budget.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if the Wasmtime engine cannot be initialized.
193    pub fn new() -> Result<Self, PluginError> {
194        Self::with_fuel_budget(DEFAULT_FUEL_BUDGET)
195    }
196
197    /// Creates a new runtime with a custom fuel budget.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if the Wasmtime engine cannot be initialized.
202    pub fn with_fuel_budget(fuel_budget: u64) -> Result<Self, PluginError> {
203        let mut config = Config::new();
204        config.consume_fuel(true);
205        config.epoch_interruption(true);
206        let engine = Engine::new(&config)?;
207
208        let mut linker = Linker::<PluginState>::new(&engine);
209        preview1::add_to_linker_sync(&mut linker, |state: &mut PluginState| &mut state.wasi)?;
210        Self::add_host_functions(&mut linker)?;
211
212        Ok(Self {
213            engine,
214            linker,
215            fuel_budget,
216        })
217    }
218
219    fn add_host_functions(linker: &mut Linker<PluginState>) -> Result<(), PluginError> {
220        linker.func_wrap(
221            HOST_NS,
222            "signal_read",
223            |caller: Caller<'_, PluginState>, id: i64| {
224                let state = caller.data();
225                let cap = Capability::SignalRead(SignalId(id as u64));
226                if state.caps.contains(&cap) {
227                    Ok(())
228                } else {
229                    Err(wasmtime::Error::msg("unauthorized signal read"))
230                }
231            },
232        )?;
233
234        linker.func_wrap(
235            HOST_NS,
236            "signal_write",
237            |caller: Caller<'_, PluginState>, id: i64| {
238                let state = caller.data();
239                let cap = Capability::SignalWrite(SignalId(id as u64));
240                if state.caps.contains(&cap) {
241                    Ok(())
242                } else {
243                    Err(wasmtime::Error::msg("unauthorized signal write"))
244                }
245            },
246        )?;
247
248        // `file_read(path_ptr, path_len, buf_ptr, buf_len) -> i32`
249        //
250        // Reads the file at the guest-supplied UTF-8 path into guest memory at
251        // `buf_ptr`. Returns the number of bytes written on success. Negative
252        // codes report specific failures:
253        //   `-1` file not found (or generic fallback),
254        //   `-2` file is larger than `buf_len`,
255        //   `-3` permission denied,
256        //   `-4` I/O error (including non-regular files),
257        //   `-5` guest-supplied path or buffer length exceeds host limits.
258        // The capability check runs before any filesystem access; unauthorized
259        // paths trap the guest.
260        linker.func_wrap(
261            HOST_NS,
262            "file_read",
263            |mut caller: Caller<'_, PluginState>,
264             path_ptr: i32,
265             path_len: i32,
266             buf_ptr: i32,
267             buf_len: i32|
268             -> Result<i32, wasmtime::Error> {
269                let path_ptr = usize::try_from(path_ptr)
270                    .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
271                let path_len = usize::try_from(path_len)
272                    .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
273                let buf_ptr = usize::try_from(buf_ptr)
274                    .map_err(|_| wasmtime::Error::msg("invalid file read buffer"))?;
275                let buf_len = usize::try_from(buf_len)
276                    .map_err(|_| wasmtime::Error::msg("invalid file read buffer"))?;
277
278                // Bound guest-supplied lengths before allocating. A malicious
279                // guest could otherwise request `i32::MAX` bytes and force a
280                // ~2 GiB host allocation.
281                const MAX_PATH_LEN: usize = 4096;
282                const MAX_BUF_LEN: usize = 16 * 1024 * 1024;
283                if path_len > MAX_PATH_LEN {
284                    tracing::warn!(
285                        len = path_len,
286                        max = MAX_PATH_LEN,
287                        "file_read: guest path length exceeds host limit"
288                    );
289                    return Ok(-5);
290                }
291                if buf_len > MAX_BUF_LEN {
292                    tracing::warn!(
293                        len = buf_len,
294                        max = MAX_BUF_LEN,
295                        "file_read: guest buffer length exceeds host limit"
296                    );
297                    return Ok(-5);
298                }
299
300                let memory = match caller.get_export("memory") {
301                    Some(Extern::Memory(memory)) => memory,
302                    _ => return Err(wasmtime::Error::msg("missing guest memory")),
303                };
304                let mut path_bytes = vec![0; path_len];
305                memory.read(&caller, path_ptr, &mut path_bytes)?;
306                let path = std::str::from_utf8(&path_bytes)
307                    .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
308
309                // Fail-closed: authorization is checked before touching the
310                // filesystem. The granted path (file or directory) must
311                // canonically contain the requested path, defeating
312                // traversal attacks like `/assets/../etc/passwd`.
313                if !caller
314                    .data()
315                    .caps
316                    .file_read_allowed(std::path::Path::new(path))
317                {
318                    return Err(wasmtime::Error::msg("unauthorized file read"));
319                }
320
321                // Open the file and inspect its metadata before reading. This
322                // rejects non-regular files (FIFOs, device nodes, sockets)
323                // which could otherwise hang the host on `read_to_end`, and
324                // avoids reading the entire file when it is larger than the
325                // guest-supplied buffer.
326                let file = match std::fs::File::open(path) {
327                    Ok(f) => f,
328                    Err(e) => {
329                        tracing::warn!(error = %e, path, "file_read: open failed");
330                        return Ok(io_error_code(&e));
331                    }
332                };
333                let metadata = match file.metadata() {
334                    Ok(m) => m,
335                    Err(e) => {
336                        tracing::warn!(error = %e, path, "file_read: metadata failed");
337                        return Ok(io_error_code(&e));
338                    }
339                };
340                if !metadata.file_type().is_file() {
341                    tracing::warn!(path, "file_read: non-regular file rejected");
342                    return Ok(-4);
343                }
344                if metadata.len() > buf_len as u64 {
345                    // File is larger than the guest buffer; do not read.
346                    return Ok(-2);
347                }
348
349                // `buf_len` is already bounded by `MAX_BUF_LEN`, so this
350                // allocation is capped at 16 MiB.
351                let mut contents = Vec::new();
352                let mut limited = file.take(buf_len as u64);
353                if let Err(e) = limited.read_to_end(&mut contents) {
354                    tracing::warn!(error = %e, path, "file_read: read failed");
355                    return Ok(io_error_code(&e));
356                }
357
358                match memory.write(&mut caller, buf_ptr, &contents) {
359                    Ok(()) => i32::try_from(contents.len())
360                        .map_err(|_| wasmtime::Error::msg("file too large")),
361                    Err(e) => {
362                        // Never silently drop a memory.write failure.
363                        tracing::warn!(error = %e, "file_read: guest memory write failed");
364                        Ok(-1)
365                    }
366                }
367            },
368        )?;
369
370        linker.func_wrap(
371            HOST_NS,
372            "network_open",
373            |caller: Caller<'_, PluginState>| {
374                if caller.data().caps.contains(&Capability::Network) {
375                    Ok(())
376                } else {
377                    Err(wasmtime::Error::msg("unauthorized network open"))
378                }
379            },
380        )?;
381
382        // `ring_buffer_ptr() -> i64`
383        //
384        // Returns the byte offset of the shared ring region. For guests with an
385        // exported `memory` this is an offset into that memory; for guests that
386        // import `martensite.ring_memory` it is `0` in the imported memory.
387        // Traps when the plugin has no accessible ring region.
388        linker.func_wrap(
389            HOST_NS,
390            "ring_buffer_ptr",
391            |caller: Caller<'_, PluginState>| -> Result<i64, wasmtime::Error> {
392                match caller.data().ring {
393                    Some(RingLocation::GuestMemory { base }) => Ok(i64::from(base)),
394                    Some(RingLocation::Imported(_)) => Ok(0),
395                    None => Err(wasmtime::Error::msg(
396                        "no ring buffer: export `memory` or import `martensite.ring_memory`",
397                    )),
398                }
399            },
400        )?;
401
402        // `ring_buffer_capacity() -> i32`: payload capacity of the ring region.
403        linker.func_wrap(
404            HOST_NS,
405            "ring_buffer_capacity",
406            |caller: Caller<'_, PluginState>| -> Result<i32, wasmtime::Error> {
407                match caller.data().ring {
408                    Some(_) => i32::try_from(DEFAULT_CAPACITY)
409                        .map_err(|_| wasmtime::Error::msg("capacity overflow")),
410                    None => Err(wasmtime::Error::msg(
411                        "no ring buffer: export `memory` or import `martensite.ring_memory`",
412                    )),
413                }
414            },
415        )?;
416
417        // `ring_buffer_len() -> i64`: bytes currently stored in the ring.
418        linker.func_wrap(
419            HOST_NS,
420            "ring_buffer_len",
421            |mut caller: Caller<'_, PluginState>| -> Result<i64, wasmtime::Error> {
422                let (memory, base) = Self::ring_memory(&mut caller)?;
423                let mut header = [0u8; SHARED_HEADER_SIZE];
424                memory.read(&caller, base, &mut header)?;
425                let head = u32::from_le_bytes(header[0..4].try_into().unwrap_or_default());
426                let tail = u32::from_le_bytes(header[4..8].try_into().unwrap_or_default());
427                let cap = DEFAULT_CAPACITY as u32;
428                if head > cap || tail > cap {
429                    return Ok(0);
430                }
431                let len = if head == tail {
432                    0
433                } else if tail > head {
434                    tail - head
435                } else {
436                    cap - head + tail
437                };
438                Ok(i64::from(len))
439            },
440        )?;
441
442        // `ring_buffer_read(ring_offset, dst_ptr, len) -> i32`
443        //
444        // Copies `len` bytes from the ring region (offset relative to the
445        // region base, including the cursor header) into the guest's linear
446        // memory at `dst_ptr`. Returns `len`, or `-1` if the range is invalid.
447        linker.func_wrap(
448            HOST_NS,
449            "ring_buffer_read",
450            |mut caller: Caller<'_, PluginState>,
451             ring_offset: i32,
452             dst_ptr: i32,
453             len: i32|
454             -> Result<i32, wasmtime::Error> {
455                let (memory, base) = Self::ring_memory(&mut caller)?;
456                let (Ok(ring_offset), Ok(dst_ptr), Ok(len)) = (
457                    usize::try_from(ring_offset),
458                    usize::try_from(dst_ptr),
459                    usize::try_from(len),
460                ) else {
461                    return Ok(-1);
462                };
463                if ring_offset.saturating_add(len) > RING_BUFFER_REGION_SIZE {
464                    return Ok(-1);
465                }
466                let mut tmp = vec![0u8; len];
467                if memory
468                    .read(&caller, base.saturating_add(ring_offset), &mut tmp)
469                    .is_err()
470                {
471                    return Ok(-1);
472                }
473                let guest = Self::guest_memory(&mut caller, memory);
474                match guest.write(&mut caller, dst_ptr, &tmp) {
475                    Ok(()) => {
476                        i32::try_from(len).map_err(|_| wasmtime::Error::msg("ring read too large"))
477                    }
478                    Err(_) => Ok(-1),
479                }
480            },
481        )?;
482
483        // `ring_buffer_write(ring_offset, src_ptr, len) -> i32`
484        //
485        // Copies `len` bytes from the guest's linear memory at `src_ptr` into
486        // the ring region at `ring_offset` relative to the region base.
487        // Returns `len`, or `-1` if the range is invalid.
488        linker.func_wrap(
489            HOST_NS,
490            "ring_buffer_write",
491            |mut caller: Caller<'_, PluginState>,
492             ring_offset: i32,
493             src_ptr: i32,
494             len: i32|
495             -> Result<i32, wasmtime::Error> {
496                let (memory, base) = Self::ring_memory(&mut caller)?;
497                let (Ok(ring_offset), Ok(src_ptr), Ok(len)) = (
498                    usize::try_from(ring_offset),
499                    usize::try_from(src_ptr),
500                    usize::try_from(len),
501                ) else {
502                    return Ok(-1);
503                };
504                if ring_offset.saturating_add(len) > RING_BUFFER_REGION_SIZE {
505                    return Ok(-1);
506                }
507                let guest = Self::guest_memory(&mut caller, memory);
508                let mut tmp = vec![0u8; len];
509                if guest.read(&caller, src_ptr, &mut tmp).is_err() {
510                    return Ok(-1);
511                }
512                match memory.write(&mut caller, base.saturating_add(ring_offset), &tmp) {
513                    Ok(()) => {
514                        i32::try_from(len).map_err(|_| wasmtime::Error::msg("ring write too large"))
515                    }
516                    Err(_) => Ok(-1),
517                }
518            },
519        )?;
520
521        Ok(())
522    }
523
524    /// Resolves the memory backing the shared ring region and the region's
525    /// base offset within that memory.
526    fn ring_memory(
527        caller: &mut Caller<'_, PluginState>,
528    ) -> Result<(Memory, usize), wasmtime::Error> {
529        match caller.data().ring {
530            Some(RingLocation::GuestMemory { base }) => match caller.get_export("memory") {
531                Some(Extern::Memory(memory)) => Ok((memory, base as usize)),
532                _ => Err(wasmtime::Error::msg("missing guest memory")),
533            },
534            Some(RingLocation::Imported(memory)) => Ok((memory, 0)),
535            None => Err(wasmtime::Error::msg(
536                "no ring buffer: export `memory` or import `martensite.ring_memory`",
537            )),
538        }
539    }
540
541    /// Returns the guest's primary linear memory: its exported `memory` when
542    /// present, otherwise the ring memory itself (which is the guest's only
543    /// address space when it imports `martensite.ring_memory`).
544    fn guest_memory(caller: &mut Caller<'_, PluginState>, fallback: Memory) -> Memory {
545        match caller.get_export("memory") {
546            Some(Extern::Memory(memory)) => memory,
547            _ => fallback,
548        }
549    }
550
551    /// Returns the Wasmtime engine used by this runtime.
552    ///
553    /// Epoch interruption is configured for every plugin store. The host must
554    /// call [`Engine::increment_epoch`] on this engine on a 5 ms cadence to
555    /// enforce the wall-clock deadline in addition to the fuel budget.
556    pub fn engine(&self) -> &Engine {
557        &self.engine
558    }
559
560    /// Loads and instantiates a plugin with the given capability set.
561    ///
562    /// The returned [`PluginInstance`] is independent from the runtime and can
563    /// be invoked repeatedly until its fuel budget is exhausted.
564    ///
565    /// # Errors
566    ///
567    /// Returns an error if compilation, instantiation, or fuel setup fails.
568    pub fn load(
569        &self,
570        wasm_bytes: &[u8],
571        caps: CapabilitySet,
572    ) -> Result<PluginInstance, PluginError> {
573        let module = Module::new(&self.engine, wasm_bytes)?;
574
575        // A plugin obtains its shared ring buffer one of two ways:
576        //  1. It imports `martensite.ring_memory`, and the host supplies a
577        //     dedicated memory sized to `RING_BUFFER_REGION_SIZE`. This is the
578        //     preferred ABI because the region is guaranteed exclusive.
579        //  2. It exports `memory`; the host grows that memory at
580        //     instantiation time and uses the appended pages as the region.
581        //     Guests on this path must treat
582        //     `[ring_buffer_ptr(), ring_buffer_ptr() + RING_BUFFER_REGION_SIZE)`
583        //     as reserved so their allocator never hands it out.
584        let wants_ring_import = module.imports().any(|import| {
585            import.module() == HOST_NS
586                && import.name() == RING_MEMORY_IMPORT
587                && matches!(import.ty(), ExternType::Memory(_))
588        });
589
590        let wasi = WasiCtxBuilder::new().build_p1();
591        let state = PluginState {
592            wasi,
593            caps,
594            ring: None,
595        };
596        let mut store = Store::new(&self.engine, state);
597        store.set_fuel(self.fuel_budget)?;
598        store.set_epoch_deadline(1);
599
600        let ring_pages =
601            u32::try_from(RING_BUFFER_REGION_SIZE.div_ceil(WASM_PAGE_SIZE)).unwrap_or(u32::MAX);
602
603        let mut ring = None;
604        let instance = if wants_ring_import {
605            let memory = Memory::new(&mut store, MemoryType::new(ring_pages, None))?;
606            // The shared linker cannot hold a per-store item, so clone it and
607            // define the memory on the per-instance copy.
608            let mut linker = self.linker.clone();
609            linker.define(&mut store, HOST_NS, RING_MEMORY_IMPORT, memory)?;
610            let instance = linker.instantiate(&mut store, &module)?;
611            ring = Some(RingLocation::Imported(memory));
612            instance
613        } else {
614            let instance = self.linker.instantiate(&mut store, &module)?;
615            if let Some(memory) = instance.get_memory(&mut store, "memory") {
616                let old_pages = memory.grow(&mut store, u64::from(ring_pages))?;
617                let base = u32::try_from(old_pages as usize * WASM_PAGE_SIZE)
618                    .map_err(|_| wasmtime::Error::msg("ring buffer offset overflow"))?;
619                ring = Some(RingLocation::GuestMemory { base });
620            }
621            instance
622        };
623        store.data_mut().ring = ring;
624
625        Ok(PluginInstance { store, instance })
626    }
627}
628
629/// A running WebAssembly plugin instance.
630///
631/// Holds the Wasmtime [`Store`] and [`Instance`] for a single plugin. All
632/// calls happen in the context of this instance and consume its fuel budget.
633pub struct PluginInstance {
634    store: Store<PluginState>,
635    instance: Instance,
636}
637
638impl PluginInstance {
639    /// Invokes an exported function that takes no parameters and returns
640    /// nothing.
641    ///
642    /// # Errors
643    ///
644    /// Returns [`PluginError::MissingExport`] if the export does not exist or
645    /// has the wrong signature, or any other plugin error if execution fails.
646    ///
647    /// # Examples
648    ///
649    /// ```ignore
650    /// use martensite_plugin::{CapabilitySet, PluginRuntime};
651    ///
652    /// let runtime = PluginRuntime::new().unwrap();
653    /// let mut plugin = runtime.load(wasm_bytes, CapabilitySet::empty()).unwrap();
654    /// plugin.invoke("run").unwrap();
655    /// ```
656    pub fn invoke(&mut self, name: &str) -> Result<(), PluginError> {
657        self.invoke_typed(name, ())
658    }
659
660    /// Invokes an exported function with a statically checked WebAssembly
661    /// signature.
662    ///
663    /// # Errors
664    ///
665    /// Returns [`PluginError::MissingExport`] if the export does not exist or
666    /// has a signature different from `Args -> Rets`, or any execution error
667    /// produced by the plugin.
668    pub fn invoke_typed<Args, Rets>(&mut self, name: &str, args: Args) -> Result<Rets, PluginError>
669    where
670        Args: WasmParams,
671        Rets: WasmResults,
672    {
673        let func = self
674            .instance
675            .get_typed_func::<Args, Rets>(&mut self.store, name)
676            .map_err(|_| PluginError::MissingExport(name.to_string()))?;
677        Ok(func.call(&mut self.store, args)?)
678    }
679
680    /// Drains paint commands from this instance's shared ring buffer.
681    ///
682    /// The ring region lives inside guest-visible linear memory: either a
683    /// host-grown region of the guest's exported `memory`, or the dedicated
684    /// `martensite.ring_memory` import. The guest produces commands by writing
685    /// `PluginPaintCmd` records plus the `head`/`tail` cursor header directly;
686    /// this call consumes and clears them. Does nothing when the plugin has no
687    /// ring region.
688    pub fn drain_paint_commands(&mut self, f: impl FnMut(&PluginPaintCmd, &[u8])) {
689        let (memory, base) = match self.store.data().ring {
690            Some(RingLocation::GuestMemory { base }) => {
691                match self.instance.get_memory(&mut self.store, "memory") {
692                    Some(memory) => (memory, base as usize),
693                    None => return,
694                }
695            }
696            Some(RingLocation::Imported(memory)) => (memory, 0),
697            None => return,
698        };
699        let data = memory.data_mut(&mut self.store);
700        let end = base.saturating_add(RING_BUFFER_REGION_SIZE).min(data.len());
701        if end <= base {
702            return;
703        }
704        let mut ring_buffer = PluginRingBuffer::new_shared(&mut data[base..end]);
705        ring_buffer.drain(f);
706    }
707
708    /// Returns a reference to the capability set active for this instance.
709    pub fn capabilities(&self) -> &CapabilitySet {
710        self.store.data().capabilities()
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use std::path::PathBuf;
718
719    fn compile_wat(wat: &str) -> Vec<u8> {
720        wat::parse_str(wat).expect("valid WAT")
721    }
722
723    #[test]
724    fn runtime_cannot_load_invalid_wasm() {
725        let runtime = PluginRuntime::new().unwrap();
726        assert!(runtime.load(b"not wasm", CapabilitySet::empty()).is_err());
727    }
728
729    #[test]
730    fn guest_is_terminated_on_infinite_loop() {
731        let wat = r#"
732            (module
733              (func (export "run")
734                (loop (br 0))
735              )
736            )
737        "#;
738        let runtime = PluginRuntime::with_fuel_budget(10_000).unwrap();
739        let mut plugin = runtime
740            .load(&compile_wat(wat), CapabilitySet::empty())
741            .unwrap();
742        let err = plugin.invoke("run").unwrap_err();
743        assert!(
744            matches!(err, PluginError::OutOfFuel),
745            "expected OutOfFuel, got {err}"
746        );
747    }
748
749    #[test]
750    fn authorized_host_call_succeeds() {
751        let wat = r#"
752            (module
753              (import "martensite" "signal_read" (func $signal_read (param i64)))
754              (func (export "run")
755                i64.const 42
756                call $signal_read
757              )
758            )
759        "#;
760        let id = SignalId(42);
761        let caps = CapabilitySet::builder()
762            .grant(Capability::SignalRead(id))
763            .build();
764
765        let runtime = PluginRuntime::with_fuel_budget(50_000).unwrap();
766        let mut plugin = runtime.load(&compile_wat(wat), caps).unwrap();
767        plugin.invoke("run").unwrap();
768    }
769
770    #[test]
771    fn unauthorized_host_call_traps() {
772        let wat = r#"
773            (module
774              (import "martensite" "signal_read" (func $signal_read (param i64)))
775              (func (export "run")
776                i64.const 7
777                call $signal_read
778              )
779            )
780        "#;
781        let runtime = PluginRuntime::with_fuel_budget(50_000).unwrap();
782        let mut plugin = runtime
783            .load(&compile_wat(wat), CapabilitySet::empty())
784            .unwrap();
785        // The host function rejects the call because the capability was not
786        // granted, so WebAssembly execution traps and returns an error.
787        assert!(plugin.invoke("run").is_err());
788    }
789
790    /// Encodes a string as a WAT data-segment literal using `\xx` escapes so
791    /// arbitrary path bytes can be embedded in a module.
792    fn wat_str(bytes: &[u8]) -> String {
793        bytes.iter().map(|b| format!("\\{b:02x}")).collect()
794    }
795
796    #[test]
797    fn file_read_requires_matching_path_capability() {
798        let dir = std::env::temp_dir().join(format!("martensite-plugin-{}", std::process::id()));
799        std::fs::create_dir_all(&dir).unwrap();
800        let path = dir.join("file_read_test.txt");
801        std::fs::write(&path, b"hello plugin").unwrap();
802        let path_str = path.to_string_lossy();
803        let escaped = wat_str(path_str.as_bytes());
804        let path_len = path_str.len();
805        let wat = format!(
806            r#"
807            (module
808              (import "martensite" "file_read"
809                (func $file_read (param i32 i32 i32 i32) (result i32)))
810              (memory (export "memory") 1)
811              (data (i32.const 16) "{escaped}")
812              (func (export "run") (result i32)
813                i32.const 16
814                i32.const {path_len}
815                i32.const 1024
816                i32.const 64
817                call $file_read
818              )
819              (func (export "buf") (param i32) (result i32)
820                local.get 0
821                i32.load8_u offset=1024)
822            )
823            "#
824        );
825        let wasm = compile_wat(&wat);
826        let runtime = PluginRuntime::new().unwrap();
827        let mut unauthorized = runtime.load(&wasm, CapabilitySet::empty()).unwrap();
828        assert!(unauthorized.invoke_typed::<(), i32>("run", ()).is_err());
829
830        // A different path must not satisfy the grant.
831        let wrong_caps = CapabilitySet::builder()
832            .grant(Capability::FileRead(dir.join("other.txt")))
833            .build();
834        let mut denied = runtime.load(&wasm, wrong_caps).unwrap();
835        assert!(denied.invoke_typed::<(), i32>("run", ()).is_err());
836
837        let caps = CapabilitySet::builder()
838            .grant(Capability::FileRead(PathBuf::from(path_str.as_ref())))
839            .build();
840        let mut authorized = runtime.load(&wasm, caps).unwrap();
841        let read = authorized.invoke_typed::<(), i32>("run", ()).unwrap();
842        assert_eq!(read, 12);
843        for (i, expected) in b"hello plugin".iter().enumerate() {
844            let got = authorized
845                .invoke_typed::<i32, i32>("buf", i as i32)
846                .unwrap();
847            assert_eq!(got as u8, *expected);
848        }
849        std::fs::remove_dir_all(&dir).ok();
850    }
851
852    #[test]
853    fn file_read_reports_io_errors_and_oversized_buffers() {
854        let dir = std::env::temp_dir().join(format!("martensite-plugin-io-{}", std::process::id()));
855        std::fs::create_dir_all(&dir).unwrap();
856        let path = dir.join("big.bin");
857        std::fs::write(&path, vec![7u8; 100]).unwrap();
858        let path_str = path.to_string_lossy();
859        let escaped = wat_str(path_str.as_bytes());
860        let missing = dir.join("does_not_exist.bin");
861        let missing_str = missing.to_string_lossy();
862        let missing_escaped = wat_str(missing_str.as_bytes());
863        let wat = format!(
864            r#"
865            (module
866              (import "martensite" "file_read"
867                (func $file_read (param i32 i32 i32 i32) (result i32)))
868              (memory (export "memory") 1)
869              (data (i32.const 16) "{escaped}")
870              (data (i32.const 4096) "{missing_escaped}")
871              (func (export "small_buf") (result i32)
872                i32.const 16 i32.const {} i32.const 2048 i32.const 10
873                call $file_read)
874              (func (export "missing") (result i32)
875                i32.const 4096 i32.const {} i32.const 2048 i32.const 200
876                call $file_read)
877            )
878            "#,
879            path_str.len(),
880            missing_str.len()
881        );
882        let caps = CapabilitySet::builder()
883            .grant(Capability::FileRead(PathBuf::from(path_str.as_ref())))
884            .grant(Capability::FileRead(PathBuf::from(missing_str.as_ref())))
885            .build();
886        let runtime = PluginRuntime::new().unwrap();
887        let mut plugin = runtime.load(&compile_wat(&wat), caps).unwrap();
888        assert_eq!(plugin.invoke_typed::<(), i32>("small_buf", ()).unwrap(), -2);
889        assert_eq!(plugin.invoke_typed::<(), i32>("missing", ()).unwrap(), -1);
890        std::fs::remove_dir_all(&dir).ok();
891    }
892
893    #[test]
894    fn network_open_requires_capability() {
895        let wat = r#"
896            (module
897              (import "martensite" "network_open" (func $network_open))
898              (func (export "run") call $network_open)
899            )
900        "#;
901        let runtime = PluginRuntime::new().unwrap();
902        let mut unauthorized = runtime
903            .load(&compile_wat(wat), CapabilitySet::empty())
904            .unwrap();
905        assert!(unauthorized.invoke("run").is_err());
906
907        let caps = CapabilitySet::builder().grant(Capability::Network).build();
908        let mut authorized = runtime.load(&compile_wat(wat), caps).unwrap();
909        authorized.invoke("run").unwrap();
910    }
911
912    #[test]
913    fn typed_invoke_and_ring_buffer_exports_work() {
914        let wat = r#"
915            (module
916              (import "martensite" "ring_buffer_ptr" (func $ptr (result i64)))
917              (import "martensite" "ring_buffer_capacity" (func $capacity (result i32)))
918              (import "martensite" "ring_buffer_len" (func $len (result i64)))
919              (import "martensite" "ring_buffer_read"
920                (func $read (param i32 i32 i32) (result i32)))
921              (memory (export "memory") 1)
922              (func (export "add_one") (param i32) (result i32)
923                local.get 0
924                i32.const 1
925                i32.add)
926              (func (export "ptr") (result i64) call $ptr)
927              (func (export "capacity") (result i32) call $capacity)
928              (func (export "ring_len") (result i64) call $len)
929              ;; Produce one 4-byte-payload command directly into the region.
930              (func (export "make_cmd")
931                (local $p i32)
932                call $ptr
933                i32.wrap_i64
934                local.set $p
935                ;; head (already 0) at $p; tail = 16 at $p+4
936                local.get $p i32.const 16 i32.store offset=4
937                ;; record at $p+8: cmd_type=1|flags=0, data_len=4, offset=12
938                local.get $p i32.const 1 i32.store offset=8
939                local.get $p i32.const 4 i32.store offset=12
940                local.get $p i32.const 12 i32.store offset=16
941                ;; payload 0x0A0B0C0D at $p+20
942                local.get $p i32.const 0x0A0B0C0D i32.store offset=20)
943              ;; Copy 4 payload bytes out of the region via the host helper.
944              (func (export "copy_out") (result i32)
945                i32.const 20 i32.const 4096 i32.const 4
946                call $read)
947              (func (export "copied") (result i32)
948                i32.const 4096 i32.load)
949            )
950        "#;
951        let runtime = PluginRuntime::new().unwrap();
952        let mut plugin = runtime
953            .load(&compile_wat(wat), CapabilitySet::empty())
954            .unwrap();
955        assert_eq!(plugin.invoke_typed::<i32, i32>("add_one", 41).unwrap(), 42);
956        // The region is appended to the guest's single initial page.
957        assert_eq!(
958            plugin.invoke_typed::<(), i64>("ptr", ()).unwrap(),
959            WASM_PAGE_SIZE as i64
960        );
961        assert_eq!(
962            plugin.invoke_typed::<(), i32>("capacity", ()).unwrap(),
963            DEFAULT_CAPACITY as i32
964        );
965        assert_eq!(plugin.invoke_typed::<(), i64>("ring_len", ()).unwrap(), 0);
966
967        plugin.invoke("make_cmd").unwrap();
968        assert_eq!(
969            plugin.invoke_typed::<(), i64>("ring_len", ()).unwrap(),
970            (PluginPaintCmd::header_size() + 4) as i64
971        );
972        assert_eq!(plugin.invoke_typed::<(), i32>("copy_out", ()).unwrap(), 4);
973        assert_eq!(
974            plugin.invoke_typed::<(), i32>("copied", ()).unwrap() as u32,
975            0x0A0B0C0D
976        );
977
978        let mut seen = 0;
979        plugin.drain_paint_commands(|cmd, payload| {
980            seen += 1;
981            assert_eq!(cmd.cmd_type, 1);
982            assert_eq!(cmd.data_len, 4);
983            assert_eq!(payload, &[0x0D, 0x0C, 0x0B, 0x0A]);
984        });
985        assert_eq!(seen, 1);
986        // The cursor header was written back to guest memory on drain.
987        assert_eq!(plugin.invoke_typed::<(), i64>("ring_len", ()).unwrap(), 0);
988        assert!(std::ptr::eq(runtime.engine(), &runtime.engine));
989    }
990
991    #[test]
992    fn imported_ring_memory_is_host_supplied() {
993        let wat = r#"
994            (module
995              (import "martensite" "ring_memory" (memory 5))
996              (import "martensite" "ring_buffer_ptr" (func $ptr (result i64)))
997              (import "martensite" "ring_buffer_capacity" (func $cap (result i32)))
998              (import "martensite" "ring_buffer_write"
999                (func $write (param i32 i32 i32) (result i32)))
1000              (func (export "ptr") (result i64) call $ptr)
1001              (func (export "cap") (result i32) call $cap)
1002              ;; Write a command through the host helper: src is this same
1003              ;; memory (it is the guest's only address space).
1004              (func (export "make_cmd")
1005                ;; stage the record bytes in scratch space beyond the region
1006                i32.const 300000 i32.const 1 i32.store
1007                i32.const 300004 i32.const 4 i32.store
1008                i32.const 300008 i32.const 12 i32.store
1009                i32.const 300012 i32.const 0x11223344 i32.store
1010                ;; tail = 16
1011                i32.const 0 i32.const 16 i32.store offset=4
1012                ;; copy record header+payload into region offset 8
1013                i32.const 8 i32.const 300000 i32.const 16
1014                call $write
1015                drop)
1016            )
1017        "#;
1018        let runtime = PluginRuntime::new().unwrap();
1019        let mut plugin = runtime
1020            .load(&compile_wat(wat), CapabilitySet::empty())
1021            .unwrap();
1022        assert_eq!(plugin.invoke_typed::<(), i64>("ptr", ()).unwrap(), 0);
1023        assert_eq!(
1024            plugin.invoke_typed::<(), i32>("cap", ()).unwrap(),
1025            DEFAULT_CAPACITY as i32
1026        );
1027        plugin.invoke("make_cmd").unwrap();
1028        let mut seen = 0;
1029        plugin.drain_paint_commands(|cmd, payload| {
1030            seen += 1;
1031            assert_eq!(cmd.cmd_type, 1);
1032            assert_eq!(payload, &[0x44, 0x33, 0x22, 0x11]);
1033        });
1034        assert_eq!(seen, 1);
1035    }
1036
1037    #[test]
1038    fn ring_buffer_without_any_memory_traps() {
1039        let wat = r#"
1040            (module
1041              (import "martensite" "ring_buffer_ptr" (func $ptr (result i64)))
1042              (func (export "ptr") (result i64) call $ptr)
1043            )
1044        "#;
1045        let runtime = PluginRuntime::new().unwrap();
1046        let mut plugin = runtime
1047            .load(&compile_wat(wat), CapabilitySet::empty())
1048            .unwrap();
1049        assert!(plugin.invoke_typed::<(), i64>("ptr", ()).is_err());
1050        // Draining is a no-op rather than a panic.
1051        plugin.drain_paint_commands(|_, _| panic!("no ring region exists"));
1052    }
1053
1054    #[test]
1055    fn empty_capability_set_is_default_for_load() {
1056        let runtime = PluginRuntime::new().unwrap();
1057        let wat = r#"
1058            (module
1059              (func (export "run"))
1060            )
1061        "#;
1062        let mut plugin = runtime
1063            .load(&compile_wat(wat), CapabilitySet::empty())
1064            .unwrap();
1065        plugin.invoke("run").unwrap();
1066        assert!(plugin.capabilities().is_empty());
1067    }
1068}