1use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::SystemTime;
29
30use anyhow::{Context, Result};
31use wasmtime::{Engine, Linker, Module};
32
33use crate::sandbox;
34use crate::types::{DirectiveWrapper, PluginInput, PluginOp, PluginOutput};
35
36fn materialize_ops(input: &[DirectiveWrapper], output: &PluginOutput) -> Vec<DirectiveWrapper> {
45 let mut out = Vec::with_capacity(output.ops.len());
46 for op in &output.ops {
47 match op {
48 PluginOp::Keep(i) => {
49 if let Some(w) = input.get(*i) {
50 out.push(w.clone());
51 }
52 }
53 PluginOp::Modify(_, w) | PluginOp::Insert(w) => out.push(w.clone()),
54 PluginOp::Delete(_) => {}
55 }
56 }
57 out
58}
59
60#[derive(Debug, Clone)]
68pub struct RuntimeConfig {
69 pub max_memory: usize,
72 pub max_time_secs: u64,
76}
77
78impl Default for RuntimeConfig {
79 fn default() -> Self {
80 Self {
81 max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
86 max_time_secs: crate::sandbox::DEFAULT_SANDBOX_MAX_TIME_SECS,
87 }
88 }
89}
90
91pub fn validate_plugin_module(bytes: &[u8]) -> Result<()> {
102 let engine = Engine::new(&sandbox::sandbox_config())?;
108 let module = Module::new(&engine, bytes)?;
109 validate_loaded_module(&module)
110}
111
112fn validate_loaded_module(module: &Module) -> Result<()> {
117 if let Some(import) = module.imports().next() {
119 anyhow::bail!(
120 "plugin has forbidden import: {}::{}",
121 import.module(),
122 import.name()
123 );
124 }
125
126 let exports: Vec<_> = module.exports().map(|e| e.name()).collect();
128
129 if !exports.contains(&"memory") {
130 anyhow::bail!("plugin must export 'memory'");
131 }
132 if !exports.contains(&"alloc") {
133 anyhow::bail!("plugin must export 'alloc' function");
134 }
135 if !exports.contains(&"process") {
136 anyhow::bail!("plugin must export 'process' function");
137 }
138
139 Ok(())
140}
141
142pub struct Plugin {
144 name: String,
146 module: Module,
148 engine: Arc<Engine>,
150}
151
152impl Plugin {
153 pub fn load(path: &Path, _config: &RuntimeConfig) -> Result<Self> {
155 let name = path
156 .file_stem()
157 .and_then(|s| s.to_str())
158 .unwrap_or("unknown")
159 .to_string();
160
161 let engine = sandbox::shared_engine();
166
167 let wasm_bytes =
169 std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
170
171 let module = Module::new(&engine, &wasm_bytes)
172 .map_err(anyhow::Error::from)
173 .with_context(|| format!("invalid plugin {}", path.display()))?;
174
175 validate_loaded_module(&module)
179 .with_context(|| format!("invalid plugin {}", path.display()))?;
180
181 Ok(Self {
182 name,
183 module,
184 engine,
185 })
186 }
187
188 pub fn load_bytes(
190 name: impl Into<String>,
191 bytes: &[u8],
192 _config: &RuntimeConfig,
193 ) -> Result<Self> {
194 let name = name.into();
195 let engine = sandbox::shared_engine();
196 let module = Module::new(&engine, bytes)
197 .map_err(anyhow::Error::from)
198 .with_context(|| format!("invalid plugin `{name}`"))?;
199 validate_loaded_module(&module).with_context(|| format!("invalid plugin `{name}`"))?;
202
203 Ok(Self {
204 name,
205 module,
206 engine,
207 })
208 }
209
210 pub fn name(&self) -> &str {
212 &self.name
213 }
214
215 pub fn execute(&self, input: &PluginInput, config: &RuntimeConfig) -> Result<PluginOutput> {
217 let mut store =
224 sandbox::make_sandboxed_store(&self.engine, config.max_memory, config.max_time_secs)?;
225
226 let linker = Linker::new(&self.engine);
229
230 let instance = linker.instantiate(&mut store, &self.module)?;
232
233 match sandbox::check_guest_abi(&instance, &mut store) {
241 sandbox::AbiCheck::Match => {}
242 sandbox::AbiCheck::Missing => anyhow::bail!(
243 "plugin `{name}` has a missing or invalid `{export}` export (expected \
244 signature `() -> u32`): it was built against an incompatible \
245 rustledger-plugin-types, or the export is absent, mistyped, or traps. \
246 Host requires ABI v{ver}. Rebuild it against a matching \
247 rustledger-plugin-types.",
248 name = self.name,
249 export = rustledger_plugin_types::ABI_VERSION_EXPORT,
250 ver = sandbox::HOST_ABI_VERSION,
251 ),
252 sandbox::AbiCheck::Mismatch { found } => anyhow::bail!(
253 "plugin `{name}` ABI version mismatch: plugin declares v{found}, host requires \
254 v{ver}. Rebuild it against a matching rustledger-plugin-types.",
255 name = self.name,
256 ver = sandbox::HOST_ABI_VERSION,
257 ),
258 }
259
260 let input_bytes = rmp_serde::to_vec(input)?;
268
269 let memory = instance
272 .get_memory(&mut store, "memory")
273 .expect("validate_loaded_module verified `memory` export at load");
274
275 let alloc = instance
280 .get_typed_func::<u32, u32>(&mut store, "alloc")
281 .map_err(anyhow::Error::from)
282 .context("plugin export `alloc` has wrong signature")?;
286
287 let input_ptr = alloc.call(&mut store, input_bytes.len() as u32)?;
289
290 memory.write(&mut store, input_ptr as usize, &input_bytes)?;
292
293 let process = instance
295 .get_typed_func::<(u32, u32), u64>(&mut store, "process")
296 .map_err(anyhow::Error::from)
297 .context("plugin export `process` has wrong signature")?;
298
299 let result = process.call(&mut store, (input_ptr, input_bytes.len() as u32))?;
300
301 let output_ptr = (result >> 32) as u32;
303 let output_len = (result & 0xFFFF_FFFF) as u32;
304
305 let mut output_bytes = vec![0u8; output_len as usize];
307 memory.read(&store, output_ptr as usize, &mut output_bytes)?;
308
309 let output: PluginOutput = rmp_serde::from_slice(&output_bytes)?;
311
312 Ok(output)
313 }
314}
315
316#[derive(Debug, Default)]
323pub struct WasmPluginDirScanReport {
324 pub loaded: Vec<String>,
328 pub failures: Vec<(PathBuf, anyhow::Error)>,
333}
334
335pub struct PluginManager {
337 config: RuntimeConfig,
339 plugins: Vec<Plugin>,
341}
342
343impl PluginManager {
344 pub fn new() -> Self {
346 Self::with_config(RuntimeConfig::default())
347 }
348
349 pub const fn with_config(config: RuntimeConfig) -> Self {
351 Self {
352 config,
353 plugins: Vec::new(),
354 }
355 }
356
357 pub fn load(&mut self, path: &Path) -> Result<usize> {
359 let plugin = Plugin::load(path, &self.config)?;
360 let index = self.plugins.len();
361 self.plugins.push(plugin);
362 Ok(index)
363 }
364
365 pub fn load_bytes(&mut self, name: impl Into<String>, bytes: &[u8]) -> Result<usize> {
367 let plugin = Plugin::load_bytes(name, bytes, &self.config)?;
368 let index = self.plugins.len();
369 self.plugins.push(plugin);
370 Ok(index)
371 }
372
373 pub fn register_wasm_dir(&mut self, dir: impl AsRef<Path>) -> Result<WasmPluginDirScanReport> {
405 let dir = dir.as_ref();
406 let scan = crate::wasm_dir_scan::collect_wasm_paths(dir)
411 .with_context(|| format!("failed to read plugin dir {}", dir.display()))?;
412 let mut report = WasmPluginDirScanReport::default();
413 for (path, source) in scan.entry_failures {
415 report.failures.push((path, anyhow::Error::new(source)));
416 }
417 for path in scan.sorted_paths {
418 match self.load(&path) {
419 Ok(index) => {
420 let name = self.plugins[index].name().to_string();
426 report.loaded.push(name);
427 }
428 Err(e) => report.failures.push((path, e)),
429 }
430 }
431 Ok(report)
432 }
433
434 pub fn execute(&self, index: usize, input: &PluginInput) -> Result<PluginOutput> {
436 let plugin = self
437 .plugins
438 .get(index)
439 .context("plugin index out of bounds")?;
440 plugin.execute(input, &self.config)
441 }
442
443 pub fn execute_all(&self, mut input: PluginInput) -> Result<PluginOutput> {
456 let mut all_errors = Vec::new();
457 let n_original = input.directives.len();
458
459 for plugin in &self.plugins {
460 let output = plugin.execute(&input, &self.config)?;
461 input.directives = materialize_ops(&input.directives, &output);
463 all_errors.extend(output.errors);
464 }
465
466 let mut ops: Vec<PluginOp> = (0..n_original).map(PluginOp::Delete).collect();
470 for w in input.directives {
471 ops.push(PluginOp::Insert(w));
472 }
473
474 Ok(PluginOutput {
475 ops,
476 errors: all_errors,
477 })
478 }
479
480 pub const fn len(&self) -> usize {
482 self.plugins.len()
483 }
484
485 pub const fn is_empty(&self) -> bool {
487 self.plugins.is_empty()
488 }
489}
490
491impl Default for PluginManager {
492 fn default() -> Self {
493 Self::new()
494 }
495}
496
497struct TrackedPlugin {
499 plugin: Plugin,
501 path: PathBuf,
503 modified: SystemTime,
505}
506
507pub struct WatchingPluginManager {
527 config: RuntimeConfig,
529 plugins: Vec<TrackedPlugin>,
531 name_index: HashMap<String, usize>,
533 on_reload: Option<Box<dyn Fn(&str) + Send + Sync>>,
535}
536
537impl WatchingPluginManager {
538 pub fn new() -> Self {
540 Self::with_config(RuntimeConfig::default())
541 }
542
543 pub fn with_config(config: RuntimeConfig) -> Self {
545 Self {
546 config,
547 plugins: Vec::new(),
548 name_index: HashMap::new(),
549 on_reload: None,
550 }
551 }
552
553 pub fn on_reload<F>(&mut self, callback: F)
555 where
556 F: Fn(&str) + Send + Sync + 'static,
557 {
558 self.on_reload = Some(Box::new(callback));
559 }
560
561 pub fn load(&mut self, path: impl AsRef<Path>) -> Result<usize> {
563 let path = path.as_ref();
564 let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
566
567 let metadata = std::fs::metadata(&abs_path)
569 .with_context(|| format!("failed to stat {}", abs_path.display()))?;
570 let modified = metadata.modified()?;
571
572 let plugin = Plugin::load(&abs_path, &self.config)?;
574 let name = plugin.name().to_string();
575 let index = self.plugins.len();
576
577 self.plugins.push(TrackedPlugin {
579 plugin,
580 path: abs_path,
581 modified,
582 });
583 self.name_index.insert(name, index);
584
585 Ok(index)
586 }
587
588 pub fn check_and_reload(&mut self) -> Result<bool> {
592 let mut reloaded = false;
593
594 for tracked in &mut self.plugins {
595 let metadata = match std::fs::metadata(&tracked.path) {
597 Ok(m) => m,
598 Err(_) => continue, };
600
601 let current_modified = match metadata.modified() {
602 Ok(m) => m,
603 Err(_) => continue,
604 };
605
606 if current_modified > tracked.modified {
608 match Plugin::load(&tracked.path, &self.config) {
610 Ok(new_plugin) => {
611 let name = tracked.plugin.name().to_string();
612 tracked.plugin = new_plugin;
613 tracked.modified = current_modified;
614 reloaded = true;
615
616 if let Some(ref callback) = self.on_reload {
618 callback(&name);
619 }
620 }
621 Err(e) => {
622 eprintln!(
624 "warning: failed to reload plugin {}: {}",
625 tracked.path.display(),
626 e
627 );
628 }
629 }
630 }
631 }
632
633 Ok(reloaded)
634 }
635
636 pub fn reload_all(&mut self) -> Result<()> {
638 for tracked in &mut self.plugins {
639 let new_plugin = Plugin::load(&tracked.path, &self.config)?;
640 let metadata = std::fs::metadata(&tracked.path)?;
641 tracked.plugin = new_plugin;
642 tracked.modified = metadata.modified()?;
643 }
644 Ok(())
645 }
646
647 pub fn get(&self, name: &str) -> Option<&Plugin> {
649 self.name_index.get(name).map(|&i| &self.plugins[i].plugin)
650 }
651
652 pub fn execute(&self, index: usize, input: &PluginInput) -> Result<PluginOutput> {
654 let tracked = self
655 .plugins
656 .get(index)
657 .context("plugin index out of bounds")?;
658 tracked.plugin.execute(input, &self.config)
659 }
660
661 pub fn execute_by_name(&self, name: &str, input: &PluginInput) -> Result<PluginOutput> {
663 let index = self
664 .name_index
665 .get(name)
666 .with_context(|| format!("plugin '{name}' not found"))?;
667 self.execute(*index, input)
668 }
669
670 pub fn execute_all(&self, mut input: PluginInput) -> Result<PluginOutput> {
675 let mut all_errors = Vec::new();
676 let n_original = input.directives.len();
677
678 for tracked in &self.plugins {
679 let output = tracked.plugin.execute(&input, &self.config)?;
680 input.directives = materialize_ops(&input.directives, &output);
681 all_errors.extend(output.errors);
682 }
683
684 let mut ops: Vec<PluginOp> = (0..n_original).map(PluginOp::Delete).collect();
685 for w in input.directives {
686 ops.push(PluginOp::Insert(w));
687 }
688
689 Ok(PluginOutput {
690 ops,
691 errors: all_errors,
692 })
693 }
694
695 pub const fn len(&self) -> usize {
697 self.plugins.len()
698 }
699
700 pub const fn is_empty(&self) -> bool {
702 self.plugins.is_empty()
703 }
704
705 pub fn plugin_info(&self) -> Vec<(&Path, SystemTime)> {
707 self.plugins
708 .iter()
709 .map(|t| (t.path.as_path(), t.modified))
710 .collect()
711 }
712}
713
714impl Default for WatchingPluginManager {
715 fn default() -> Self {
716 Self::new()
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use crate::types::PluginOptions;
724
725 #[test]
729 fn test_valid_plugin_validation() {
730 let wasm = wat::parse_str(
736 r#"
737 (module
738 (memory (export "memory") 1)
739 (func (export "alloc") (param i32) (result i32)
740 i32.const 0
741 )
742 (func (export "process") (param i32 i32) (result i64)
743 i64.const 0
744 )
745 )
746 "#,
747 )
748 .expect("valid wat");
749
750 let result = validate_plugin_module(&wasm);
751 assert!(
752 result.is_ok(),
753 "valid plugin should pass validation: {:?}",
754 result.err()
755 );
756 }
757
758 #[test]
760 fn test_wasi_import_rejected() {
761 let wasm = wat::parse_str(
763 r#"
764 (module
765 (import "wasi_snapshot_preview1" "fd_write"
766 (func $fd_write (param i32 i32 i32 i32) (result i32))
767 )
768 (memory (export "memory") 1)
769 (func (export "alloc") (param i32) (result i32)
770 i32.const 0
771 )
772 (func (export "process") (param i32 i32) (result i64)
773 i64.const 0
774 )
775 )
776 "#,
777 )
778 .expect("valid wat");
779
780 let result = validate_plugin_module(&wasm);
781 assert!(
782 result.is_err(),
783 "module with WASI import should be rejected"
784 );
785 let err = result.unwrap_err().to_string();
786 assert!(
787 err.contains("forbidden import"),
788 "error should mention forbidden import: {err}"
789 );
790 assert!(
791 err.contains("wasi_snapshot_preview1"),
792 "error should mention WASI: {err}"
793 );
794 }
795
796 #[test]
798 fn test_env_import_rejected() {
799 let wasm = wat::parse_str(
801 r#"
802 (module
803 (import "env" "some_func" (func $some_func))
804 (memory (export "memory") 1)
805 (func (export "alloc") (param i32) (result i32)
806 i32.const 0
807 )
808 (func (export "process") (param i32 i32) (result i64)
809 i64.const 0
810 )
811 )
812 "#,
813 )
814 .expect("valid wat");
815
816 let result = validate_plugin_module(&wasm);
817 assert!(result.is_err(), "module with env import should be rejected");
818 }
819
820 #[test]
822 fn test_missing_exports_rejected() {
823 let wasm = wat::parse_str(
825 r#"
826 (module
827 (memory (export "memory") 1)
828 (func (export "process") (param i32 i32) (result i64)
829 i64.const 0
830 )
831 )
832 "#,
833 )
834 .expect("valid wat");
835
836 let result = validate_plugin_module(&wasm);
837 assert!(result.is_err(), "module missing alloc should be rejected");
838 assert!(result.unwrap_err().to_string().contains("alloc"));
839 }
840
841 #[test]
843 fn test_runtime_config_defaults() {
844 let config = RuntimeConfig::default();
845 assert_eq!(
850 config.max_memory,
851 crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY
852 );
853 assert_eq!(
854 config.max_time_secs,
855 crate::sandbox::DEFAULT_SANDBOX_MAX_TIME_SECS
856 );
857 }
858
859 #[test]
861 fn test_missing_memory_rejected() {
862 let wasm = wat::parse_str(
863 r#"
864 (module
865 (func (export "alloc") (param i32) (result i32)
866 i32.const 0
867 )
868 (func (export "process") (param i32 i32) (result i64)
869 i64.const 0
870 )
871 )
872 "#,
873 )
874 .expect("valid wat");
875
876 let result = validate_plugin_module(&wasm);
877 assert!(result.is_err(), "module missing memory should be rejected");
878 assert!(result.unwrap_err().to_string().contains("memory"));
879 }
880
881 #[test]
883 fn test_missing_process_rejected() {
884 let wasm = wat::parse_str(
885 r#"
886 (module
887 (memory (export "memory") 1)
888 (func (export "alloc") (param i32) (result i32)
889 i32.const 0
890 )
891 )
892 "#,
893 )
894 .expect("valid wat");
895
896 let result = validate_plugin_module(&wasm);
897 assert!(result.is_err(), "module missing process should be rejected");
898 assert!(result.unwrap_err().to_string().contains("process"));
899 }
900
901 #[test]
903 fn test_invalid_wasm_rejected() {
904 let invalid = b"not valid wasm bytes";
905 let result = validate_plugin_module(invalid);
906 assert!(result.is_err(), "invalid WASM should be rejected");
907 }
908
909 #[test]
911 fn test_runtime_config_custom() {
912 let config = RuntimeConfig {
913 max_memory: 512 * 1024 * 1024, max_time_secs: 60,
915 };
916 assert_eq!(config.max_memory, 512 * 1024 * 1024);
917 assert_eq!(config.max_time_secs, 60);
918 }
919
920 #[test]
925 fn test_plugin_manager_new() {
926 let manager = PluginManager::new();
927 assert!(manager.is_empty());
928 assert_eq!(manager.len(), 0);
929 }
930
931 #[test]
932 fn test_plugin_manager_with_config() {
933 let config = RuntimeConfig {
934 max_memory: 128 * 1024 * 1024,
935 max_time_secs: 10,
936 };
937 let manager = PluginManager::with_config(config);
938 assert!(manager.is_empty());
939 }
940
941 #[test]
942 fn test_plugin_manager_default() {
943 let manager = PluginManager::default();
944 assert!(manager.is_empty());
945 assert_eq!(manager.len(), 0);
946 }
947
948 #[test]
949 fn test_watching_plugin_manager_new() {
950 let manager = WatchingPluginManager::new();
951 assert!(manager.is_empty());
952 assert_eq!(manager.len(), 0);
953 assert!(manager.plugin_info().is_empty());
954 }
955
956 #[test]
957 fn test_watching_plugin_manager_with_config() {
958 let config = RuntimeConfig {
959 max_memory: 64 * 1024 * 1024,
960 max_time_secs: 5,
961 };
962 let manager = WatchingPluginManager::with_config(config);
963 assert!(manager.is_empty());
964 }
965
966 #[test]
967 fn test_watching_plugin_manager_default() {
968 let manager = WatchingPluginManager::default();
969 assert!(manager.is_empty());
970 assert_eq!(manager.len(), 0);
971 }
972
973 #[test]
974 fn test_watching_plugin_manager_get_unknown() {
975 let manager = WatchingPluginManager::new();
976 assert!(manager.get("nonexistent").is_none());
977 }
978
979 #[test]
980 fn test_plugin_manager_execute_out_of_bounds() {
981 let manager = PluginManager::new();
982 let input = crate::types::PluginInput {
983 directives: vec![],
984 options: crate::types::PluginOptions::default(),
985 config: None,
986 };
987 let result = manager.execute(0, &input);
988 assert!(result.is_err());
989 assert!(result.unwrap_err().to_string().contains("out of bounds"));
990 }
991
992 #[test]
993 fn test_watching_plugin_manager_execute_out_of_bounds() {
994 let manager = WatchingPluginManager::new();
995 let input = crate::types::PluginInput {
996 directives: vec![],
997 options: crate::types::PluginOptions::default(),
998 config: None,
999 };
1000 let result = manager.execute(0, &input);
1001 assert!(result.is_err());
1002 assert!(result.unwrap_err().to_string().contains("out of bounds"));
1003 }
1004
1005 #[test]
1006 fn test_watching_plugin_manager_execute_by_name_unknown() {
1007 let manager = WatchingPluginManager::new();
1008 let input = crate::types::PluginInput {
1009 directives: vec![],
1010 options: crate::types::PluginOptions::default(),
1011 config: None,
1012 };
1013 let result = manager.execute_by_name("unknown", &input);
1014 assert!(result.is_err());
1015 assert!(result.unwrap_err().to_string().contains("not found"));
1016 }
1017
1018 #[test]
1019 fn test_plugin_manager_execute_all_empty() {
1020 let manager = PluginManager::new();
1021 let input = crate::types::PluginInput {
1022 directives: vec![],
1023 options: crate::types::PluginOptions::default(),
1024 config: None,
1025 };
1026 let result = manager.execute_all(input);
1027 assert!(result.is_ok());
1028 let output = result.unwrap();
1029 assert!(output.ops.is_empty());
1030 assert!(output.errors.is_empty());
1031 }
1032
1033 #[test]
1034 fn test_watching_plugin_manager_execute_all_empty() {
1035 let manager = WatchingPluginManager::new();
1036 let input = crate::types::PluginInput {
1037 directives: vec![],
1038 options: crate::types::PluginOptions::default(),
1039 config: None,
1040 };
1041 let result = manager.execute_all(input);
1042 assert!(result.is_ok());
1043 let output = result.unwrap();
1044 assert!(output.ops.is_empty());
1045 assert!(output.errors.is_empty());
1046 }
1047
1048 #[test]
1049 fn test_watching_plugin_manager_check_reload_empty() {
1050 let mut manager = WatchingPluginManager::new();
1051 let result = manager.check_and_reload();
1052 assert!(result.is_ok());
1053 assert!(!result.unwrap()); }
1055
1056 #[test]
1057 fn test_watching_plugin_manager_reload_all_empty() {
1058 let mut manager = WatchingPluginManager::new();
1059 let result = manager.reload_all();
1060 assert!(result.is_ok()); }
1062
1063 #[test]
1064 fn test_plugin_load_bytes() {
1065 let wasm = wat::parse_str(
1066 r#"
1067 (module
1068 (memory (export "memory") 1)
1069 (func (export "alloc") (param i32) (result i32)
1070 i32.const 0
1071 )
1072 (func (export "process") (param i32 i32) (result i64)
1073 i64.const 0
1074 )
1075 )
1076 "#,
1077 )
1078 .expect("valid wat");
1079
1080 let config = RuntimeConfig::default();
1081 let result = Plugin::load_bytes("test_plugin", &wasm, &config);
1082 assert!(result.is_ok());
1083
1084 let plugin = result.unwrap();
1085 assert_eq!(plugin.name(), "test_plugin");
1086 }
1087
1088 #[test]
1089 fn test_plugin_manager_load_bytes() {
1090 let wasm = wat::parse_str(
1091 r#"
1092 (module
1093 (memory (export "memory") 1)
1094 (func (export "alloc") (param i32) (result i32)
1095 i32.const 0
1096 )
1097 (func (export "process") (param i32 i32) (result i64)
1098 i64.const 0
1099 )
1100 )
1101 "#,
1102 )
1103 .expect("valid wat");
1104
1105 let mut manager = PluginManager::new();
1106 let result = manager.load_bytes("my_plugin", &wasm);
1107 assert!(result.is_ok());
1108 assert_eq!(result.unwrap(), 0); assert_eq!(manager.len(), 1);
1110 assert!(!manager.is_empty());
1111 }
1112
1113 #[test]
1114 fn test_plugin_manager_multiple_plugins() {
1115 let wasm = wat::parse_str(
1116 r#"
1117 (module
1118 (memory (export "memory") 1)
1119 (func (export "alloc") (param i32) (result i32)
1120 i32.const 0
1121 )
1122 (func (export "process") (param i32 i32) (result i64)
1123 i64.const 0
1124 )
1125 )
1126 "#,
1127 )
1128 .expect("valid wat");
1129
1130 let mut manager = PluginManager::new();
1131 manager.load_bytes("plugin1", &wasm).unwrap();
1132 manager.load_bytes("plugin2", &wasm).unwrap();
1133 manager.load_bytes("plugin3", &wasm).unwrap();
1134
1135 assert_eq!(manager.len(), 3);
1136 }
1137
1138 #[test]
1139 fn test_validate_truncated_wasm() {
1140 let truncated = &[0x00, 0x61, 0x73, 0x6d]; let result = validate_plugin_module(truncated);
1143 assert!(result.is_err());
1144 }
1145
1146 #[test]
1147 fn test_validate_wrong_magic() {
1148 let wrong_magic = &[0xFF, 0xFF, 0xFF, 0xFF];
1149 let result = validate_plugin_module(wrong_magic);
1150 assert!(result.is_err());
1151 }
1152
1153 #[test]
1154 fn test_validate_empty_wasm() {
1155 let empty: &[u8] = &[];
1156 let result = validate_plugin_module(empty);
1157 assert!(result.is_err());
1158 }
1159
1160 #[test]
1161 fn execute_rejects_initial_memory_above_max_memory_cap() {
1162 let wasm = wat::parse_str(
1170 r#"
1171 (module
1172 (memory (export "memory") 5000)
1173 (func (export "alloc") (param i32) (result i32) i32.const 0)
1174 (func (export "process") (param i32 i32) (result i64) i64.const 0)
1175 )
1176 "#,
1177 )
1178 .expect("WAT parses");
1179 let plugin = Plugin::load_bytes("bigmem", &wasm, &RuntimeConfig::default())
1180 .expect("module loads (declared memory size is checked at instantiate, not compile)");
1181 let tight_config = RuntimeConfig {
1182 max_memory: 64 * 1024 * 1024,
1183 max_time_secs: 30,
1184 };
1185 let input = PluginInput {
1186 directives: vec![],
1187 options: PluginOptions {
1188 operating_currencies: vec![],
1189 title: None,
1190 },
1191 config: None,
1192 };
1193 let err = plugin
1194 .execute(&input, &tight_config)
1195 .expect_err("instantiation should fail when initial memory > cap");
1196 let msg = format!("{err:#}").to_ascii_lowercase();
1203 assert!(
1204 msg.contains("memory") || msg.contains("limit"),
1205 "expected memory-limit error, got: {msg}"
1206 );
1207 }
1208
1209 fn plugin_wat_with_abi(abi_section: &str) -> String {
1214 format!(
1215 r#"
1216 (module
1217 (memory (export "memory") 1)
1218 (func (export "alloc") (param i32) (result i32) i32.const 0)
1219 (func (export "process") (param i32 i32) (result i64) i64.const 0)
1220 {abi_section}
1221 )
1222 "#
1223 )
1224 }
1225
1226 fn abi_test_plugin_input() -> PluginInput {
1227 PluginInput {
1228 directives: vec![],
1229 options: PluginOptions {
1230 operating_currencies: vec![],
1231 title: None,
1232 },
1233 config: None,
1234 }
1235 }
1236
1237 #[test]
1241 fn execute_rejects_plugin_missing_abi_version() {
1242 let wasm = wat::parse_str(plugin_wat_with_abi("")).expect("WAT parses");
1243 let plugin =
1244 Plugin::load_bytes("noabi", &wasm, &RuntimeConfig::default()).expect("module loads");
1245 let err = plugin
1246 .execute(&abi_test_plugin_input(), &RuntimeConfig::default())
1247 .expect_err("execute must reject a plugin with no ABI export");
1248 let msg = format!("{err:#}");
1249 assert!(
1250 msg.contains("__rustledger_abi_version") && msg.contains("missing or invalid"),
1251 "expected a missing-ABI error, got: {msg}"
1252 );
1253 }
1254
1255 #[test]
1258 fn execute_rejects_plugin_with_mismatched_abi_version() {
1259 let wasm = wat::parse_str(plugin_wat_with_abi(
1260 r#"(func (export "__rustledger_abi_version") (result i32) i32.const 999)"#,
1261 ))
1262 .expect("WAT parses");
1263 let plugin =
1264 Plugin::load_bytes("badabi", &wasm, &RuntimeConfig::default()).expect("module loads");
1265 let err = plugin
1266 .execute(&abi_test_plugin_input(), &RuntimeConfig::default())
1267 .expect_err("execute must reject an ABI-mismatched plugin");
1268 let msg = format!("{err:#}");
1269 assert!(
1270 msg.contains("ABI version mismatch") && msg.contains("999"),
1271 "expected an ABI-mismatch error naming v999, got: {msg}"
1272 );
1273 }
1274
1275 #[test]
1276 fn execute_surfaces_wrong_signature_on_alloc() {
1277 let wasm = wat::parse_str(
1284 r#"
1285 (module
1286 (memory (export "memory") 1)
1287 (func (export "alloc") (param i64) (result i64) i64.const 0)
1288 (func (export "process") (param i32 i32) (result i64) i64.const 0)
1289 ;; Correct ABI so the check passes and the alloc
1290 ;; signature mismatch is what surfaces (issue #1234).
1291 (func (export "__rustledger_abi_version") (result i32) i32.const 1)
1292 )
1293 "#,
1294 )
1295 .expect("WAT parses");
1296 let plugin = Plugin::load_bytes("bad-alloc-sig", &wasm, &RuntimeConfig::default())
1297 .expect("module loads (validate only checks presence by name)");
1298 let input = PluginInput {
1299 directives: vec![],
1300 options: PluginOptions {
1301 operating_currencies: vec![],
1302 title: None,
1303 },
1304 config: None,
1305 };
1306 let err = plugin
1307 .execute(&input, &RuntimeConfig::default())
1308 .expect_err("wrong-sig alloc should fail execute");
1309 let msg = format!("{err:#}");
1310 assert!(
1311 msg.contains("alloc") && msg.contains("wrong signature"),
1312 "expected `alloc` + `wrong signature` in error, got: {msg}"
1313 );
1314 }
1315
1316 #[test]
1317 fn execute_surfaces_wrong_signature_on_process() {
1318 let wasm = wat::parse_str(
1323 r#"
1324 (module
1325 (memory (export "memory") 1)
1326 (func (export "alloc") (param i32) (result i32) i32.const 0)
1327 (func (export "process") (param i32 i32) (result i32) i32.const 0)
1328 ;; Correct ABI so the check passes and the process
1329 ;; signature mismatch is what surfaces (issue #1234).
1330 (func (export "__rustledger_abi_version") (result i32) i32.const 1)
1331 )
1332 "#,
1333 )
1334 .expect("WAT parses");
1335 let plugin = Plugin::load_bytes("bad-process-sig", &wasm, &RuntimeConfig::default())
1336 .expect("module loads (validate only checks presence by name)");
1337 let input = PluginInput {
1338 directives: vec![],
1339 options: PluginOptions {
1340 operating_currencies: vec![],
1341 title: None,
1342 },
1343 config: None,
1344 };
1345 let err = plugin
1346 .execute(&input, &RuntimeConfig::default())
1347 .expect_err("wrong-sig process should fail execute");
1348 let msg = format!("{err:#}");
1349 assert!(
1350 msg.contains("process") && msg.contains("wrong signature"),
1351 "expected `process` + `wrong signature` in error, got: {msg}"
1352 );
1353 }
1354
1355 fn passthrough_wat() -> &'static str {
1359 r#"
1360 (module
1361 (memory (export "memory") 1)
1362 (func (export "alloc") (param i32) (result i32) i32.const 0)
1363 (func (export "process") (param i32 i32) (result i64) i64.const 0)
1364 ;; ABI handshake (issue #1234): matches the host so execute
1365 ;; reaches the process/decode path the fuel tests exercise.
1366 (func (export "__rustledger_abi_version") (result i32) i32.const 1)
1367 )
1368 "#
1369 }
1370
1371 fn empty_input() -> PluginInput {
1372 PluginInput {
1373 directives: vec![],
1374 options: PluginOptions {
1375 operating_currencies: vec![],
1376 title: None,
1377 },
1378 config: None,
1379 }
1380 }
1381
1382 fn assert_not_fuel_trap(err: &anyhow::Error) {
1390 let msg = format!("{err:#}").to_ascii_lowercase();
1391 assert!(
1392 !msg.contains("fuel") && !msg.contains("trap"),
1393 "expected msgpack decode error, got fuel/trap: {msg}"
1394 );
1395 }
1396
1397 #[test]
1398 fn execute_with_zero_max_time_secs_clamps_to_min_fuel() {
1399 let wasm = wat::parse_str(passthrough_wat()).expect("WAT parses");
1406 let plugin =
1407 Plugin::load_bytes("fuel-zero", &wasm, &RuntimeConfig::default()).expect("loads");
1408 let zero_secs = RuntimeConfig {
1409 max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
1410 max_time_secs: 0,
1411 };
1412 let err = plugin
1413 .execute(&empty_input(), &zero_secs)
1414 .expect_err("passthrough WAT decode-fails by design");
1415 assert_not_fuel_trap(&err);
1416 }
1417
1418 #[test]
1419 fn execute_with_max_max_time_secs_saturates_fuel() {
1420 let wasm = wat::parse_str(passthrough_wat()).expect("WAT parses");
1425 let plugin =
1426 Plugin::load_bytes("fuel-max", &wasm, &RuntimeConfig::default()).expect("loads");
1427 let max_secs = RuntimeConfig {
1428 max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
1429 max_time_secs: u64::MAX,
1430 };
1431 let err = plugin
1432 .execute(&empty_input(), &max_secs)
1433 .expect_err("passthrough WAT decode-fails by design");
1434 assert_not_fuel_trap(&err);
1435 }
1436
1437 fn valid_plugin_wasm() -> Vec<u8> {
1446 wat::parse_str(
1447 r#"
1448 (module
1449 (memory (export "memory") 1)
1450 (func (export "alloc") (param i32) (result i32) i32.const 0)
1451 (func (export "process") (param i32 i32) (result i64) i64.const 0)
1452 ;; A valid plugin advertises the ABI version (issue #1234).
1453 (func (export "__rustledger_abi_version") (result i32) i32.const 1)
1454 )
1455 "#,
1456 )
1457 .expect("valid wat")
1458 }
1459
1460 #[test]
1461 fn register_wasm_dir_loads_valid_skips_broken_and_non_wasm() {
1462 let dir = tempfile::tempdir().expect("tempdir");
1463 let dir_path = dir.path();
1464
1465 std::fs::write(dir_path.join("b_second.wasm"), valid_plugin_wasm()).unwrap();
1467 std::fs::write(dir_path.join("a_first.wasm"), valid_plugin_wasm()).unwrap();
1468
1469 std::fs::write(dir_path.join("broken.wasm"), b"not a wasm module").unwrap();
1471
1472 std::fs::write(dir_path.join("README.md"), "ignore me").unwrap();
1474 std::fs::write(dir_path.join(".gitignore"), "ignore me too").unwrap();
1475
1476 let subdir = dir_path.join("sub");
1478 std::fs::create_dir(&subdir).unwrap();
1479 std::fs::write(subdir.join("recursed.wasm"), valid_plugin_wasm()).unwrap();
1480
1481 let mut manager = PluginManager::new();
1482 let report = manager
1483 .register_wasm_dir(dir_path)
1484 .expect("dir-level read succeeds");
1485
1486 assert_eq!(report.loaded, vec!["a_first", "b_second"]);
1488 assert_eq!(manager.len(), 2);
1489
1490 assert_eq!(report.failures.len(), 1);
1492 assert_eq!(
1493 report.failures[0].0.file_name().and_then(|s| s.to_str()),
1494 Some("broken.wasm"),
1495 );
1496 }
1497
1498 #[test]
1499 fn register_wasm_dir_propagates_dir_level_io_error() {
1500 let tmp = tempfile::tempdir().expect("tempdir");
1506 let nonexistent = tmp.path().join("does-not-exist");
1507 let mut manager = PluginManager::new();
1508 let err = manager
1509 .register_wasm_dir(&nonexistent)
1510 .expect_err("nonexistent dir should error at read_dir, not in failures");
1511 assert!(err.to_string().contains("failed to read plugin dir"));
1512 }
1513
1514 #[test]
1515 fn register_wasm_dir_is_case_insensitive_on_extension() {
1516 let dir = tempfile::tempdir().expect("tempdir");
1517 std::fs::write(dir.path().join("upper.WASM"), valid_plugin_wasm()).unwrap();
1518 std::fs::write(dir.path().join("mixed.Wasm"), valid_plugin_wasm()).unwrap();
1519
1520 let mut manager = PluginManager::new();
1521 let report = manager
1522 .register_wasm_dir(dir.path())
1523 .expect("scan succeeds");
1524 assert_eq!(report.loaded.len(), 2, "both case variants should load");
1525 assert!(report.failures.is_empty());
1526 }
1527}