1use 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
44pub const DEFAULT_FUEL_BUDGET: u64 = 200_000;
51
52const HOST_NS: &str = "martensite";
54
55const RING_MEMORY_IMPORT: &str = "ring_memory";
58
59const WASM_PAGE_SIZE: usize = 65_536;
61
62pub const RING_BUFFER_REGION_SIZE: usize = SHARED_HEADER_SIZE + DEFAULT_CAPACITY;
79
80#[derive(Clone, Copy, Debug)]
82enum RingLocation {
83 GuestMemory { base: u32 },
86 Imported(Memory),
89}
90
91pub 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 pub fn capabilities(&self) -> &CapabilitySet {
112 &self.caps
113 }
114}
115
116#[derive(Debug)]
118pub enum PluginError {
119 Wasmtime(wasmtime::Error),
121 MissingExport(String),
123 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
152fn 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
166pub struct PluginRuntime {
182 engine: Engine,
183 linker: Linker<PluginState>,
184 fuel_budget: u64,
185}
186
187impl PluginRuntime {
188 pub fn new() -> Result<Self, PluginError> {
194 Self::with_fuel_budget(DEFAULT_FUEL_BUDGET)
195 }
196
197 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 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 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 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 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 return Ok(-2);
347 }
348
349 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 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 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 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 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 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 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 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 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 pub fn engine(&self) -> &Engine {
557 &self.engine
558 }
559
560 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 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 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
629pub struct PluginInstance {
634 store: Store<PluginState>,
635 instance: Instance,
636}
637
638impl PluginInstance {
639 pub fn invoke(&mut self, name: &str) -> Result<(), PluginError> {
657 self.invoke_typed(name, ())
658 }
659
660 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 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 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 assert!(plugin.invoke("run").is_err());
788 }
789
790 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 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 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 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 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}