Skip to main content

rustledger_plugin/
runtime.rs

1//! WASM Plugin Runtime.
2//!
3//! This module provides the wasmtime-based runtime for executing plugins.
4//!
5//! # Security / Sandboxing
6//!
7//! Plugins run in a fully sandboxed environment with the following guarantees:
8//!
9//! - **No filesystem access**: Plugins cannot read or write files
10//! - **No network access**: Plugins cannot make network connections
11//! - **No environment access**: Plugins cannot read environment variables
12//! - **No system calls**: No WASI or other system imports are provided
13//! - **Memory limits**: Configurable max memory (default 256MB)
14//! - **Execution limits**: Fuel-based execution time limits (default 30s)
15//!
16//! The only way for plugins to communicate is through the `process` function
17//! which receives serialized directive data and returns modified directives.
18//!
19//! # Hot Reloading
20//!
21//! The `WatchingPluginManager` provides file-watching capability for
22//! development workflows. It tracks plugin file modification times and
23//! reloads plugins when their source files change.
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::SystemTime;
29
30use anyhow::{Context, Result};
31use wasmtime::{Config, Engine, Linker, Module, Store};
32
33use crate::types::{PluginInput, PluginOutput};
34
35/// Configuration for the plugin runtime.
36#[derive(Debug, Clone)]
37pub struct RuntimeConfig {
38    /// Maximum memory in bytes (default: 256MB).
39    pub max_memory: usize,
40    /// Maximum execution time in seconds (default: 30).
41    pub max_time_secs: u64,
42}
43
44impl Default for RuntimeConfig {
45    fn default() -> Self {
46        Self {
47            max_memory: 256 * 1024 * 1024, // 256MB
48            max_time_secs: 30,
49        }
50    }
51}
52
53/// Validate that a WASM module doesn't have any forbidden imports.
54///
55/// Beancount plugins should be self-contained and not require any
56/// external imports (WASI, env, etc.). This function checks that the
57/// module only has the expected exports and no unexpected imports.
58///
59/// # Errors
60///
61/// Returns an error if the module has forbidden imports or is missing
62/// required exports.
63pub fn validate_plugin_module(bytes: &[u8]) -> Result<()> {
64    let engine = Engine::default();
65    let module = Module::new(&engine, bytes)?;
66
67    // Check for forbidden imports (any imports are forbidden)
68    if let Some(import) = module.imports().next() {
69        anyhow::bail!(
70            "plugin has forbidden import: {}::{}",
71            import.module(),
72            import.name()
73        );
74    }
75
76    // Verify required exports exist
77    let exports: Vec<_> = module.exports().map(|e| e.name()).collect();
78
79    if !exports.contains(&"memory") {
80        anyhow::bail!("plugin must export 'memory'");
81    }
82    if !exports.contains(&"alloc") {
83        anyhow::bail!("plugin must export 'alloc' function");
84    }
85    if !exports.contains(&"process") {
86        anyhow::bail!("plugin must export 'process' function");
87    }
88
89    Ok(())
90}
91
92/// A loaded WASM plugin.
93pub struct Plugin {
94    /// Plugin name (derived from filename).
95    name: String,
96    /// Compiled module.
97    module: Module,
98    /// Engine reference.
99    engine: Arc<Engine>,
100}
101
102impl Plugin {
103    /// Load a plugin from a WASM file.
104    pub fn load(path: &Path, _config: &RuntimeConfig) -> Result<Self> {
105        let name = path
106            .file_stem()
107            .and_then(|s| s.to_str())
108            .unwrap_or("unknown")
109            .to_string();
110
111        // Create engine with configuration
112        let mut engine_config = Config::new();
113        engine_config.consume_fuel(true); // Enable fuel for execution limits
114
115        let engine = Arc::new(Engine::new(&engine_config)?);
116
117        // Load and compile the module
118        let wasm_bytes =
119            std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
120
121        let module = Module::new(&engine, &wasm_bytes)
122            .with_context(|| format!("failed to compile {}", path.display()))?;
123
124        Ok(Self {
125            name,
126            module,
127            engine,
128        })
129    }
130
131    /// Load a plugin from WASM bytes.
132    pub fn load_bytes(
133        name: impl Into<String>,
134        bytes: &[u8],
135        _config: &RuntimeConfig,
136    ) -> Result<Self> {
137        let name = name.into();
138
139        let mut engine_config = Config::new();
140        engine_config.consume_fuel(true);
141
142        let engine = Arc::new(Engine::new(&engine_config)?);
143        let module = Module::new(&engine, bytes)?;
144
145        Ok(Self {
146            name,
147            module,
148            engine,
149        })
150    }
151
152    /// Get the plugin name.
153    pub fn name(&self) -> &str {
154        &self.name
155    }
156
157    /// Execute the plugin with the given input.
158    pub fn execute(&self, input: &PluginInput, config: &RuntimeConfig) -> Result<PluginOutput> {
159        // Create a store with fuel limit
160        let mut store = Store::new(&self.engine, ());
161
162        // Set fuel limit based on time (rough approximation: 1M instructions per second)
163        let fuel = config.max_time_secs * 1_000_000;
164        store.set_fuel(fuel)?;
165
166        // Create linker with NO imports for full sandboxing
167        // Plugins have no access to filesystem, network, or any system calls
168        let linker = Linker::new(&self.engine);
169
170        // Instantiate the module
171        let instance = linker.instantiate(&mut store, &self.module)?;
172
173        // Serialize input
174        let input_bytes = rmp_serde::to_vec(input)?;
175
176        // Get memory and allocate space for input
177        let memory = instance
178            .get_memory(&mut store, "memory")
179            .context("plugin must export 'memory'")?;
180
181        // Get the alloc function to allocate space in WASM memory
182        let alloc = instance
183            .get_typed_func::<u32, u32>(&mut store, "alloc")
184            .context("plugin must export 'alloc' function")?;
185
186        // Allocate space for input
187        let input_ptr = alloc.call(&mut store, input_bytes.len() as u32)?;
188
189        // Write input to WASM memory
190        memory.write(&mut store, input_ptr as usize, &input_bytes)?;
191
192        // Call the process function
193        let process = instance
194            .get_typed_func::<(u32, u32), u64>(&mut store, "process")
195            .context("plugin must export 'process' function")?;
196
197        let result = process.call(&mut store, (input_ptr, input_bytes.len() as u32))?;
198
199        // Parse result (packed as ptr << 32 | len)
200        let output_ptr = (result >> 32) as u32;
201        let output_len = (result & 0xFFFF_FFFF) as u32;
202
203        // Read output from WASM memory
204        let mut output_bytes = vec![0u8; output_len as usize];
205        memory.read(&store, output_ptr as usize, &mut output_bytes)?;
206
207        // Deserialize output
208        let output: PluginOutput = rmp_serde::from_slice(&output_bytes)?;
209
210        Ok(output)
211    }
212}
213
214/// Plugin manager that caches loaded plugins.
215pub struct PluginManager {
216    /// Runtime configuration.
217    config: RuntimeConfig,
218    /// Loaded plugins.
219    plugins: Vec<Plugin>,
220}
221
222impl PluginManager {
223    /// Create a new plugin manager.
224    pub fn new() -> Self {
225        Self::with_config(RuntimeConfig::default())
226    }
227
228    /// Create a plugin manager with custom configuration.
229    pub const fn with_config(config: RuntimeConfig) -> Self {
230        Self {
231            config,
232            plugins: Vec::new(),
233        }
234    }
235
236    /// Load a plugin from a file path.
237    pub fn load(&mut self, path: &Path) -> Result<usize> {
238        let plugin = Plugin::load(path, &self.config)?;
239        let index = self.plugins.len();
240        self.plugins.push(plugin);
241        Ok(index)
242    }
243
244    /// Load a plugin from bytes.
245    pub fn load_bytes(&mut self, name: impl Into<String>, bytes: &[u8]) -> Result<usize> {
246        let plugin = Plugin::load_bytes(name, bytes, &self.config)?;
247        let index = self.plugins.len();
248        self.plugins.push(plugin);
249        Ok(index)
250    }
251
252    /// Execute a plugin by index.
253    pub fn execute(&self, index: usize, input: &PluginInput) -> Result<PluginOutput> {
254        let plugin = self
255            .plugins
256            .get(index)
257            .context("plugin index out of bounds")?;
258        plugin.execute(input, &self.config)
259    }
260
261    /// Execute all loaded plugins in sequence.
262    pub fn execute_all(&self, mut input: PluginInput) -> Result<PluginOutput> {
263        let mut all_errors = Vec::new();
264
265        for plugin in &self.plugins {
266            let output = plugin.execute(&input, &self.config)?;
267            all_errors.extend(output.errors);
268            input.directives = output.directives;
269        }
270
271        Ok(PluginOutput {
272            directives: input.directives,
273            errors: all_errors,
274        })
275    }
276
277    /// Get the number of loaded plugins.
278    pub const fn len(&self) -> usize {
279        self.plugins.len()
280    }
281
282    /// Check if any plugins are loaded.
283    pub const fn is_empty(&self) -> bool {
284        self.plugins.is_empty()
285    }
286}
287
288impl Default for PluginManager {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294/// A plugin with file tracking info for hot-reloading.
295struct TrackedPlugin {
296    /// The loaded plugin.
297    plugin: Plugin,
298    /// Path to the WASM file.
299    path: PathBuf,
300    /// Last modification time.
301    modified: SystemTime,
302}
303
304/// Plugin manager with hot-reloading support.
305///
306/// This manager tracks plugin file modification times and can reload
307/// plugins when their source files change. This is useful for development
308/// workflows where you want to iterate on plugins without restarting.
309///
310/// # Example
311///
312/// ```ignore
313/// use rustledger_plugin::WatchingPluginManager;
314///
315/// let mut manager = WatchingPluginManager::new();
316/// manager.load("plugins/my_plugin.wasm")?;
317///
318/// // Check for changes and reload if needed
319/// if manager.check_and_reload()? {
320///     println!("Plugins reloaded!");
321/// }
322/// ```
323pub struct WatchingPluginManager {
324    /// Runtime configuration.
325    config: RuntimeConfig,
326    /// Tracked plugins with file info.
327    plugins: Vec<TrackedPlugin>,
328    /// Plugin name to index mapping for lookup.
329    name_index: HashMap<String, usize>,
330    /// Reload callback (optional).
331    on_reload: Option<Box<dyn Fn(&str) + Send + Sync>>,
332}
333
334impl WatchingPluginManager {
335    /// Create a new watching plugin manager.
336    pub fn new() -> Self {
337        Self::with_config(RuntimeConfig::default())
338    }
339
340    /// Create a watching plugin manager with custom configuration.
341    pub fn with_config(config: RuntimeConfig) -> Self {
342        Self {
343            config,
344            plugins: Vec::new(),
345            name_index: HashMap::new(),
346            on_reload: None,
347        }
348    }
349
350    /// Set a callback to be invoked when a plugin is reloaded.
351    pub fn on_reload<F>(&mut self, callback: F)
352    where
353        F: Fn(&str) + Send + Sync + 'static,
354    {
355        self.on_reload = Some(Box::new(callback));
356    }
357
358    /// Load a plugin from a file path.
359    pub fn load(&mut self, path: impl AsRef<Path>) -> Result<usize> {
360        let path = path.as_ref();
361        let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
362
363        // Get modification time
364        let metadata = std::fs::metadata(&abs_path)
365            .with_context(|| format!("failed to stat {}", abs_path.display()))?;
366        let modified = metadata.modified()?;
367
368        // Load the plugin
369        let plugin = Plugin::load(&abs_path, &self.config)?;
370        let name = plugin.name().to_string();
371        let index = self.plugins.len();
372
373        // Track the plugin
374        self.plugins.push(TrackedPlugin {
375            plugin,
376            path: abs_path,
377            modified,
378        });
379        self.name_index.insert(name, index);
380
381        Ok(index)
382    }
383
384    /// Check for file changes and reload modified plugins.
385    ///
386    /// Returns `true` if any plugins were reloaded.
387    pub fn check_and_reload(&mut self) -> Result<bool> {
388        let mut reloaded = false;
389
390        for tracked in &mut self.plugins {
391            // Get current modification time
392            let metadata = match std::fs::metadata(&tracked.path) {
393                Ok(m) => m,
394                Err(_) => continue, // File might have been deleted
395            };
396
397            let current_modified = match metadata.modified() {
398                Ok(m) => m,
399                Err(_) => continue,
400            };
401
402            // Check if file was modified
403            if current_modified > tracked.modified {
404                // Reload the plugin
405                match Plugin::load(&tracked.path, &self.config) {
406                    Ok(new_plugin) => {
407                        let name = tracked.plugin.name().to_string();
408                        tracked.plugin = new_plugin;
409                        tracked.modified = current_modified;
410                        reloaded = true;
411
412                        // Call reload callback if set
413                        if let Some(ref callback) = self.on_reload {
414                            callback(&name);
415                        }
416                    }
417                    Err(e) => {
418                        // Log error but don't fail - keep using old plugin
419                        eprintln!(
420                            "warning: failed to reload plugin {}: {}",
421                            tracked.path.display(),
422                            e
423                        );
424                    }
425                }
426            }
427        }
428
429        Ok(reloaded)
430    }
431
432    /// Force reload all plugins.
433    pub fn reload_all(&mut self) -> Result<()> {
434        for tracked in &mut self.plugins {
435            let new_plugin = Plugin::load(&tracked.path, &self.config)?;
436            let metadata = std::fs::metadata(&tracked.path)?;
437            tracked.plugin = new_plugin;
438            tracked.modified = metadata.modified()?;
439        }
440        Ok(())
441    }
442
443    /// Get a plugin by name.
444    pub fn get(&self, name: &str) -> Option<&Plugin> {
445        self.name_index.get(name).map(|&i| &self.plugins[i].plugin)
446    }
447
448    /// Execute a plugin by index.
449    pub fn execute(&self, index: usize, input: &PluginInput) -> Result<PluginOutput> {
450        let tracked = self
451            .plugins
452            .get(index)
453            .context("plugin index out of bounds")?;
454        tracked.plugin.execute(input, &self.config)
455    }
456
457    /// Execute a plugin by name.
458    pub fn execute_by_name(&self, name: &str, input: &PluginInput) -> Result<PluginOutput> {
459        let index = self
460            .name_index
461            .get(name)
462            .with_context(|| format!("plugin '{name}' not found"))?;
463        self.execute(*index, input)
464    }
465
466    /// Execute all loaded plugins in sequence.
467    pub fn execute_all(&self, mut input: PluginInput) -> Result<PluginOutput> {
468        let mut all_errors = Vec::new();
469
470        for tracked in &self.plugins {
471            let output = tracked.plugin.execute(&input, &self.config)?;
472            all_errors.extend(output.errors);
473            input.directives = output.directives;
474        }
475
476        Ok(PluginOutput {
477            directives: input.directives,
478            errors: all_errors,
479        })
480    }
481
482    /// Get the number of loaded plugins.
483    pub const fn len(&self) -> usize {
484        self.plugins.len()
485    }
486
487    /// Check if any plugins are loaded.
488    pub const fn is_empty(&self) -> bool {
489        self.plugins.is_empty()
490    }
491
492    /// Get plugin paths and their last modification times.
493    pub fn plugin_info(&self) -> Vec<(&Path, SystemTime)> {
494        self.plugins
495            .iter()
496            .map(|t| (t.path.as_path(), t.modified))
497            .collect()
498    }
499}
500
501impl Default for WatchingPluginManager {
502    fn default() -> Self {
503        Self::new()
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    /// Test that a minimal valid WASM module passes validation.
512    ///
513    /// This module exports memory, alloc, and process as required.
514    #[test]
515    fn test_valid_plugin_validation() {
516        // A minimal WASM module with required exports
517        // This is a hand-crafted minimal module that exports:
518        // - memory
519        // - alloc (returns 0)
520        // - process (returns 0)
521        let wasm = wat::parse_str(
522            r#"
523            (module
524                (memory (export "memory") 1)
525                (func (export "alloc") (param i32) (result i32)
526                    i32.const 0
527                )
528                (func (export "process") (param i32 i32) (result i64)
529                    i64.const 0
530                )
531            )
532            "#,
533        )
534        .expect("valid wat");
535
536        let result = validate_plugin_module(&wasm);
537        assert!(
538            result.is_ok(),
539            "valid plugin should pass validation: {:?}",
540            result.err()
541        );
542    }
543
544    /// Test that a module with WASI imports is rejected.
545    #[test]
546    fn test_wasi_import_rejected() {
547        // A module that tries to import WASI fd_write
548        let wasm = wat::parse_str(
549            r#"
550            (module
551                (import "wasi_snapshot_preview1" "fd_write"
552                    (func $fd_write (param i32 i32 i32 i32) (result i32))
553                )
554                (memory (export "memory") 1)
555                (func (export "alloc") (param i32) (result i32)
556                    i32.const 0
557                )
558                (func (export "process") (param i32 i32) (result i64)
559                    i64.const 0
560                )
561            )
562            "#,
563        )
564        .expect("valid wat");
565
566        let result = validate_plugin_module(&wasm);
567        assert!(
568            result.is_err(),
569            "module with WASI import should be rejected"
570        );
571        let err = result.unwrap_err().to_string();
572        assert!(
573            err.contains("forbidden import"),
574            "error should mention forbidden import: {err}"
575        );
576        assert!(
577            err.contains("wasi_snapshot_preview1"),
578            "error should mention WASI: {err}"
579        );
580    }
581
582    /// Test that a module with env imports is rejected.
583    #[test]
584    fn test_env_import_rejected() {
585        // A module that tries to import from env
586        let wasm = wat::parse_str(
587            r#"
588            (module
589                (import "env" "some_func" (func $some_func))
590                (memory (export "memory") 1)
591                (func (export "alloc") (param i32) (result i32)
592                    i32.const 0
593                )
594                (func (export "process") (param i32 i32) (result i64)
595                    i64.const 0
596                )
597            )
598            "#,
599        )
600        .expect("valid wat");
601
602        let result = validate_plugin_module(&wasm);
603        assert!(result.is_err(), "module with env import should be rejected");
604    }
605
606    /// Test that a module missing required exports is rejected.
607    #[test]
608    fn test_missing_exports_rejected() {
609        // Module missing 'alloc' export
610        let wasm = wat::parse_str(
611            r#"
612            (module
613                (memory (export "memory") 1)
614                (func (export "process") (param i32 i32) (result i64)
615                    i64.const 0
616                )
617            )
618            "#,
619        )
620        .expect("valid wat");
621
622        let result = validate_plugin_module(&wasm);
623        assert!(result.is_err(), "module missing alloc should be rejected");
624        assert!(result.unwrap_err().to_string().contains("alloc"));
625    }
626
627    /// Test that runtime config has sane defaults.
628    #[test]
629    fn test_runtime_config_defaults() {
630        let config = RuntimeConfig::default();
631        assert_eq!(config.max_memory, 256 * 1024 * 1024); // 256MB
632        assert_eq!(config.max_time_secs, 30);
633    }
634
635    /// Test that a module missing memory export is rejected.
636    #[test]
637    fn test_missing_memory_rejected() {
638        let wasm = wat::parse_str(
639            r#"
640            (module
641                (func (export "alloc") (param i32) (result i32)
642                    i32.const 0
643                )
644                (func (export "process") (param i32 i32) (result i64)
645                    i64.const 0
646                )
647            )
648            "#,
649        )
650        .expect("valid wat");
651
652        let result = validate_plugin_module(&wasm);
653        assert!(result.is_err(), "module missing memory should be rejected");
654        assert!(result.unwrap_err().to_string().contains("memory"));
655    }
656
657    /// Test that a module missing process export is rejected.
658    #[test]
659    fn test_missing_process_rejected() {
660        let wasm = wat::parse_str(
661            r#"
662            (module
663                (memory (export "memory") 1)
664                (func (export "alloc") (param i32) (result i32)
665                    i32.const 0
666                )
667            )
668            "#,
669        )
670        .expect("valid wat");
671
672        let result = validate_plugin_module(&wasm);
673        assert!(result.is_err(), "module missing process should be rejected");
674        assert!(result.unwrap_err().to_string().contains("process"));
675    }
676
677    /// Test that invalid WASM bytes are rejected.
678    #[test]
679    fn test_invalid_wasm_rejected() {
680        let invalid = b"not valid wasm bytes";
681        let result = validate_plugin_module(invalid);
682        assert!(result.is_err(), "invalid WASM should be rejected");
683    }
684
685    /// Test that runtime config can be customized.
686    #[test]
687    fn test_runtime_config_custom() {
688        let config = RuntimeConfig {
689            max_memory: 512 * 1024 * 1024, // 512MB
690            max_time_secs: 60,
691        };
692        assert_eq!(config.max_memory, 512 * 1024 * 1024);
693        assert_eq!(config.max_time_secs, 60);
694    }
695
696    // ====================================================================
697    // Phase 3: Additional Coverage Tests for Plugin Managers
698    // ====================================================================
699
700    #[test]
701    fn test_plugin_manager_new() {
702        let manager = PluginManager::new();
703        assert!(manager.is_empty());
704        assert_eq!(manager.len(), 0);
705    }
706
707    #[test]
708    fn test_plugin_manager_with_config() {
709        let config = RuntimeConfig {
710            max_memory: 128 * 1024 * 1024,
711            max_time_secs: 10,
712        };
713        let manager = PluginManager::with_config(config);
714        assert!(manager.is_empty());
715    }
716
717    #[test]
718    fn test_plugin_manager_default() {
719        let manager = PluginManager::default();
720        assert!(manager.is_empty());
721        assert_eq!(manager.len(), 0);
722    }
723
724    #[test]
725    fn test_watching_plugin_manager_new() {
726        let manager = WatchingPluginManager::new();
727        assert!(manager.is_empty());
728        assert_eq!(manager.len(), 0);
729        assert!(manager.plugin_info().is_empty());
730    }
731
732    #[test]
733    fn test_watching_plugin_manager_with_config() {
734        let config = RuntimeConfig {
735            max_memory: 64 * 1024 * 1024,
736            max_time_secs: 5,
737        };
738        let manager = WatchingPluginManager::with_config(config);
739        assert!(manager.is_empty());
740    }
741
742    #[test]
743    fn test_watching_plugin_manager_default() {
744        let manager = WatchingPluginManager::default();
745        assert!(manager.is_empty());
746        assert_eq!(manager.len(), 0);
747    }
748
749    #[test]
750    fn test_watching_plugin_manager_get_unknown() {
751        let manager = WatchingPluginManager::new();
752        assert!(manager.get("nonexistent").is_none());
753    }
754
755    #[test]
756    fn test_plugin_manager_execute_out_of_bounds() {
757        let manager = PluginManager::new();
758        let input = crate::types::PluginInput {
759            directives: vec![],
760            options: crate::types::PluginOptions::default(),
761            config: None,
762        };
763        let result = manager.execute(0, &input);
764        assert!(result.is_err());
765        assert!(result.unwrap_err().to_string().contains("out of bounds"));
766    }
767
768    #[test]
769    fn test_watching_plugin_manager_execute_out_of_bounds() {
770        let manager = WatchingPluginManager::new();
771        let input = crate::types::PluginInput {
772            directives: vec![],
773            options: crate::types::PluginOptions::default(),
774            config: None,
775        };
776        let result = manager.execute(0, &input);
777        assert!(result.is_err());
778        assert!(result.unwrap_err().to_string().contains("out of bounds"));
779    }
780
781    #[test]
782    fn test_watching_plugin_manager_execute_by_name_unknown() {
783        let manager = WatchingPluginManager::new();
784        let input = crate::types::PluginInput {
785            directives: vec![],
786            options: crate::types::PluginOptions::default(),
787            config: None,
788        };
789        let result = manager.execute_by_name("unknown", &input);
790        assert!(result.is_err());
791        assert!(result.unwrap_err().to_string().contains("not found"));
792    }
793
794    #[test]
795    fn test_plugin_manager_execute_all_empty() {
796        let manager = PluginManager::new();
797        let input = crate::types::PluginInput {
798            directives: vec![],
799            options: crate::types::PluginOptions::default(),
800            config: None,
801        };
802        let result = manager.execute_all(input);
803        assert!(result.is_ok());
804        let output = result.unwrap();
805        assert!(output.directives.is_empty());
806        assert!(output.errors.is_empty());
807    }
808
809    #[test]
810    fn test_watching_plugin_manager_execute_all_empty() {
811        let manager = WatchingPluginManager::new();
812        let input = crate::types::PluginInput {
813            directives: vec![],
814            options: crate::types::PluginOptions::default(),
815            config: None,
816        };
817        let result = manager.execute_all(input);
818        assert!(result.is_ok());
819        let output = result.unwrap();
820        assert!(output.directives.is_empty());
821        assert!(output.errors.is_empty());
822    }
823
824    #[test]
825    fn test_watching_plugin_manager_check_reload_empty() {
826        let mut manager = WatchingPluginManager::new();
827        let result = manager.check_and_reload();
828        assert!(result.is_ok());
829        assert!(!result.unwrap()); // No plugins reloaded
830    }
831
832    #[test]
833    fn test_watching_plugin_manager_reload_all_empty() {
834        let mut manager = WatchingPluginManager::new();
835        let result = manager.reload_all();
836        assert!(result.is_ok()); // Should succeed with empty manager
837    }
838
839    #[test]
840    fn test_plugin_load_bytes() {
841        let wasm = wat::parse_str(
842            r#"
843            (module
844                (memory (export "memory") 1)
845                (func (export "alloc") (param i32) (result i32)
846                    i32.const 0
847                )
848                (func (export "process") (param i32 i32) (result i64)
849                    i64.const 0
850                )
851            )
852            "#,
853        )
854        .expect("valid wat");
855
856        let config = RuntimeConfig::default();
857        let result = Plugin::load_bytes("test_plugin", &wasm, &config);
858        assert!(result.is_ok());
859
860        let plugin = result.unwrap();
861        assert_eq!(plugin.name(), "test_plugin");
862    }
863
864    #[test]
865    fn test_plugin_manager_load_bytes() {
866        let wasm = wat::parse_str(
867            r#"
868            (module
869                (memory (export "memory") 1)
870                (func (export "alloc") (param i32) (result i32)
871                    i32.const 0
872                )
873                (func (export "process") (param i32 i32) (result i64)
874                    i64.const 0
875                )
876            )
877            "#,
878        )
879        .expect("valid wat");
880
881        let mut manager = PluginManager::new();
882        let result = manager.load_bytes("my_plugin", &wasm);
883        assert!(result.is_ok());
884        assert_eq!(result.unwrap(), 0); // First plugin index
885        assert_eq!(manager.len(), 1);
886        assert!(!manager.is_empty());
887    }
888
889    #[test]
890    fn test_plugin_manager_multiple_plugins() {
891        let wasm = wat::parse_str(
892            r#"
893            (module
894                (memory (export "memory") 1)
895                (func (export "alloc") (param i32) (result i32)
896                    i32.const 0
897                )
898                (func (export "process") (param i32 i32) (result i64)
899                    i64.const 0
900                )
901            )
902            "#,
903        )
904        .expect("valid wat");
905
906        let mut manager = PluginManager::new();
907        manager.load_bytes("plugin1", &wasm).unwrap();
908        manager.load_bytes("plugin2", &wasm).unwrap();
909        manager.load_bytes("plugin3", &wasm).unwrap();
910
911        assert_eq!(manager.len(), 3);
912    }
913
914    #[test]
915    fn test_validate_truncated_wasm() {
916        // Start of valid WASM but truncated
917        let truncated = &[0x00, 0x61, 0x73, 0x6d]; // Just the magic bytes
918        let result = validate_plugin_module(truncated);
919        assert!(result.is_err());
920    }
921
922    #[test]
923    fn test_validate_wrong_magic() {
924        let wrong_magic = &[0xFF, 0xFF, 0xFF, 0xFF];
925        let result = validate_plugin_module(wrong_magic);
926        assert!(result.is_err());
927    }
928
929    #[test]
930    fn test_validate_empty_wasm() {
931        let empty: &[u8] = &[];
932        let result = validate_plugin_module(empty);
933        assert!(result.is_err());
934    }
935}