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::{Engine, Linker, Module};
32
33use crate::sandbox;
34use crate::types::{DirectiveWrapper, PluginInput, PluginOp, PluginOutput};
35
36/// Materialize a plugin's `ops` against its input directive list,
37/// producing the resulting flat list of wrappers.
38///
39/// Used by `execute_all` to chain plugin outputs back into the next
40/// plugin's input. The loader uses a more elaborate version
41/// (`apply_plugin_ops` in `rustledger-loader`) that also validates the
42/// ops protocol invariants and preserves source spans; here we just
43/// need the materialized list.
44fn 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/// Configuration for the plugin runtime.
61///
62/// **Applied at `Plugin::execute` time, not at load.** `Plugin::load`
63/// and `Plugin::load_bytes` accept a `&RuntimeConfig` for API
64/// symmetry but ignore it — only the per-call execution caps
65/// (`max_memory`, `max_time_secs`) are meaningful, and those flow
66/// into the per-`Store` setup that `execute` builds.
67#[derive(Debug, Clone)]
68pub struct RuntimeConfig {
69    /// Maximum memory in bytes (default: 256MB). Enforced at
70    /// `Plugin::execute` via [`crate::sandbox::make_sandboxed_store`].
71    pub max_memory: usize,
72    /// Maximum execution time in seconds (default: 30). Clamped to
73    /// `≥1` and `saturating_mul`'d to fuel units; see
74    /// [`crate::sandbox::make_sandboxed_store`].
75    pub max_time_secs: u64,
76}
77
78impl Default for RuntimeConfig {
79    fn default() -> Self {
80        Self {
81            // Both defaults flow from `sandbox` constants so this
82            // path, the WASM importer, and the Python plugin runtime
83            // (memory only — Python opts out of the time default)
84            // share single sources of truth.
85            max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
86            max_time_secs: crate::sandbox::DEFAULT_SANDBOX_MAX_TIME_SECS,
87        }
88    }
89}
90
91/// Validate that a WASM module doesn't have any forbidden imports.
92///
93/// Beancount plugins should be self-contained and not require any
94/// external imports (WASI, env, etc.). This function checks that the
95/// module only has the expected exports and no unexpected imports.
96///
97/// # Errors
98///
99/// Returns an error if the module has forbidden imports or is missing
100/// required exports.
101pub fn validate_plugin_module(bytes: &[u8]) -> Result<()> {
102    // Use the workspace sandbox config — same feature flags the
103    // runtime applies at load. Otherwise `validate_plugin_module`
104    // could say "Ok" against vanilla wasmtime features but the
105    // actual `Plugin::load_bytes` would reject the same module
106    // because (e.g.) it uses `wasm_threads`.
107    let engine = Engine::new(&sandbox::sandbox_config())?;
108    let module = Module::new(&engine, bytes)?;
109    validate_loaded_module(&module)
110}
111
112/// Inner validator that operates on an already-compiled [`Module`].
113/// Used by both [`validate_plugin_module`] (the public bytes-taking
114/// helper) and `Plugin::load`/`load_bytes` so the module is only
115/// compiled once during load instead of twice.
116fn validate_loaded_module(module: &Module) -> Result<()> {
117    // Check for forbidden imports (any imports are forbidden)
118    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    // Verify required exports exist
127    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
142/// A loaded WASM plugin.
143pub struct Plugin {
144    /// Plugin name (derived from filename).
145    name: String,
146    /// Compiled module.
147    module: Module,
148    /// Engine reference.
149    engine: Arc<Engine>,
150}
151
152impl Plugin {
153    /// Load a plugin from a WASM file.
154    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        // Process-wide shared engine with the workspace's locked-down
162        // wasm-feature config (see `sandbox::sandbox_config` for the
163        // list). One Engine per process amortizes JIT/cache cost
164        // across all loaded plugins.
165        let engine = sandbox::shared_engine();
166
167        // Load and compile the module
168        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 imports + required exports at load time so failures
176        // surface here (with a clear "must export" message) rather than
177        // deeper in `execute()` where they look like signature mismatches.
178        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    /// Load a plugin from WASM bytes.
189    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        // Same load-time validation as `load` — see that method's
200        // comment for rationale.
201        validate_loaded_module(&module).with_context(|| format!("invalid plugin `{name}`"))?;
202
203        Ok(Self {
204            name,
205            module,
206            engine,
207        })
208    }
209
210    /// Get the plugin name.
211    pub fn name(&self) -> &str {
212        &self.name
213    }
214
215    /// Execute the plugin with the given input.
216    pub fn execute(&self, input: &PluginInput, config: &RuntimeConfig) -> Result<PluginOutput> {
217        // Workspace-shared sandboxed store: wires the MemoryLimiter
218        // (enforcing `config.max_memory` on initial allocation +
219        // `memory.grow`) and the fuel budget (clamped ≥1 + saturating
220        // to avoid zero-fuel starvation and u64 overflow). Mirrors the
221        // WASM importer host so per-call enforcement is consistent
222        // across the workspace.
223        let mut store =
224            sandbox::make_sandboxed_store(&self.engine, config.max_memory, config.max_time_secs)?;
225
226        // Create linker with NO imports for full sandboxing
227        // Plugins have no access to filesystem, network, or any system calls
228        let linker = Linker::new(&self.engine);
229
230        // Instantiate the module
231        let instance = linker.instantiate(&mut store, &self.module)?;
232
233        // ABI handshake: confirm the guest was built against a
234        // compatible plugin-types before we hand it any data. A
235        // version skew otherwise manifests as an opaque trap once the
236        // guest misreads `PluginInput`; the check turns that into a
237        // clear error naming both versions (issue #1234). Plugins
238        // instantiate per-execute, so this runs here rather than at
239        // load; the cost is one extra typed-func call.
240        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        // Serialize input. The serializer choice (default-mode
261        // `to_vec`, not `to_vec_named`) is pinned by the
262        // cross-boundary wire-format tests in
263        // `rustledger-plugin-types/tests/cost_number_wire_format.rs`.
264        // If you change this call to a different rmp_serde entry
265        // point, update those tests to match — otherwise plugins
266        // built against the old wire shape silently break.
267        let input_bytes = rmp_serde::to_vec(input)?;
268
269        // `validate_loaded_module` proved `memory` presence at load
270        // time — an absent export here is unreachable in practice.
271        let memory = instance
272            .get_memory(&mut store, "memory")
273            .expect("validate_loaded_module verified `memory` export at load");
274
275        // Same reasoning: presence is guaranteed by load-time
276        // validation, so any `get_typed_func` failure here is a
277        // signature mismatch (e.g. plugin declared `alloc(i64) -> i64`
278        // instead of `alloc(u32) -> u32`), not absence.
279        let alloc = instance
280            .get_typed_func::<u32, u32>(&mut store, "alloc")
281            .map_err(anyhow::Error::from)
282            // wasmtime's error already names the expected vs found
283            // signature — our context just labels what was being
284            // looked up. Avoids drift if the ABI ever changes.
285            .context("plugin export `alloc` has wrong signature")?;
286
287        // Allocate space for input
288        let input_ptr = alloc.call(&mut store, input_bytes.len() as u32)?;
289
290        // Write input to WASM memory
291        memory.write(&mut store, input_ptr as usize, &input_bytes)?;
292
293        // Call the process function
294        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        // Parse result (packed as ptr << 32 | len)
302        let output_ptr = (result >> 32) as u32;
303        let output_len = (result & 0xFFFF_FFFF) as u32;
304
305        // Read output from WASM memory
306        let mut output_bytes = vec![0u8; output_len as usize];
307        memory.read(&store, output_ptr as usize, &mut output_bytes)?;
308
309        // Deserialize output
310        let output: PluginOutput = rmp_serde::from_slice(&output_bytes)?;
311
312        Ok(output)
313    }
314}
315
316/// Result of [`PluginManager::register_wasm_dir`].
317///
318/// Splits successfully-loaded plugin names from per-file failures so
319/// callers can log/report both. A single broken module in a dir with
320/// 19 good ones leaves the 19 registered; the broken one's path + error
321/// land in [`Self::failures`]. Mirrors `rustledger_importer::WasmDirScanReport`.
322#[derive(Debug, Default)]
323pub struct WasmPluginDirScanReport {
324    /// Plugin names of each successfully-loaded module, in load order
325    /// (lexicographic by file path). Name is the file stem — the path's
326    /// final component without the `.wasm` extension.
327    pub loaded: Vec<String>,
328    /// Per-file load failures. Each entry is the `.wasm` path plus the
329    /// underlying error. Per-entry I/O errors (rare — broken symlinks,
330    /// permission denied on a single inode) appear here tagged with
331    /// the dir path since the inode's name isn't known.
332    pub failures: Vec<(PathBuf, anyhow::Error)>,
333}
334
335/// Plugin manager that caches loaded plugins.
336pub struct PluginManager {
337    /// Runtime configuration.
338    config: RuntimeConfig,
339    /// Loaded plugins.
340    plugins: Vec<Plugin>,
341}
342
343impl PluginManager {
344    /// Create a new plugin manager.
345    pub fn new() -> Self {
346        Self::with_config(RuntimeConfig::default())
347    }
348
349    /// Create a plugin manager with custom configuration.
350    pub const fn with_config(config: RuntimeConfig) -> Self {
351        Self {
352            config,
353            plugins: Vec::new(),
354        }
355    }
356
357    /// Load a plugin from a file path.
358    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    /// Load a plugin from bytes.
366    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    /// Scan `dir` for `*.wasm` files (one level only — no recursion)
374    /// and register each as a plugin.
375    ///
376    /// Files are loaded in sorted order so multi-plugin pipelines have
377    /// deterministic ordering across filesystems and platforms.
378    /// Extension matching is case-insensitive — `foo.wasm` and
379    /// `BAR.WASM` are both picked up.
380    ///
381    /// Loading is **skip-and-collect**: every loadable module is
382    /// registered; failures are accumulated in
383    /// [`WasmPluginDirScanReport::failures`] so the caller can decide
384    /// whether to log them, abort, or ignore. A single broken module
385    /// in a dir with 19 good ones doesn't prevent the 19 from
386    /// loading. Mirrors `ImporterRegistry::register_wasm_dir` in
387    /// `rustledger-importer`.
388    ///
389    /// Non-`.wasm` files (a `README.md` or `.gitignore`) and
390    /// subdirectories are silently skipped. Entries whose metadata
391    /// can't be read at all (broken symlinks; the file existed in
392    /// `read_dir`'s listing but `path.is_file()` returns false) are
393    /// also silently skipped — `std::fs::DirEntry::path().is_file()`
394    /// swallows the underlying I/O error. If that matters for your
395    /// use case, walk the dir yourself with explicit
396    /// `symlink_metadata` checks.
397    ///
398    /// # Errors
399    ///
400    /// The outer `Result` reports an I/O error reading `dir` itself
401    /// (dir doesn't exist, permission denied on the dir). Per-file
402    /// load failures land inside the report's `failures` vec so the
403    /// caller can surface them without aborting the rest of the scan.
404    pub fn register_wasm_dir(&mut self, dir: impl AsRef<Path>) -> Result<WasmPluginDirScanReport> {
405        let dir = dir.as_ref();
406        // Listing/filtering/sorting is shared with `ImporterRegistry::register_wasm_dir`
407        // in `rustledger-importer` — see `crate::wasm_dir_scan` for the
408        // common helper. Caller-side: dir-level error context + the
409        // per-file load fn + the per-entry error wrapping.
410        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        // Forward per-entry I/O failures wrapped in anyhow.
414        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                    // Read the name back from the registered Plugin so
421                    // `report.loaded` exactly matches `Plugin::name()`
422                    // for all subsequent calls — including the
423                    // non-UTF-8-filename edge case where `Plugin::load`
424                    // falls back to `"unknown"`.
425                    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    /// Execute a plugin by index.
435    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    /// Execute all loaded plugins in sequence.
444    ///
445    /// Note: because the ops protocol references the **plugin's** input
446    /// indices and `execute_all` chains plugins by materializing each
447    /// stage's ops before feeding the next, the final ops returned
448    /// here describe the result relative to the original input as a
449    /// **rebuild**: every output directive is encoded as
450    /// [`PluginOp::Insert`] and every original input is encoded as
451    /// [`PluginOp::Delete`]. Loader callers don't go through this
452    /// path — they apply ops one plugin at a time — so this simplifies
453    /// the multi-plugin WASM-runtime case to "here's the resulting
454    /// directive list" without losing the protocol's invariants.
455    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            // Materialize this plugin's ops to feed the next plugin.
462            input.directives = materialize_ops(&input.directives, &output);
463            all_errors.extend(output.errors);
464        }
465
466        // Rebuild-style ops: Delete every original input, Insert every
467        // final directive. Order: deletes first so the protocol
468        // invariant (each input index appears once) is satisfied.
469        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    /// Get the number of loaded plugins.
481    pub const fn len(&self) -> usize {
482        self.plugins.len()
483    }
484
485    /// Check if any plugins are loaded.
486    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
497/// A plugin with file tracking info for hot-reloading.
498struct TrackedPlugin {
499    /// The loaded plugin.
500    plugin: Plugin,
501    /// Path to the WASM file.
502    path: PathBuf,
503    /// Last modification time.
504    modified: SystemTime,
505}
506
507/// Plugin manager with hot-reloading support.
508///
509/// This manager tracks plugin file modification times and can reload
510/// plugins when their source files change. This is useful for development
511/// workflows where you want to iterate on plugins without restarting.
512///
513/// # Example
514///
515/// ```ignore
516/// use rustledger_plugin::WatchingPluginManager;
517///
518/// let mut manager = WatchingPluginManager::new();
519/// manager.load("plugins/my_plugin.wasm")?;
520///
521/// // Check for changes and reload if needed
522/// if manager.check_and_reload()? {
523///     println!("Plugins reloaded!");
524/// }
525/// ```
526pub struct WatchingPluginManager {
527    /// Runtime configuration.
528    config: RuntimeConfig,
529    /// Tracked plugins with file info.
530    plugins: Vec<TrackedPlugin>,
531    /// Plugin name to index mapping for lookup.
532    name_index: HashMap<String, usize>,
533    /// Reload callback (optional).
534    on_reload: Option<Box<dyn Fn(&str) + Send + Sync>>,
535}
536
537impl WatchingPluginManager {
538    /// Create a new watching plugin manager.
539    pub fn new() -> Self {
540        Self::with_config(RuntimeConfig::default())
541    }
542
543    /// Create a watching plugin manager with custom configuration.
544    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    /// Set a callback to be invoked when a plugin is reloaded.
554    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    /// Load a plugin from a file path.
562    pub fn load(&mut self, path: impl AsRef<Path>) -> Result<usize> {
563        let path = path.as_ref();
564        // Canonicalize path, or use original if it fails (e.g., symlink issues)
565        let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
566
567        // Get modification time
568        let metadata = std::fs::metadata(&abs_path)
569            .with_context(|| format!("failed to stat {}", abs_path.display()))?;
570        let modified = metadata.modified()?;
571
572        // Load the plugin
573        let plugin = Plugin::load(&abs_path, &self.config)?;
574        let name = plugin.name().to_string();
575        let index = self.plugins.len();
576
577        // Track the plugin
578        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    /// Check for file changes and reload modified plugins.
589    ///
590    /// Returns `true` if any plugins were reloaded.
591    pub fn check_and_reload(&mut self) -> Result<bool> {
592        let mut reloaded = false;
593
594        for tracked in &mut self.plugins {
595            // Get current modification time
596            let metadata = match std::fs::metadata(&tracked.path) {
597                Ok(m) => m,
598                Err(_) => continue, // File might have been deleted
599            };
600
601            let current_modified = match metadata.modified() {
602                Ok(m) => m,
603                Err(_) => continue,
604            };
605
606            // Check if file was modified
607            if current_modified > tracked.modified {
608                // Reload the plugin
609                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                        // Call reload callback if set
617                        if let Some(ref callback) = self.on_reload {
618                            callback(&name);
619                        }
620                    }
621                    Err(e) => {
622                        // Log error but don't fail - keep using old plugin
623                        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    /// Force reload all plugins.
637    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    /// Get a plugin by name.
648    pub fn get(&self, name: &str) -> Option<&Plugin> {
649        self.name_index.get(name).map(|&i| &self.plugins[i].plugin)
650    }
651
652    /// Execute a plugin by index.
653    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    /// Execute a plugin by name.
662    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    /// Execute all loaded plugins in sequence.
671    ///
672    /// See [`PluginManager::execute_all`] for the rebuild-style
673    /// (Delete-all-Insert-all) op encoding rationale.
674    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    /// Get the number of loaded plugins.
696    pub const fn len(&self) -> usize {
697        self.plugins.len()
698    }
699
700    /// Check if any plugins are loaded.
701    pub const fn is_empty(&self) -> bool {
702        self.plugins.is_empty()
703    }
704
705    /// Get plugin paths and their last modification times.
706    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 that a minimal valid WASM module passes validation.
726    ///
727    /// This module exports memory, alloc, and process as required.
728    #[test]
729    fn test_valid_plugin_validation() {
730        // A minimal WASM module with required exports
731        // This is a hand-crafted minimal module that exports:
732        // - memory
733        // - alloc (returns 0)
734        // - process (returns 0)
735        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 that a module with WASI imports is rejected.
759    #[test]
760    fn test_wasi_import_rejected() {
761        // A module that tries to import WASI fd_write
762        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 that a module with env imports is rejected.
797    #[test]
798    fn test_env_import_rejected() {
799        // A module that tries to import from env
800        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 that a module missing required exports is rejected.
821    #[test]
822    fn test_missing_exports_rejected() {
823        // Module missing 'alloc' export
824        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 that runtime config has sane defaults.
842    #[test]
843    fn test_runtime_config_defaults() {
844        let config = RuntimeConfig::default();
845        // Single source of truth: both fields are aliases of the
846        // sandbox-wide defaults. The Python and importer paths
847        // reference the same constants, so a future bump to either
848        // value propagates uniformly.
849        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 that a module missing memory export is rejected.
860    #[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 that a module missing process export is rejected.
882    #[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 that invalid WASM bytes are rejected.
902    #[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 that runtime config can be customized.
910    #[test]
911    fn test_runtime_config_custom() {
912        let config = RuntimeConfig {
913            max_memory: 512 * 1024 * 1024, // 512MB
914            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    // ====================================================================
921    // Phase 3: Additional Coverage Tests for Plugin Managers
922    // ====================================================================
923
924    #[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()); // No plugins reloaded
1054    }
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()); // Should succeed with empty manager
1061    }
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); // First plugin index
1109        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        // Start of valid WASM but truncated
1141        let truncated = &[0x00, 0x61, 0x73, 0x6d]; // Just the magic bytes
1142        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        // Plugin declares 5000 pages (320 MiB) initial memory.
1163        // With max_memory = 64 MiB, instantiation inside execute()
1164        // must fail via the MemoryLimiter wired by
1165        // `sandbox::make_sandboxed_store`. Pins the equivalent of
1166        // the importer's `initial_memory_above_cap_is_rejected_via_limiter_wiring`
1167        // test for the plugin runtime path — proves the per-Store
1168        // limiter is actually applied here, not just in the importer.
1169        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        // Check for one of the keywords wasmtime uses when a
1197        // ResourceLimiter rejects allocation. Wording varies across
1198        // versions, but at least one of these tokens has appeared
1199        // in every release we've targeted, so this catches a
1200        // truly-silent failure (e.g. limiter not wired) while
1201        // tolerating message rephrasings.
1202        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    /// A plugin WAT with the required memory/alloc/process exports plus
1210    /// a configurable `__rustledger_abi_version`. Pass `""` to model a
1211    /// pre-handshake guest. `process` returns junk because the ABI
1212    /// check runs right after instantiation, before it is ever called.
1213    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    /// Issue #1234: a plugin that doesn't advertise an ABI version is
1238    /// rejected with a clear error instead of an opaque trap when the
1239    /// guest misreads `PluginInput`.
1240    #[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    /// Issue #1234: a plugin built against a different ABI version is
1256    /// rejected, naming both versions.
1257    #[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        // Plugin has `alloc(i64) -> i64` instead of the required
1278        // `alloc(u32) -> u32`. Presence check (validate_loaded_module)
1279        // passes — the export is there. The signature mismatch
1280        // surfaces inside `execute()` with the new "wrong signature"
1281        // context. Pre-PR this would have read "plugin must export
1282        // 'alloc' function" — misleading, since it DOES export it.
1283        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        // Symmetric to the `alloc` sibling: `process` is declared
1319        // as `(i32, i32) -> i32` instead of `(u32, u32) -> u64`.
1320        // Presence check passes; signature mismatch surfaces with
1321        // the new "wrong signature" context.
1322        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    /// Minimal passthrough WAT used by the fuel-clamp tests below.
1356    /// `process` returns `(ptr=0, len=0)` which deserializes to an
1357    /// empty `PluginOutput` — enough to exercise the full fuel path.
1358    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    /// Assert that the error from a passthrough-WAT `execute` is the
1383    /// expected msgpack-decode failure (the WAT returns
1384    /// `(ptr=0, len=0)`, which can't parse as `PluginOutput`) and not
1385    /// a fuel-exhaustion trap.
1386    ///
1387    /// Reaching the decode step proves WASM execution completed —
1388    /// any fuel-starvation bug would have trapped before then.
1389    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        // Regression for the fuel-calc bug fix that landed via
1400        // `make_sandboxed_store`. Pre-PR, `max_time_secs = 0` caused
1401        // immediate fuel-exhaustion trap on first instruction. Now
1402        // clamped to ≥1 second of fuel by the shared helper.
1403        // Proves the plugin runtime gets the fix, not just the
1404        // importer.
1405        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        // Regression for the saturating_mul fix. Pre-PR, max_time_secs
1421        // = u64::MAX would panic in debug and silently wrap in
1422        // release. Now saturates to u64::MAX fuel via the shared
1423        // helper.
1424        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    // ===== register_wasm_dir =====
1438    //
1439    // Mirrors `ImporterRegistry::register_wasm_dir`'s skip-and-collect
1440    // contract. Build a tempdir holding a mix of valid `.wasm`, invalid
1441    // `.wasm`, non-wasm files, and a subdirectory; assert the loader
1442    // picks only the top-level `.wasm` files, loads what's valid,
1443    // collects failures for what isn't, and never aborts the scan.
1444
1445    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        // Two valid plugins — load in sorted order: `a_first`, `b_second`.
1466        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        // A broken `.wasm` — failure lands in `failures`, doesn't abort.
1470        std::fs::write(dir_path.join("broken.wasm"), b"not a wasm module").unwrap();
1471
1472        // Non-wasm files — silently ignored.
1473        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        // Subdirectory — not recursed into. Even with a `.wasm` inside.
1477        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        // Sorted load order — `a_first` before `b_second`.
1487        assert_eq!(report.loaded, vec!["a_first", "b_second"]);
1488        assert_eq!(manager.len(), 2);
1489
1490        // `broken.wasm` is the only failure.
1491        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        // Use a tempdir-relative path that's guaranteed not to exist
1501        // — hard-coding `/this/dir/does/not/exist` could pass on a
1502        // weird machine where that path happens to be a real dir,
1503        // and would fail with a different error class on platforms
1504        // where the syscall behaves differently.
1505        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}