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