use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use anyhow::{Context, Result};
use wasmtime::{Engine, Linker, Module};
use crate::sandbox;
use crate::types::{DirectiveWrapper, PluginInput, PluginOp, PluginOutput};
fn materialize_ops(input: &[DirectiveWrapper], output: &PluginOutput) -> Vec<DirectiveWrapper> {
let mut out = Vec::with_capacity(output.ops.len());
for op in &output.ops {
match op {
PluginOp::Keep(i) => {
if let Some(w) = input.get(*i) {
out.push(w.clone());
}
}
PluginOp::Modify(_, w) | PluginOp::Insert(w) => out.push(w.clone()),
PluginOp::Delete(_) => {}
}
}
out
}
#[derive(Debug, Clone)]
pub struct RuntimeConfig {
pub max_memory: usize,
pub max_time_secs: u64,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
max_time_secs: crate::sandbox::DEFAULT_SANDBOX_MAX_TIME_SECS,
}
}
}
pub fn validate_plugin_module(bytes: &[u8]) -> Result<()> {
let engine = Engine::new(&sandbox::sandbox_config())?;
let module = Module::new(&engine, bytes)?;
validate_loaded_module(&module)
}
fn validate_loaded_module(module: &Module) -> Result<()> {
if let Some(import) = module.imports().next() {
anyhow::bail!(
"plugin has forbidden import: {}::{}",
import.module(),
import.name()
);
}
let exports: Vec<_> = module.exports().map(|e| e.name()).collect();
if !exports.contains(&"memory") {
anyhow::bail!("plugin must export 'memory'");
}
if !exports.contains(&"alloc") {
anyhow::bail!("plugin must export 'alloc' function");
}
if !exports.contains(&"process") {
anyhow::bail!("plugin must export 'process' function");
}
Ok(())
}
pub struct Plugin {
name: String,
module: Module,
engine: Arc<Engine>,
}
impl Plugin {
pub fn load(path: &Path, _config: &RuntimeConfig) -> Result<Self> {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let engine = sandbox::shared_engine();
let wasm_bytes =
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
let module = Module::new(&engine, &wasm_bytes)
.map_err(anyhow::Error::from)
.with_context(|| format!("invalid plugin {}", path.display()))?;
validate_loaded_module(&module)
.with_context(|| format!("invalid plugin {}", path.display()))?;
Ok(Self {
name,
module,
engine,
})
}
pub fn load_bytes(
name: impl Into<String>,
bytes: &[u8],
_config: &RuntimeConfig,
) -> Result<Self> {
let name = name.into();
let engine = sandbox::shared_engine();
let module = Module::new(&engine, bytes)
.map_err(anyhow::Error::from)
.with_context(|| format!("invalid plugin `{name}`"))?;
validate_loaded_module(&module).with_context(|| format!("invalid plugin `{name}`"))?;
Ok(Self {
name,
module,
engine,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn execute(&self, input: &PluginInput, config: &RuntimeConfig) -> Result<PluginOutput> {
let mut store =
sandbox::make_sandboxed_store(&self.engine, config.max_memory, config.max_time_secs)?;
let linker = Linker::new(&self.engine);
let instance = linker.instantiate(&mut store, &self.module)?;
match sandbox::check_guest_abi(&instance, &mut store) {
sandbox::AbiCheck::Match => {}
sandbox::AbiCheck::Missing => anyhow::bail!(
"plugin `{name}` has a missing or invalid `{export}` export (expected \
signature `() -> u32`): it was built against an incompatible \
rustledger-plugin-types, or the export is absent, mistyped, or traps. \
Host requires ABI v{ver}. Rebuild it against a matching \
rustledger-plugin-types.",
name = self.name,
export = rustledger_plugin_types::ABI_VERSION_EXPORT,
ver = sandbox::HOST_ABI_VERSION,
),
sandbox::AbiCheck::Mismatch { found } => anyhow::bail!(
"plugin `{name}` ABI version mismatch: plugin declares v{found}, host requires \
v{ver}. Rebuild it against a matching rustledger-plugin-types.",
name = self.name,
ver = sandbox::HOST_ABI_VERSION,
),
}
let input_bytes = rmp_serde::to_vec(input)?;
let memory = instance
.get_memory(&mut store, "memory")
.expect("validate_loaded_module verified `memory` export at load");
let alloc = instance
.get_typed_func::<u32, u32>(&mut store, "alloc")
.map_err(anyhow::Error::from)
.context("plugin export `alloc` has wrong signature")?;
let input_ptr = alloc.call(&mut store, input_bytes.len() as u32)?;
memory.write(&mut store, input_ptr as usize, &input_bytes)?;
let process = instance
.get_typed_func::<(u32, u32), u64>(&mut store, "process")
.map_err(anyhow::Error::from)
.context("plugin export `process` has wrong signature")?;
let result = process.call(&mut store, (input_ptr, input_bytes.len() as u32))?;
let output_ptr = (result >> 32) as u32;
let output_len = (result & 0xFFFF_FFFF) as u32;
let mut output_bytes = vec![0u8; output_len as usize];
memory.read(&store, output_ptr as usize, &mut output_bytes)?;
let output: PluginOutput = rmp_serde::from_slice(&output_bytes)?;
Ok(output)
}
}
#[derive(Debug, Default)]
pub struct WasmPluginDirScanReport {
pub loaded: Vec<String>,
pub failures: Vec<(PathBuf, anyhow::Error)>,
}
pub struct PluginManager {
config: RuntimeConfig,
plugins: Vec<Plugin>,
}
impl PluginManager {
pub fn new() -> Self {
Self::with_config(RuntimeConfig::default())
}
pub const fn with_config(config: RuntimeConfig) -> Self {
Self {
config,
plugins: Vec::new(),
}
}
pub fn load(&mut self, path: &Path) -> Result<usize> {
let plugin = Plugin::load(path, &self.config)?;
let index = self.plugins.len();
self.plugins.push(plugin);
Ok(index)
}
pub fn load_bytes(&mut self, name: impl Into<String>, bytes: &[u8]) -> Result<usize> {
let plugin = Plugin::load_bytes(name, bytes, &self.config)?;
let index = self.plugins.len();
self.plugins.push(plugin);
Ok(index)
}
pub fn register_wasm_dir(&mut self, dir: impl AsRef<Path>) -> Result<WasmPluginDirScanReport> {
let dir = dir.as_ref();
let scan = crate::wasm_dir_scan::collect_wasm_paths(dir)
.with_context(|| format!("failed to read plugin dir {}", dir.display()))?;
let mut report = WasmPluginDirScanReport::default();
for (path, source) in scan.entry_failures {
report.failures.push((path, anyhow::Error::new(source)));
}
for path in scan.sorted_paths {
match self.load(&path) {
Ok(index) => {
let name = self.plugins[index].name().to_string();
report.loaded.push(name);
}
Err(e) => report.failures.push((path, e)),
}
}
Ok(report)
}
pub fn execute(&self, index: usize, input: &PluginInput) -> Result<PluginOutput> {
let plugin = self
.plugins
.get(index)
.context("plugin index out of bounds")?;
plugin.execute(input, &self.config)
}
pub fn execute_all(&self, mut input: PluginInput) -> Result<PluginOutput> {
let mut all_errors = Vec::new();
let n_original = input.directives.len();
for plugin in &self.plugins {
let output = plugin.execute(&input, &self.config)?;
input.directives = materialize_ops(&input.directives, &output);
all_errors.extend(output.errors);
}
let mut ops: Vec<PluginOp> = (0..n_original).map(PluginOp::Delete).collect();
for w in input.directives {
ops.push(PluginOp::Insert(w));
}
Ok(PluginOutput {
ops,
errors: all_errors,
})
}
pub const fn len(&self) -> usize {
self.plugins.len()
}
pub const fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
struct TrackedPlugin {
plugin: Plugin,
path: PathBuf,
modified: SystemTime,
}
pub struct WatchingPluginManager {
config: RuntimeConfig,
plugins: Vec<TrackedPlugin>,
name_index: HashMap<String, usize>,
on_reload: Option<Box<dyn Fn(&str) + Send + Sync>>,
}
impl WatchingPluginManager {
pub fn new() -> Self {
Self::with_config(RuntimeConfig::default())
}
pub fn with_config(config: RuntimeConfig) -> Self {
Self {
config,
plugins: Vec::new(),
name_index: HashMap::new(),
on_reload: None,
}
}
pub fn on_reload<F>(&mut self, callback: F)
where
F: Fn(&str) + Send + Sync + 'static,
{
self.on_reload = Some(Box::new(callback));
}
pub fn load(&mut self, path: impl AsRef<Path>) -> Result<usize> {
let path = path.as_ref();
let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let metadata = std::fs::metadata(&abs_path)
.with_context(|| format!("failed to stat {}", abs_path.display()))?;
let modified = metadata.modified()?;
let plugin = Plugin::load(&abs_path, &self.config)?;
let name = plugin.name().to_string();
let index = self.plugins.len();
self.plugins.push(TrackedPlugin {
plugin,
path: abs_path,
modified,
});
self.name_index.insert(name, index);
Ok(index)
}
pub fn check_and_reload(&mut self) -> Result<bool> {
let mut reloaded = false;
for tracked in &mut self.plugins {
let metadata = match std::fs::metadata(&tracked.path) {
Ok(m) => m,
Err(_) => continue, };
let current_modified = match metadata.modified() {
Ok(m) => m,
Err(_) => continue,
};
if current_modified > tracked.modified {
match Plugin::load(&tracked.path, &self.config) {
Ok(new_plugin) => {
let name = tracked.plugin.name().to_string();
tracked.plugin = new_plugin;
tracked.modified = current_modified;
reloaded = true;
if let Some(ref callback) = self.on_reload {
callback(&name);
}
}
Err(e) => {
eprintln!(
"warning: failed to reload plugin {}: {}",
tracked.path.display(),
e
);
}
}
}
}
Ok(reloaded)
}
pub fn reload_all(&mut self) -> Result<()> {
for tracked in &mut self.plugins {
let new_plugin = Plugin::load(&tracked.path, &self.config)?;
let metadata = std::fs::metadata(&tracked.path)?;
tracked.plugin = new_plugin;
tracked.modified = metadata.modified()?;
}
Ok(())
}
pub fn get(&self, name: &str) -> Option<&Plugin> {
self.name_index.get(name).map(|&i| &self.plugins[i].plugin)
}
pub fn execute(&self, index: usize, input: &PluginInput) -> Result<PluginOutput> {
let tracked = self
.plugins
.get(index)
.context("plugin index out of bounds")?;
tracked.plugin.execute(input, &self.config)
}
pub fn execute_by_name(&self, name: &str, input: &PluginInput) -> Result<PluginOutput> {
let index = self
.name_index
.get(name)
.with_context(|| format!("plugin '{name}' not found"))?;
self.execute(*index, input)
}
pub fn execute_all(&self, mut input: PluginInput) -> Result<PluginOutput> {
let mut all_errors = Vec::new();
let n_original = input.directives.len();
for tracked in &self.plugins {
let output = tracked.plugin.execute(&input, &self.config)?;
input.directives = materialize_ops(&input.directives, &output);
all_errors.extend(output.errors);
}
let mut ops: Vec<PluginOp> = (0..n_original).map(PluginOp::Delete).collect();
for w in input.directives {
ops.push(PluginOp::Insert(w));
}
Ok(PluginOutput {
ops,
errors: all_errors,
})
}
pub const fn len(&self) -> usize {
self.plugins.len()
}
pub const fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
pub fn plugin_info(&self) -> Vec<(&Path, SystemTime)> {
self.plugins
.iter()
.map(|t| (t.path.as_path(), t.modified))
.collect()
}
}
impl Default for WatchingPluginManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::PluginOptions;
#[test]
fn test_valid_plugin_validation() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let result = validate_plugin_module(&wasm);
assert!(
result.is_ok(),
"valid plugin should pass validation: {:?}",
result.err()
);
}
#[test]
fn test_wasi_import_rejected() {
let wasm = wat::parse_str(
r#"
(module
(import "wasi_snapshot_preview1" "fd_write"
(func $fd_write (param i32 i32 i32 i32) (result i32))
)
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let result = validate_plugin_module(&wasm);
assert!(
result.is_err(),
"module with WASI import should be rejected"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("forbidden import"),
"error should mention forbidden import: {err}"
);
assert!(
err.contains("wasi_snapshot_preview1"),
"error should mention WASI: {err}"
);
}
#[test]
fn test_env_import_rejected() {
let wasm = wat::parse_str(
r#"
(module
(import "env" "some_func" (func $some_func))
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let result = validate_plugin_module(&wasm);
assert!(result.is_err(), "module with env import should be rejected");
}
#[test]
fn test_missing_exports_rejected() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let result = validate_plugin_module(&wasm);
assert!(result.is_err(), "module missing alloc should be rejected");
assert!(result.unwrap_err().to_string().contains("alloc"));
}
#[test]
fn test_runtime_config_defaults() {
let config = RuntimeConfig::default();
assert_eq!(
config.max_memory,
crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY
);
assert_eq!(
config.max_time_secs,
crate::sandbox::DEFAULT_SANDBOX_MAX_TIME_SECS
);
}
#[test]
fn test_missing_memory_rejected() {
let wasm = wat::parse_str(
r#"
(module
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let result = validate_plugin_module(&wasm);
assert!(result.is_err(), "module missing memory should be rejected");
assert!(result.unwrap_err().to_string().contains("memory"));
}
#[test]
fn test_missing_process_rejected() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
)
"#,
)
.expect("valid wat");
let result = validate_plugin_module(&wasm);
assert!(result.is_err(), "module missing process should be rejected");
assert!(result.unwrap_err().to_string().contains("process"));
}
#[test]
fn test_invalid_wasm_rejected() {
let invalid = b"not valid wasm bytes";
let result = validate_plugin_module(invalid);
assert!(result.is_err(), "invalid WASM should be rejected");
}
#[test]
fn test_runtime_config_custom() {
let config = RuntimeConfig {
max_memory: 512 * 1024 * 1024, max_time_secs: 60,
};
assert_eq!(config.max_memory, 512 * 1024 * 1024);
assert_eq!(config.max_time_secs, 60);
}
#[test]
fn test_plugin_manager_new() {
let manager = PluginManager::new();
assert!(manager.is_empty());
assert_eq!(manager.len(), 0);
}
#[test]
fn test_plugin_manager_with_config() {
let config = RuntimeConfig {
max_memory: 128 * 1024 * 1024,
max_time_secs: 10,
};
let manager = PluginManager::with_config(config);
assert!(manager.is_empty());
}
#[test]
fn test_plugin_manager_default() {
let manager = PluginManager::default();
assert!(manager.is_empty());
assert_eq!(manager.len(), 0);
}
#[test]
fn test_watching_plugin_manager_new() {
let manager = WatchingPluginManager::new();
assert!(manager.is_empty());
assert_eq!(manager.len(), 0);
assert!(manager.plugin_info().is_empty());
}
#[test]
fn test_watching_plugin_manager_with_config() {
let config = RuntimeConfig {
max_memory: 64 * 1024 * 1024,
max_time_secs: 5,
};
let manager = WatchingPluginManager::with_config(config);
assert!(manager.is_empty());
}
#[test]
fn test_watching_plugin_manager_default() {
let manager = WatchingPluginManager::default();
assert!(manager.is_empty());
assert_eq!(manager.len(), 0);
}
#[test]
fn test_watching_plugin_manager_get_unknown() {
let manager = WatchingPluginManager::new();
assert!(manager.get("nonexistent").is_none());
}
#[test]
fn test_plugin_manager_execute_out_of_bounds() {
let manager = PluginManager::new();
let input = crate::types::PluginInput {
directives: vec![],
options: crate::types::PluginOptions::default(),
config: None,
};
let result = manager.execute(0, &input);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("out of bounds"));
}
#[test]
fn test_watching_plugin_manager_execute_out_of_bounds() {
let manager = WatchingPluginManager::new();
let input = crate::types::PluginInput {
directives: vec![],
options: crate::types::PluginOptions::default(),
config: None,
};
let result = manager.execute(0, &input);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("out of bounds"));
}
#[test]
fn test_watching_plugin_manager_execute_by_name_unknown() {
let manager = WatchingPluginManager::new();
let input = crate::types::PluginInput {
directives: vec![],
options: crate::types::PluginOptions::default(),
config: None,
};
let result = manager.execute_by_name("unknown", &input);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_plugin_manager_execute_all_empty() {
let manager = PluginManager::new();
let input = crate::types::PluginInput {
directives: vec![],
options: crate::types::PluginOptions::default(),
config: None,
};
let result = manager.execute_all(input);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.ops.is_empty());
assert!(output.errors.is_empty());
}
#[test]
fn test_watching_plugin_manager_execute_all_empty() {
let manager = WatchingPluginManager::new();
let input = crate::types::PluginInput {
directives: vec![],
options: crate::types::PluginOptions::default(),
config: None,
};
let result = manager.execute_all(input);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.ops.is_empty());
assert!(output.errors.is_empty());
}
#[test]
fn test_watching_plugin_manager_check_reload_empty() {
let mut manager = WatchingPluginManager::new();
let result = manager.check_and_reload();
assert!(result.is_ok());
assert!(!result.unwrap()); }
#[test]
fn test_watching_plugin_manager_reload_all_empty() {
let mut manager = WatchingPluginManager::new();
let result = manager.reload_all();
assert!(result.is_ok()); }
#[test]
fn test_plugin_load_bytes() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let config = RuntimeConfig::default();
let result = Plugin::load_bytes("test_plugin", &wasm, &config);
assert!(result.is_ok());
let plugin = result.unwrap();
assert_eq!(plugin.name(), "test_plugin");
}
#[test]
fn test_plugin_manager_load_bytes() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let mut manager = PluginManager::new();
let result = manager.load_bytes("my_plugin", &wasm);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0); assert_eq!(manager.len(), 1);
assert!(!manager.is_empty());
}
#[test]
fn test_plugin_manager_multiple_plugins() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32)
i32.const 0
)
(func (export "process") (param i32 i32) (result i64)
i64.const 0
)
)
"#,
)
.expect("valid wat");
let mut manager = PluginManager::new();
manager.load_bytes("plugin1", &wasm).unwrap();
manager.load_bytes("plugin2", &wasm).unwrap();
manager.load_bytes("plugin3", &wasm).unwrap();
assert_eq!(manager.len(), 3);
}
#[test]
fn test_validate_truncated_wasm() {
let truncated = &[0x00, 0x61, 0x73, 0x6d]; let result = validate_plugin_module(truncated);
assert!(result.is_err());
}
#[test]
fn test_validate_wrong_magic() {
let wrong_magic = &[0xFF, 0xFF, 0xFF, 0xFF];
let result = validate_plugin_module(wrong_magic);
assert!(result.is_err());
}
#[test]
fn test_validate_empty_wasm() {
let empty: &[u8] = &[];
let result = validate_plugin_module(empty);
assert!(result.is_err());
}
#[test]
fn execute_rejects_initial_memory_above_max_memory_cap() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 5000)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "process") (param i32 i32) (result i64) i64.const 0)
)
"#,
)
.expect("WAT parses");
let plugin = Plugin::load_bytes("bigmem", &wasm, &RuntimeConfig::default())
.expect("module loads (declared memory size is checked at instantiate, not compile)");
let tight_config = RuntimeConfig {
max_memory: 64 * 1024 * 1024,
max_time_secs: 30,
};
let input = PluginInput {
directives: vec![],
options: PluginOptions {
operating_currencies: vec![],
title: None,
},
config: None,
};
let err = plugin
.execute(&input, &tight_config)
.expect_err("instantiation should fail when initial memory > cap");
let msg = format!("{err:#}").to_ascii_lowercase();
assert!(
msg.contains("memory") || msg.contains("limit"),
"expected memory-limit error, got: {msg}"
);
}
fn plugin_wat_with_abi(abi_section: &str) -> String {
format!(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "process") (param i32 i32) (result i64) i64.const 0)
{abi_section}
)
"#
)
}
fn abi_test_plugin_input() -> PluginInput {
PluginInput {
directives: vec![],
options: PluginOptions {
operating_currencies: vec![],
title: None,
},
config: None,
}
}
#[test]
fn execute_rejects_plugin_missing_abi_version() {
let wasm = wat::parse_str(plugin_wat_with_abi("")).expect("WAT parses");
let plugin =
Plugin::load_bytes("noabi", &wasm, &RuntimeConfig::default()).expect("module loads");
let err = plugin
.execute(&abi_test_plugin_input(), &RuntimeConfig::default())
.expect_err("execute must reject a plugin with no ABI export");
let msg = format!("{err:#}");
assert!(
msg.contains("__rustledger_abi_version") && msg.contains("missing or invalid"),
"expected a missing-ABI error, got: {msg}"
);
}
#[test]
fn execute_rejects_plugin_with_mismatched_abi_version() {
let wasm = wat::parse_str(plugin_wat_with_abi(
r#"(func (export "__rustledger_abi_version") (result i32) i32.const 999)"#,
))
.expect("WAT parses");
let plugin =
Plugin::load_bytes("badabi", &wasm, &RuntimeConfig::default()).expect("module loads");
let err = plugin
.execute(&abi_test_plugin_input(), &RuntimeConfig::default())
.expect_err("execute must reject an ABI-mismatched plugin");
let msg = format!("{err:#}");
assert!(
msg.contains("ABI version mismatch") && msg.contains("999"),
"expected an ABI-mismatch error naming v999, got: {msg}"
);
}
#[test]
fn execute_surfaces_wrong_signature_on_alloc() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i64) (result i64) i64.const 0)
(func (export "process") (param i32 i32) (result i64) i64.const 0)
;; Correct ABI so the check passes and the alloc
;; signature mismatch is what surfaces (issue #1234).
(func (export "__rustledger_abi_version") (result i32) i32.const 1)
)
"#,
)
.expect("WAT parses");
let plugin = Plugin::load_bytes("bad-alloc-sig", &wasm, &RuntimeConfig::default())
.expect("module loads (validate only checks presence by name)");
let input = PluginInput {
directives: vec![],
options: PluginOptions {
operating_currencies: vec![],
title: None,
},
config: None,
};
let err = plugin
.execute(&input, &RuntimeConfig::default())
.expect_err("wrong-sig alloc should fail execute");
let msg = format!("{err:#}");
assert!(
msg.contains("alloc") && msg.contains("wrong signature"),
"expected `alloc` + `wrong signature` in error, got: {msg}"
);
}
#[test]
fn execute_surfaces_wrong_signature_on_process() {
let wasm = wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "process") (param i32 i32) (result i32) i32.const 0)
;; Correct ABI so the check passes and the process
;; signature mismatch is what surfaces (issue #1234).
(func (export "__rustledger_abi_version") (result i32) i32.const 1)
)
"#,
)
.expect("WAT parses");
let plugin = Plugin::load_bytes("bad-process-sig", &wasm, &RuntimeConfig::default())
.expect("module loads (validate only checks presence by name)");
let input = PluginInput {
directives: vec![],
options: PluginOptions {
operating_currencies: vec![],
title: None,
},
config: None,
};
let err = plugin
.execute(&input, &RuntimeConfig::default())
.expect_err("wrong-sig process should fail execute");
let msg = format!("{err:#}");
assert!(
msg.contains("process") && msg.contains("wrong signature"),
"expected `process` + `wrong signature` in error, got: {msg}"
);
}
fn passthrough_wat() -> &'static str {
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "process") (param i32 i32) (result i64) i64.const 0)
;; ABI handshake (issue #1234): matches the host so execute
;; reaches the process/decode path the fuel tests exercise.
(func (export "__rustledger_abi_version") (result i32) i32.const 1)
)
"#
}
fn empty_input() -> PluginInput {
PluginInput {
directives: vec![],
options: PluginOptions {
operating_currencies: vec![],
title: None,
},
config: None,
}
}
fn assert_not_fuel_trap(err: &anyhow::Error) {
let msg = format!("{err:#}").to_ascii_lowercase();
assert!(
!msg.contains("fuel") && !msg.contains("trap"),
"expected msgpack decode error, got fuel/trap: {msg}"
);
}
#[test]
fn execute_with_zero_max_time_secs_clamps_to_min_fuel() {
let wasm = wat::parse_str(passthrough_wat()).expect("WAT parses");
let plugin =
Plugin::load_bytes("fuel-zero", &wasm, &RuntimeConfig::default()).expect("loads");
let zero_secs = RuntimeConfig {
max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
max_time_secs: 0,
};
let err = plugin
.execute(&empty_input(), &zero_secs)
.expect_err("passthrough WAT decode-fails by design");
assert_not_fuel_trap(&err);
}
#[test]
fn execute_with_max_max_time_secs_saturates_fuel() {
let wasm = wat::parse_str(passthrough_wat()).expect("WAT parses");
let plugin =
Plugin::load_bytes("fuel-max", &wasm, &RuntimeConfig::default()).expect("loads");
let max_secs = RuntimeConfig {
max_memory: crate::sandbox::DEFAULT_SANDBOX_MAX_MEMORY,
max_time_secs: u64::MAX,
};
let err = plugin
.execute(&empty_input(), &max_secs)
.expect_err("passthrough WAT decode-fails by design");
assert_not_fuel_trap(&err);
}
fn valid_plugin_wasm() -> Vec<u8> {
wat::parse_str(
r#"
(module
(memory (export "memory") 1)
(func (export "alloc") (param i32) (result i32) i32.const 0)
(func (export "process") (param i32 i32) (result i64) i64.const 0)
;; A valid plugin advertises the ABI version (issue #1234).
(func (export "__rustledger_abi_version") (result i32) i32.const 1)
)
"#,
)
.expect("valid wat")
}
#[test]
fn register_wasm_dir_loads_valid_skips_broken_and_non_wasm() {
let dir = tempfile::tempdir().expect("tempdir");
let dir_path = dir.path();
std::fs::write(dir_path.join("b_second.wasm"), valid_plugin_wasm()).unwrap();
std::fs::write(dir_path.join("a_first.wasm"), valid_plugin_wasm()).unwrap();
std::fs::write(dir_path.join("broken.wasm"), b"not a wasm module").unwrap();
std::fs::write(dir_path.join("README.md"), "ignore me").unwrap();
std::fs::write(dir_path.join(".gitignore"), "ignore me too").unwrap();
let subdir = dir_path.join("sub");
std::fs::create_dir(&subdir).unwrap();
std::fs::write(subdir.join("recursed.wasm"), valid_plugin_wasm()).unwrap();
let mut manager = PluginManager::new();
let report = manager
.register_wasm_dir(dir_path)
.expect("dir-level read succeeds");
assert_eq!(report.loaded, vec!["a_first", "b_second"]);
assert_eq!(manager.len(), 2);
assert_eq!(report.failures.len(), 1);
assert_eq!(
report.failures[0].0.file_name().and_then(|s| s.to_str()),
Some("broken.wasm"),
);
}
#[test]
fn register_wasm_dir_propagates_dir_level_io_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let nonexistent = tmp.path().join("does-not-exist");
let mut manager = PluginManager::new();
let err = manager
.register_wasm_dir(&nonexistent)
.expect_err("nonexistent dir should error at read_dir, not in failures");
assert!(err.to_string().contains("failed to read plugin dir"));
}
#[test]
fn register_wasm_dir_is_case_insensitive_on_extension() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("upper.WASM"), valid_plugin_wasm()).unwrap();
std::fs::write(dir.path().join("mixed.Wasm"), valid_plugin_wasm()).unwrap();
let mut manager = PluginManager::new();
let report = manager
.register_wasm_dir(dir.path())
.expect("scan succeeds");
assert_eq!(report.loaded.len(), 2, "both case variants should load");
assert!(report.failures.is_empty());
}
}