1use std::fmt;
10use std::path::PathBuf;
11
12use wasmtime::{
13 Caller, Config, Engine, Extern, Instance, Linker, Module, Store, Trap, WasmParams, WasmResults,
14};
15use wasmtime_wasi::preview1::{self, WasiP1Ctx};
16use wasmtime_wasi::WasiCtxBuilder;
17
18use crate::ring_buffer::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
19use crate::security::{Capability, CapabilitySet};
20use martensite_reactive::SignalId;
21
22pub const DEFAULT_FUEL_BUDGET: u64 = 200_000;
29
30const HOST_NS: &str = "martensite";
32
33pub struct PluginState {
38 wasi: WasiP1Ctx,
39 caps: CapabilitySet,
40 ring_buffer: Vec<u8>,
41}
42
43impl fmt::Debug for PluginState {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 f.debug_struct("PluginState")
46 .field("capabilities", &self.caps)
47 .finish_non_exhaustive()
48 }
49}
50
51impl PluginState {
52 pub fn capabilities(&self) -> &CapabilitySet {
54 &self.caps
55 }
56}
57
58#[derive(Debug)]
60pub enum PluginError {
61 Wasmtime(wasmtime::Error),
63 MissingExport(String),
65 OutOfFuel,
67}
68
69impl fmt::Display for PluginError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 PluginError::Wasmtime(e) => write!(f, "wasmtime error: {e}"),
73 PluginError::MissingExport(name) => write!(f, "missing export: {name}"),
74 PluginError::OutOfFuel => write!(f, "plugin ran out of fuel"),
75 }
76 }
77}
78
79impl std::error::Error for PluginError {}
80
81impl From<wasmtime::Error> for PluginError {
82 fn from(err: wasmtime::Error) -> Self {
83 if err
84 .downcast_ref::<Trap>()
85 .is_some_and(|t| matches!(t, Trap::OutOfFuel))
86 {
87 PluginError::OutOfFuel
88 } else {
89 PluginError::Wasmtime(err)
90 }
91 }
92}
93
94pub struct PluginRuntime {
110 engine: Engine,
111 linker: Linker<PluginState>,
112 fuel_budget: u64,
113}
114
115impl PluginRuntime {
116 pub fn new() -> Result<Self, PluginError> {
122 Self::with_fuel_budget(DEFAULT_FUEL_BUDGET)
123 }
124
125 pub fn with_fuel_budget(fuel_budget: u64) -> Result<Self, PluginError> {
131 let mut config = Config::new();
132 config.consume_fuel(true);
133 config.epoch_interruption(true);
134 let engine = Engine::new(&config)?;
135
136 let mut linker = Linker::<PluginState>::new(&engine);
137 preview1::add_to_linker_sync(&mut linker, |state: &mut PluginState| &mut state.wasi)?;
138 Self::add_host_functions(&mut linker)?;
139
140 Ok(Self {
141 engine,
142 linker,
143 fuel_budget,
144 })
145 }
146
147 fn add_host_functions(linker: &mut Linker<PluginState>) -> Result<(), PluginError> {
148 linker.func_wrap(
149 HOST_NS,
150 "signal_read",
151 |caller: Caller<'_, PluginState>, id: i64| {
152 let state = caller.data();
153 let cap = Capability::SignalRead(SignalId(id as u64));
154 if state.caps.contains(&cap) {
155 Ok(())
156 } else {
157 Err(wasmtime::Error::msg("unauthorized signal read"))
158 }
159 },
160 )?;
161
162 linker.func_wrap(
163 HOST_NS,
164 "signal_write",
165 |caller: Caller<'_, PluginState>, id: i64| {
166 let state = caller.data();
167 let cap = Capability::SignalWrite(SignalId(id as u64));
168 if state.caps.contains(&cap) {
169 Ok(())
170 } else {
171 Err(wasmtime::Error::msg("unauthorized signal write"))
172 }
173 },
174 )?;
175
176 linker.func_wrap(
177 HOST_NS,
178 "file_read",
179 |mut caller: Caller<'_, PluginState>,
180 path_ptr: i32,
181 path_len: i32,
182 _buf_ptr: i32,
183 _buf_len: i32| {
184 let path_ptr = usize::try_from(path_ptr)
185 .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
186 let path_len = usize::try_from(path_len)
187 .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
188 let memory = match caller.get_export("memory") {
189 Some(Extern::Memory(memory)) => memory,
190 _ => return Err(wasmtime::Error::msg("missing guest memory")),
191 };
192 let mut path_bytes = vec![0; path_len];
193 memory.read(&caller, path_ptr, &mut path_bytes)?;
194 let path = std::str::from_utf8(&path_bytes)
195 .map_err(|_| wasmtime::Error::msg("invalid file read path"))?;
196 if !caller
197 .data()
198 .caps
199 .contains(&Capability::FileRead(PathBuf::from(path)))
200 {
201 return Err(wasmtime::Error::msg("unauthorized file read"));
202 }
203
204 Ok(())
207 },
208 )?;
209
210 linker.func_wrap(
211 HOST_NS,
212 "network_open",
213 |caller: Caller<'_, PluginState>| {
214 if caller.data().caps.contains(&Capability::Network) {
215 Ok(())
216 } else {
217 Err(wasmtime::Error::msg("unauthorized network open"))
218 }
219 },
220 )?;
221
222 linker.func_wrap(HOST_NS, "ring_buffer_ptr", || -> i64 { 0 })?;
223 linker.func_wrap(HOST_NS, "ring_buffer_capacity", || -> i32 {
224 DEFAULT_CAPACITY as i32
225 })?;
226
227 Ok(())
228 }
229
230 pub fn engine(&self) -> &Engine {
236 &self.engine
237 }
238
239 pub fn load(
248 &self,
249 wasm_bytes: &[u8],
250 caps: CapabilitySet,
251 ) -> Result<PluginInstance, PluginError> {
252 let module = Module::new(&self.engine, wasm_bytes)?;
253
254 let wasi = WasiCtxBuilder::new().build_p1();
255 let state = PluginState {
256 wasi,
257 caps,
258 ring_buffer: vec![0; DEFAULT_CAPACITY],
259 };
260 let mut store = Store::new(&self.engine, state);
261 store.set_fuel(self.fuel_budget)?;
262 store.set_epoch_deadline(1);
263
264 let instance = self.linker.instantiate(&mut store, &module)?;
265
266 Ok(PluginInstance { store, instance })
267 }
268}
269
270pub struct PluginInstance {
275 store: Store<PluginState>,
276 instance: Instance,
277}
278
279impl PluginInstance {
280 pub fn invoke(&mut self, name: &str) -> Result<(), PluginError> {
298 self.invoke_typed(name, ())
299 }
300
301 pub fn invoke_typed<Args, Rets>(&mut self, name: &str, args: Args) -> Result<Rets, PluginError>
310 where
311 Args: WasmParams,
312 Rets: WasmResults,
313 {
314 let func = self
315 .instance
316 .get_typed_func::<Args, Rets>(&mut self.store, name)
317 .map_err(|_| PluginError::MissingExport(name.to_string()))?;
318 Ok(func.call(&mut self.store, args)?)
319 }
320
321 pub fn drain_paint_commands(&mut self, f: impl FnMut(&PluginPaintCmd, &[u8])) {
327 let mut ring_buffer = PluginRingBuffer::new(&mut self.store.data_mut().ring_buffer);
328 ring_buffer.drain(f);
329 }
330
331 pub fn capabilities(&self) -> &CapabilitySet {
333 self.store.data().capabilities()
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 fn compile_wat(wat: &str) -> Vec<u8> {
342 wat::parse_str(wat).expect("valid WAT")
343 }
344
345 #[test]
346 fn runtime_cannot_load_invalid_wasm() {
347 let runtime = PluginRuntime::new().unwrap();
348 assert!(runtime.load(b"not wasm", CapabilitySet::empty()).is_err());
349 }
350
351 #[test]
352 fn guest_is_terminated_on_infinite_loop() {
353 let wat = r#"
354 (module
355 (func (export "run")
356 (loop (br 0))
357 )
358 )
359 "#;
360 let runtime = PluginRuntime::with_fuel_budget(10_000).unwrap();
361 let mut plugin = runtime
362 .load(&compile_wat(wat), CapabilitySet::empty())
363 .unwrap();
364 let err = plugin.invoke("run").unwrap_err();
365 assert!(
366 matches!(err, PluginError::OutOfFuel),
367 "expected OutOfFuel, got {err}"
368 );
369 }
370
371 #[test]
372 fn authorized_host_call_succeeds() {
373 let wat = r#"
374 (module
375 (import "martensite" "signal_read" (func $signal_read (param i64)))
376 (func (export "run")
377 i64.const 42
378 call $signal_read
379 )
380 )
381 "#;
382 let id = SignalId(42);
383 let caps = CapabilitySet::builder()
384 .grant(Capability::SignalRead(id))
385 .build();
386
387 let runtime = PluginRuntime::with_fuel_budget(50_000).unwrap();
388 let mut plugin = runtime.load(&compile_wat(wat), caps).unwrap();
389 plugin.invoke("run").unwrap();
390 }
391
392 #[test]
393 fn unauthorized_host_call_traps() {
394 let wat = r#"
395 (module
396 (import "martensite" "signal_read" (func $signal_read (param i64)))
397 (func (export "run")
398 i64.const 7
399 call $signal_read
400 )
401 )
402 "#;
403 let runtime = PluginRuntime::with_fuel_budget(50_000).unwrap();
404 let mut plugin = runtime
405 .load(&compile_wat(wat), CapabilitySet::empty())
406 .unwrap();
407 assert!(plugin.invoke("run").is_err());
410 }
411
412 #[test]
413 fn file_read_requires_matching_path_capability() {
414 let wat = r#"
415 (module
416 (import "martensite" "file_read"
417 (func $file_read (param i32 i32 i32 i32)))
418 (memory (export "memory") 1)
419 (data (i32.const 16) "/assets")
420 (func (export "run")
421 i32.const 16
422 i32.const 7
423 i32.const 0
424 i32.const 0
425 call $file_read
426 )
427 )
428 "#;
429 let runtime = PluginRuntime::new().unwrap();
430 let mut unauthorized = runtime
431 .load(&compile_wat(wat), CapabilitySet::empty())
432 .unwrap();
433 assert!(unauthorized.invoke("run").is_err());
434
435 let caps = CapabilitySet::builder()
436 .grant(Capability::FileRead(PathBuf::from("/assets")))
437 .build();
438 let mut authorized = runtime.load(&compile_wat(wat), caps).unwrap();
439 authorized.invoke("run").unwrap();
440 }
441
442 #[test]
443 fn network_open_requires_capability() {
444 let wat = r#"
445 (module
446 (import "martensite" "network_open" (func $network_open))
447 (func (export "run") call $network_open)
448 )
449 "#;
450 let runtime = PluginRuntime::new().unwrap();
451 let mut unauthorized = runtime
452 .load(&compile_wat(wat), CapabilitySet::empty())
453 .unwrap();
454 assert!(unauthorized.invoke("run").is_err());
455
456 let caps = CapabilitySet::builder().grant(Capability::Network).build();
457 let mut authorized = runtime.load(&compile_wat(wat), caps).unwrap();
458 authorized.invoke("run").unwrap();
459 }
460
461 #[test]
462 fn typed_invoke_and_ring_buffer_exports_work() {
463 let wat = r#"
464 (module
465 (import "martensite" "ring_buffer_ptr" (func $ptr (result i64)))
466 (import "martensite" "ring_buffer_capacity" (func $capacity (result i32)))
467 (func (export "add_one") (param i32) (result i32)
468 local.get 0
469 i32.const 1
470 i32.add)
471 (func (export "ptr") (result i64) call $ptr)
472 (func (export "capacity") (result i32) call $capacity)
473 )
474 "#;
475 let runtime = PluginRuntime::new().unwrap();
476 let mut plugin = runtime
477 .load(&compile_wat(wat), CapabilitySet::empty())
478 .unwrap();
479 assert_eq!(plugin.invoke_typed::<i32, i32>("add_one", 41).unwrap(), 42);
480 assert_eq!(plugin.invoke_typed::<(), i64>("ptr", ()).unwrap(), 0);
481 assert_eq!(
482 plugin.invoke_typed::<(), i32>("capacity", ()).unwrap(),
483 DEFAULT_CAPACITY as i32
484 );
485 plugin.drain_paint_commands(|_, _| panic!("new buffer must be empty"));
486 assert!(std::ptr::eq(runtime.engine(), &runtime.engine));
487 }
488
489 #[test]
490 fn empty_capability_set_is_default_for_load() {
491 let runtime = PluginRuntime::new().unwrap();
492 let wat = r#"
493 (module
494 (func (export "run"))
495 )
496 "#;
497 let mut plugin = runtime
498 .load(&compile_wat(wat), CapabilitySet::empty())
499 .unwrap();
500 plugin.invoke("run").unwrap();
501 assert!(plugin.capabilities().is_empty());
502 }
503}