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;
59
60const HOST_NS: &str = "martensite";
62
63const RING_MEMORY_IMPORT: &str = "ring_memory";
66
67const WASM_PAGE_SIZE: usize = 65_536;
69
70pub const RING_BUFFER_REGION_SIZE: usize = SHARED_HEADER_SIZE + DEFAULT_CAPACITY;
87
88#[derive(Clone, Copy, Debug)]
90enum RingLocation {
91 GuestMemory { base: u32 },
94 Imported(Memory),
97}
98
99pub 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 pub fn capabilities(&self) -> &CapabilitySet {
143 &self.caps
144 }
145}
146
147#[derive(Debug)]
161pub enum PluginError {
162 Wasmtime(wasmtime::Error),
164 MissingExport(String),
166 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
195fn 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
209pub struct PluginRuntime {
225 engine: Engine,
226 linker: Linker<PluginState>,
227 fuel_budget: u64,
228}
229
230impl PluginRuntime {
231 pub fn new() -> Result<Self, PluginError> {
245 Self::with_fuel_budget(DEFAULT_FUEL_BUDGET)
246 }
247
248 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 preview1::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 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 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 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 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 return Ok(-2);
406 }
407
408 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 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 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 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 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 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 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 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 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 pub fn engine(&self) -> &Engine {
625 &self.engine
626 }
627
628 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 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 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
707pub struct PluginInstance {
723 store: Store<PluginState>,
724 instance: Instance,
725}
726
727impl PluginInstance {
728 pub fn invoke(&mut self, name: &str) -> Result<(), PluginError> {
746 self.invoke_typed(name, ())
747 }
748
749 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 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 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 assert!(plugin.invoke("run").is_err());
914 }
915
916 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 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 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 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 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}