dynamic_cli/plugin/wasm.rs
1//! WASM plugin loader for `dynamic-cli` (Option C — DD-021)
2//!
3//! Provides [`WasmPlugin`], a [`Plugin`] implementation backed by a sandboxed
4//! WebAssembly module loaded and executed via `wasmtime`. Only available
5//! when the `wasm-plugins` feature is enabled.
6//!
7//! # Why WASM plugins
8//!
9//! Static plugins ([`SystemPlugin`][crate::plugin::SystemPlugin] and Option A
10//! in general) must be compiled into the host binary. WASM plugins trade
11//! that compile-time coupling for a safe, cross-platform sandbox: a `.wasm`
12//! module can be distributed independently of the host application and
13//! loaded at runtime, with no `unsafe` code on the host side.
14//!
15//! # ABI contract — mandatory exports
16//!
17//! Every WASM module loaded as a [`WasmPlugin`] **must** export:
18//!
19//! | Export | Signature | Purpose |
20//! |--------|-----------|---------|
21//! | `memory` | (standard linear memory) | Shared buffer for argument/result transfer |
22//! | `dcli_alloc` | `(size: i32) -> i32` | Host asks the guest to reserve `size` bytes; returns the pointer |
23//! | `dcli_dealloc` | `(ptr: i32, size: i32)` | Host asks the guest to free a buffer it previously allocated |
24//! | *(business function)* | `(ptr: i32, len: i32) -> i32` | Reads serialized args at `ptr`/`len`; returns `0` on success, non-zero on error |
25//!
26//! The business function's exported name is chosen freely by the plugin
27//! author and mapped to an `implementation` name via
28//! [`WasmPlugin::with_function_map`].
29//!
30//! # ABI contract — optional exports
31//!
32//! | Export | Signature | Purpose |
33//! |--------|-----------|---------|
34//! | `dcli_last_error_message` | `() -> (ptr: i32, len: i32)` | Detailed error message when the business function returns non-zero |
35//!
36//! When absent, errors surface with the raw code only
37//! ([`WasmError::guest_error_without_message`]).
38//!
39//! # Serialization
40//!
41//! Handler arguments (`HashMap<String, String>`) are serialized to a byte
42//! buffer before crossing the host/guest boundary. YAML is the default,
43//! consistent with the framework's config-first principle (DD-002); JSON is
44//! available via [`WasmPlugin::with_format`].
45//!
46//! # Known limitation — no `ExecutionContext` access
47//!
48//! WASM handlers do **not** receive the host's [`ExecutionContext`]. Trait
49//! objects cannot cross the WASM FFI boundary, and exposing arbitrary host
50//! state to a sandboxed guest would defeat the purpose of the sandbox. WASM
51//! plugins in this version only exchange serialized arguments and a result
52//! code/message.
53//!
54//! Future work may introduce a restricted set of host functions (e.g.
55//! `host_log`, `host_get_state`) or WASI integration for guests that need
56//! controlled access to host capabilities — see DD-021 for the open
57//! discussion. This version intentionally ships without them.
58//!
59//! Full reference: `WASM_PLUGIN_INTERFACE.md`.
60//!
61//! # Example
62//!
63//! ```no_run
64//! use dynamic_cli::plugin::wasm::{WasmPlugin, WasmSerializationFormat};
65//! use std::path::Path;
66//!
67//! # fn main() -> dynamic_cli::Result<()> {
68//! let plugin = WasmPlugin::load(Path::new("plugins/greet.wasm"))?
69//! .with_function_map("greet_hello", "say_hello")
70//! .with_format(WasmSerializationFormat::Yaml)
71//! .with_metadata("greet", "1.0.0", "Greeting commands");
72//! # Ok(())
73//! # }
74//! ```
75
76use crate::context::ExecutionContext;
77use crate::error::WasmError;
78use crate::executor::CommandHandler;
79use crate::plugin::Plugin;
80use crate::Result;
81use std::collections::HashMap;
82use std::path::Path;
83use wasmtime::{Engine, Instance, Module, Store};
84
85// ============================================================================
86// WasmSerializationFormat
87// ============================================================================
88
89/// Serialization format used to exchange handler arguments across the
90/// host/guest boundary.
91///
92/// YAML is the default, consistent with the framework's config-first
93/// principle (DD-002). Guests that prefer JSON can request it via
94/// [`WasmPlugin::with_format`].
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum WasmSerializationFormat {
97 /// YAML-encoded arguments (default)
98 #[default]
99 Yaml,
100 /// JSON-encoded arguments
101 Json,
102}
103
104impl WasmSerializationFormat {
105 /// Serialize a handler argument map into bytes using this format.
106 fn serialize(self, args: &HashMap<String, String>) -> Result<Vec<u8>> {
107 let bytes = match self {
108 Self::Yaml => serde_yaml::to_string(args)
109 .map_err(|e| WasmError::SerializationFailed(e.to_string()))?
110 .into_bytes(),
111 Self::Json => serde_json::to_vec(args)
112 .map_err(|e| WasmError::SerializationFailed(e.to_string()))?,
113 };
114 Ok(bytes)
115 }
116}
117
118// ============================================================================
119// Mandatory export names
120// ============================================================================
121
122const EXPORT_MEMORY: &str = "memory";
123const EXPORT_ALLOC: &str = "dcli_alloc";
124const EXPORT_DEALLOC: &str = "dcli_dealloc";
125const EXPORT_LAST_ERROR: &str = "dcli_last_error_message";
126
127// ============================================================================
128// WasmPlugin
129// ============================================================================
130
131/// A [`Plugin`] backed by a sandboxed WASM module.
132///
133/// Load a `.wasm` (or `.wat`, useful for testing) module with [`WasmPlugin::load`],
134/// map its business functions to `implementation` names with
135/// [`with_function_map`][Self::with_function_map], then register it via
136/// [`CliBuilder::register_plugin`][crate::CliBuilder::register_plugin] or the
137/// convenience [`CliBuilder::register_wasm_plugin`][crate::CliBuilder::register_wasm_plugin].
138///
139/// See the [module-level documentation](self) for the full ABI contract.
140pub struct WasmPlugin {
141 name: String,
142 version: String,
143 description: String,
144 engine: Engine,
145 module: Module,
146 /// `implementation_name -> wasm_exported_function_name`
147 function_map: HashMap<String, String>,
148 format: WasmSerializationFormat,
149}
150
151impl WasmPlugin {
152 /// Load a WASM module from disk and validate its mandatory exports.
153 ///
154 /// Accepts both binary `.wasm` and text `.wat` modules (the latter is
155 /// primarily useful for tests and minimal fixtures).
156 ///
157 /// # Errors
158 ///
159 /// Returns [`WasmError::LoadFailed`] if the file cannot be read or fails
160 /// to compile/validate. Returns [`WasmError::FunctionNotFound`] if
161 /// `memory`, `dcli_alloc`, or `dcli_dealloc` is missing — these three
162 /// are mandatory regardless of which business functions are mapped
163 /// later.
164 ///
165 /// # Example
166 ///
167 /// ```no_run
168 /// use dynamic_cli::plugin::wasm::WasmPlugin;
169 /// use std::path::Path;
170 ///
171 /// # fn main() -> dynamic_cli::Result<()> {
172 /// let plugin = WasmPlugin::load(Path::new("plugins/greet.wasm"))?;
173 /// # Ok(())
174 /// # }
175 /// ```
176 pub fn load(path: &Path) -> Result<Self> {
177 let bytes = std::fs::read(path).map_err(|e| WasmError::LoadFailed {
178 path: path.to_path_buf(),
179 source: anyhow::Error::new(e),
180 suggestion: Some("Verify the path is correct and the file is readable.".to_string()),
181 })?;
182
183 Self::from_bytes(&bytes, path)
184 }
185
186 /// Load a WASM module from raw bytes (binary `.wasm` or text `.wat`).
187 ///
188 /// Used internally by [`load`][Self::load]. Exposed for tests and for
189 /// embedding scenarios where the module bytes are not stored on disk
190 /// (e.g. fetched over the network).
191 ///
192 /// Validates the same mandatory exports as [`load`][Self::load].
193 pub fn from_bytes(bytes: &[u8], origin: &Path) -> Result<Self> {
194 let engine = Engine::default();
195
196 let module = Module::new(&engine, bytes).map_err(|e| WasmError::LoadFailed {
197 path: origin.to_path_buf(),
198 source: e.into(),
199 suggestion: Some("Verify the file is a valid WASM binary or WAT module.".to_string()),
200 })?;
201
202 let module_label = origin.display().to_string();
203 Self::validate_mandatory_exports(&module, &module_label)?;
204
205 let default_name = origin
206 .file_stem()
207 .and_then(|s| s.to_str())
208 .unwrap_or("wasm-plugin")
209 .to_string();
210
211 Ok(Self {
212 name: default_name,
213 version: "0.0.0".to_string(),
214 description: "WASM plugin (no metadata provided)".to_string(),
215 engine,
216 module,
217 function_map: HashMap::new(),
218 format: WasmSerializationFormat::default(),
219 })
220 }
221
222 /// Verify that `memory`, `dcli_alloc`, and `dcli_dealloc` are exported.
223 ///
224 /// These three exports are mandatory for every WASM plugin regardless
225 /// of which business functions are mapped — without them the host has
226 /// no safe way to exchange data with the guest or to free guest memory
227 /// after a call.
228 fn validate_mandatory_exports(module: &Module, module_label: &str) -> Result<()> {
229 let exported_names: Vec<&str> = module.exports().map(|e| e.name()).collect();
230
231 for required in [EXPORT_MEMORY, EXPORT_ALLOC, EXPORT_DEALLOC] {
232 if !exported_names.contains(&required) {
233 return Err(WasmError::missing_mandatory_export(required, module_label).into());
234 }
235 }
236 Ok(())
237 }
238
239 /// Map an `implementation` name to a WASM-exported business function.
240 ///
241 /// The exported function name is chosen freely by the plugin author —
242 /// `dynamic-cli` imposes no naming convention beyond the four reserved
243 /// names (`memory`, `dcli_alloc`, `dcli_dealloc`, `dcli_last_error_message`).
244 ///
245 /// Existence of `wasm_fn_name` in the module is verified at handler
246 /// construction time, in [`Plugin::handlers`].
247 ///
248 /// # Example
249 ///
250 /// ```no_run
251 /// use dynamic_cli::plugin::wasm::WasmPlugin;
252 /// use std::path::Path;
253 ///
254 /// # fn main() -> dynamic_cli::Result<()> {
255 /// let plugin = WasmPlugin::load(Path::new("plugins/greet.wasm"))?
256 /// .with_function_map("greet_hello", "say_hello");
257 /// # Ok(())
258 /// # }
259 /// ```
260 pub fn with_function_map(mut self, impl_name: &str, wasm_fn_name: &str) -> Self {
261 self.function_map
262 .insert(impl_name.to_string(), wasm_fn_name.to_string());
263 self
264 }
265
266 /// Set the serialization format used for argument exchange.
267 ///
268 /// Defaults to [`WasmSerializationFormat::Yaml`].
269 pub fn with_format(mut self, format: WasmSerializationFormat) -> Self {
270 self.format = format;
271 self
272 }
273
274 /// Set plugin metadata (name, version, description).
275 ///
276 /// When not called, defaults are derived from the module's file name
277 /// (`name`), `"0.0.0"` (`version`), and a generic description.
278 pub fn with_metadata(mut self, name: &str, version: &str, description: &str) -> Self {
279 self.name = name.to_string();
280 self.version = version.to_string();
281 self.description = description.to_string();
282 self
283 }
284}
285
286impl Plugin for WasmPlugin {
287 fn name(&self) -> &str {
288 &self.name
289 }
290
291 fn version(&self) -> &str {
292 &self.version
293 }
294
295 fn description(&self) -> &str {
296 &self.description
297 }
298
299 fn handlers(&self) -> Vec<(String, Box<dyn CommandHandler>)> {
300 self.function_map
301 .iter()
302 .map(|(impl_name, wasm_fn_name)| {
303 let handler: Box<dyn CommandHandler> = Box::new(WasmHandler {
304 engine: self.engine.clone(),
305 module: self.module.clone(),
306 module_label: self.name.clone(),
307 wasm_fn_name: wasm_fn_name.clone(),
308 format: self.format,
309 });
310 (impl_name.clone(), handler)
311 })
312 .collect()
313 }
314}
315
316// ============================================================================
317// WasmHandler (private)
318// ============================================================================
319
320/// [`CommandHandler`] that invokes a single exported WASM business function.
321///
322/// One instance is created per mapped `implementation` name in
323/// [`WasmPlugin::handlers`]. Each call to [`execute`][CommandHandler::execute]
324/// creates a fresh `Store` and `Instance` — WASM instantiation is cheap and
325/// this keeps each invocation isolated, with no state leaking between calls.
326struct WasmHandler {
327 engine: Engine,
328 module: Module,
329 /// Plugin name, used only to identify the module in error messages
330 module_label: String,
331 wasm_fn_name: String,
332 format: WasmSerializationFormat,
333}
334
335impl WasmHandler {
336 /// Run the full call sequence: alloc → write → call → dealloc → result.
337 ///
338 /// `dcli_dealloc` is invoked on every exit path — including when the
339 /// business function itself returns a non-zero error code — so the
340 /// guest never accumulates unfreed buffers across repeated calls.
341 fn call_guest(&self, args: &HashMap<String, String>) -> Result<()> {
342 let mut store = Store::new(&self.engine, ());
343 let instance =
344 Instance::new(&mut store, &self.module, &[]).map_err(|e| WasmError::LoadFailed {
345 path: std::path::PathBuf::from(&self.module_label),
346 source: e.into(),
347 suggestion: Some("Failed to instantiate the WASM module.".to_string()),
348 })?;
349
350 let memory = instance
351 .get_memory(&mut store, EXPORT_MEMORY)
352 .ok_or_else(|| {
353 WasmError::missing_mandatory_export(EXPORT_MEMORY, &self.module_label)
354 })?;
355
356 let alloc = instance
357 .get_typed_func::<i32, i32>(&mut store, EXPORT_ALLOC)
358 .map_err(|_| WasmError::missing_mandatory_export(EXPORT_ALLOC, &self.module_label))?;
359
360 let dealloc = instance
361 .get_typed_func::<(i32, i32), ()>(&mut store, EXPORT_DEALLOC)
362 .map_err(|_| WasmError::missing_mandatory_export(EXPORT_DEALLOC, &self.module_label))?;
363
364 let business_fn = instance
365 .get_typed_func::<(i32, i32), i32>(&mut store, self.wasm_fn_name.as_str())
366 .map_err(|_| WasmError::FunctionNotFound {
367 function: self.wasm_fn_name.clone(),
368 module: self.module_label.clone(),
369 suggestion: Some(format!(
370 "Export `fn {}(ptr: i32, len: i32) -> i32` from the WASM module, \
371 or check the name passed to `with_function_map`.",
372 self.wasm_fn_name
373 )),
374 })?;
375
376 // Serialize arguments and request guest-owned buffer space.
377 let payload = self.format.serialize(args)?;
378 let len = payload.len() as i32;
379
380 let ptr = alloc
381 .call(&mut store, len)
382 .map_err(|e| WasmError::MemoryAccessFailed {
383 reason: e.to_string(),
384 })?;
385
386 memory
387 .write(&mut store, ptr as usize, &payload)
388 .map_err(|e| WasmError::MemoryAccessFailed {
389 reason: e.to_string(),
390 })?;
391
392 // Invoke the business function. Regardless of outcome, the guest
393 // buffer is freed before this function returns (see below).
394 let call_result = business_fn.call(&mut store, (ptr, len));
395
396 // Always free the buffer we allocated, on every exit path.
397 let dealloc_result = dealloc.call(&mut store, (ptr, len));
398
399 let code = call_result.map_err(|e| WasmError::MemoryAccessFailed {
400 reason: format!("business function trapped: {e}"),
401 })?;
402
403 // A failure to deallocate is logged via the returned error only if
404 // the business call itself succeeded — otherwise the guest error
405 // takes priority as the more actionable failure.
406 if code == 0 {
407 dealloc_result.map_err(|e| WasmError::MemoryAccessFailed {
408 reason: format!("dcli_dealloc failed: {e}"),
409 })?;
410 return Ok(());
411 }
412
413 // Non-zero return code: attempt to retrieve a detailed message via
414 // the optional `dcli_last_error_message` export.
415 //
416 // dealloc_result is intentionally not surfaced here even if it
417 // failed: the guest's own error code is the more actionable signal
418 // for the caller, and a secondary dealloc failure on an already
419 // failing call would only obscure it.
420 let message = self.read_last_error_message(&mut store, &instance);
421 Err(WasmError::GuestError { code, message }.into())
422 }
423
424 /// Best-effort retrieval of a detailed error message from the guest.
425 ///
426 /// Returns `None` when the module does not export
427 /// `dcli_last_error_message`, or when reading the message fails for any
428 /// reason. A missing or unreadable message degrades to the raw error
429 /// code — it never escalates into a separate error of its own.
430 fn read_last_error_message(
431 &self,
432 store: &mut Store<()>,
433 instance: &Instance,
434 ) -> Option<String> {
435 let last_error_fn = instance
436 .get_typed_func::<(), (i32, i32)>(&mut *store, EXPORT_LAST_ERROR)
437 .ok()?;
438 let (ptr, len) = last_error_fn.call(&mut *store, ()).ok()?;
439 if len <= 0 {
440 return None;
441 }
442 let memory = instance.get_memory(&mut *store, EXPORT_MEMORY)?;
443 let mut buf = vec![0u8; len as usize];
444 memory.read(&mut *store, ptr as usize, &mut buf).ok()?;
445 String::from_utf8(buf).ok()
446 }
447}
448
449impl CommandHandler for WasmHandler {
450 fn execute(
451 &self,
452 _ctx: &mut dyn ExecutionContext,
453 args: &HashMap<String, String>,
454 ) -> Result<()> {
455 // ExecutionContext is intentionally not forwarded to the guest —
456 // see the module-level "Known limitation" section.
457 self.call_guest(args)
458 }
459}
460
461// ============================================================================
462// Tests
463// ============================================================================
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use std::any::Any;
469 use std::path::PathBuf;
470
471 #[derive(Default)]
472 struct TestContext;
473
474 impl ExecutionContext for TestContext {
475 fn as_any(&self) -> &dyn Any {
476 self
477 }
478 fn as_any_mut(&mut self) -> &mut dyn Any {
479 self
480 }
481 }
482
483 /// A minimal valid module: memory + dcli_alloc + dcli_dealloc + a
484 /// business function `ok_handler` that always returns 0 (success).
485 ///
486 /// Allocation/deallocation are no-ops here (a fixed bump pointer at
487 /// offset 1024) — sufficient for ABI-contract tests, not a realistic
488 /// allocator.
489 const WAT_MINIMAL_OK: &str = r#"
490 (module
491 (memory (export "memory") 1)
492 (func (export "dcli_alloc") (param i32) (result i32)
493 i32.const 1024)
494 (func (export "dcli_dealloc") (param i32 i32))
495 (func (export "ok_handler") (param i32 i32) (result i32)
496 i32.const 0)
497 )
498 "#;
499
500 /// Same as above, but the business function always returns error code 1,
501 /// with no `dcli_last_error_message` export.
502 const WAT_MINIMAL_ERROR: &str = r#"
503 (module
504 (memory (export "memory") 1)
505 (func (export "dcli_alloc") (param i32) (result i32)
506 i32.const 1024)
507 (func (export "dcli_dealloc") (param i32 i32))
508 (func (export "err_handler") (param i32 i32) (result i32)
509 i32.const 1)
510 )
511 "#;
512
513 /// Same as the error module, but also exports `dcli_last_error_message`
514 /// pointing at a fixed "boom" string baked into a data segment.
515 const WAT_WITH_ERROR_MESSAGE: &str = r#"
516 (module
517 (memory (export "memory") 1)
518 (data (i32.const 2048) "boom")
519 (func (export "dcli_alloc") (param i32) (result i32)
520 i32.const 1024)
521 (func (export "dcli_dealloc") (param i32 i32))
522 (func (export "err_handler") (param i32 i32) (result i32)
523 i32.const 1)
524 (func (export "dcli_last_error_message") (result i32 i32)
525 i32.const 2048
526 i32.const 4)
527 )
528 "#;
529
530 /// Missing `dcli_dealloc` entirely.
531 const WAT_MISSING_DEALLOC: &str = r#"
532 (module
533 (memory (export "memory") 1)
534 (func (export "dcli_alloc") (param i32) (result i32)
535 i32.const 1024)
536 (func (export "ok_handler") (param i32 i32) (result i32)
537 i32.const 0)
538 )
539 "#;
540
541 /// Missing `memory` entirely.
542 const WAT_MISSING_MEMORY: &str = r#"
543 (module
544 (func (export "dcli_alloc") (param i32) (result i32)
545 i32.const 1024)
546 (func (export "dcli_dealloc") (param i32 i32))
547 (func (export "ok_handler") (param i32 i32) (result i32)
548 i32.const 0)
549 )
550 "#;
551
552 fn load_wat(wat: &str) -> Result<WasmPlugin> {
553 WasmPlugin::from_bytes(wat.as_bytes(), &PathBuf::from("test.wat"))
554 }
555
556 // -------------------------------------------------------------------------
557 // Loading & mandatory export validation
558 // -------------------------------------------------------------------------
559
560 #[test]
561 fn test_load_minimal_valid_module_succeeds() {
562 let plugin = load_wat(WAT_MINIMAL_OK);
563 assert!(plugin.is_ok());
564 }
565
566 #[test]
567 fn test_load_missing_dealloc_fails() {
568 let result = load_wat(WAT_MISSING_DEALLOC);
569 assert!(result.is_err());
570 }
571
572 #[test]
573 fn test_load_missing_memory_fails() {
574 let result = load_wat(WAT_MISSING_MEMORY);
575 assert!(result.is_err());
576 }
577
578 #[test]
579 fn test_load_invalid_bytes_fails() {
580 let result = WasmPlugin::from_bytes(b"not a wasm module", &PathBuf::from("bad.wasm"));
581 assert!(result.is_err());
582 }
583
584 #[test]
585 fn test_default_metadata_derived_from_filename() {
586 let plugin = WasmPlugin::from_bytes(
587 WAT_MINIMAL_OK.as_bytes(),
588 &PathBuf::from("plugins/greet.wat"),
589 )
590 .unwrap();
591 assert_eq!(plugin.name(), "greet");
592 }
593
594 // -------------------------------------------------------------------------
595 // Metadata builder methods
596 // -------------------------------------------------------------------------
597
598 #[test]
599 fn test_with_metadata_overrides_defaults() {
600 let plugin =
601 load_wat(WAT_MINIMAL_OK)
602 .unwrap()
603 .with_metadata("custom", "2.5.0", "A custom plugin");
604 assert_eq!(plugin.name(), "custom");
605 assert_eq!(plugin.version(), "2.5.0");
606 assert_eq!(plugin.description(), "A custom plugin");
607 }
608
609 #[test]
610 fn test_with_format_defaults_to_yaml() {
611 let plugin = load_wat(WAT_MINIMAL_OK).unwrap();
612 assert_eq!(plugin.format, WasmSerializationFormat::Yaml);
613 }
614
615 #[test]
616 fn test_with_format_can_be_set_to_json() {
617 let plugin = load_wat(WAT_MINIMAL_OK)
618 .unwrap()
619 .with_format(WasmSerializationFormat::Json);
620 assert_eq!(plugin.format, WasmSerializationFormat::Json);
621 }
622
623 // -------------------------------------------------------------------------
624 // Plugin::handlers()
625 // -------------------------------------------------------------------------
626
627 #[test]
628 fn test_handlers_reflects_function_map() {
629 let plugin = load_wat(WAT_MINIMAL_OK)
630 .unwrap()
631 .with_function_map("my_command", "ok_handler");
632 let handlers = plugin.handlers();
633 assert_eq!(handlers.len(), 1);
634 assert_eq!(handlers[0].0, "my_command");
635 }
636
637 #[test]
638 fn test_handlers_empty_without_function_map() {
639 let plugin = load_wat(WAT_MINIMAL_OK).unwrap();
640 assert_eq!(plugin.handlers().len(), 0);
641 }
642
643 // -------------------------------------------------------------------------
644 // Execution — success path
645 // -------------------------------------------------------------------------
646
647 #[test]
648 fn test_execute_success_returns_ok() {
649 let plugin = load_wat(WAT_MINIMAL_OK)
650 .unwrap()
651 .with_function_map("cmd", "ok_handler");
652 let handlers = plugin.handlers();
653 let (_, handler) = &handlers[0];
654
655 let mut ctx = TestContext;
656 let args = HashMap::new();
657 assert!(handler.execute(&mut ctx, &args).is_ok());
658 }
659
660 #[test]
661 fn test_execute_with_args_serializes_without_error() {
662 let plugin = load_wat(WAT_MINIMAL_OK)
663 .unwrap()
664 .with_function_map("cmd", "ok_handler");
665 let handlers = plugin.handlers();
666 let (_, handler) = &handlers[0];
667
668 let mut ctx = TestContext;
669 let mut args = HashMap::new();
670 args.insert("name".to_string(), "World".to_string());
671 args.insert("count".to_string(), "3".to_string());
672 assert!(handler.execute(&mut ctx, &args).is_ok());
673 }
674
675 #[test]
676 fn test_execute_repeated_calls_do_not_exhaust_guest() {
677 // Verifies that dcli_dealloc being called on every exit path allows
678 // the same handler to be invoked many times without issue — a
679 // regression test for the "always deallocate" requirement.
680 let plugin = load_wat(WAT_MINIMAL_OK)
681 .unwrap()
682 .with_function_map("cmd", "ok_handler");
683 let handlers = plugin.handlers();
684 let (_, handler) = &handlers[0];
685
686 let mut ctx = TestContext;
687 for _ in 0..50 {
688 let args = HashMap::new();
689 assert!(handler.execute(&mut ctx, &args).is_ok());
690 }
691 }
692
693 // -------------------------------------------------------------------------
694 // Execution — error path
695 // -------------------------------------------------------------------------
696
697 #[test]
698 fn test_execute_guest_error_without_message() {
699 let plugin = load_wat(WAT_MINIMAL_ERROR)
700 .unwrap()
701 .with_function_map("cmd", "err_handler");
702 let handlers = plugin.handlers();
703 let (_, handler) = &handlers[0];
704
705 let mut ctx = TestContext;
706 let args = HashMap::new();
707 let result = handler.execute(&mut ctx, &args);
708 assert!(result.is_err());
709
710 match result.unwrap_err() {
711 crate::error::DynamicCliError::Wasm(WasmError::GuestError { code, message }) => {
712 assert_eq!(code, 1);
713 assert!(message.is_none());
714 }
715 other => panic!("unexpected error variant: {other:?}"),
716 }
717 }
718
719 #[test]
720 fn test_execute_guest_error_with_message() {
721 let plugin = load_wat(WAT_WITH_ERROR_MESSAGE)
722 .unwrap()
723 .with_function_map("cmd", "err_handler");
724 let handlers = plugin.handlers();
725 let (_, handler) = &handlers[0];
726
727 let mut ctx = TestContext;
728 let args = HashMap::new();
729 let result = handler.execute(&mut ctx, &args);
730 assert!(result.is_err());
731
732 match result.unwrap_err() {
733 crate::error::DynamicCliError::Wasm(WasmError::GuestError { code, message }) => {
734 assert_eq!(code, 1);
735 assert_eq!(message, Some("boom".to_string()));
736 }
737 other => panic!("unexpected error variant: {other:?}"),
738 }
739 }
740
741 #[test]
742 fn test_execute_unmapped_function_name_fails() {
743 let plugin = load_wat(WAT_MINIMAL_OK)
744 .unwrap()
745 .with_function_map("cmd", "does_not_exist");
746 let handlers = plugin.handlers();
747 let (_, handler) = &handlers[0];
748
749 let mut ctx = TestContext;
750 let args = HashMap::new();
751 assert!(handler.execute(&mut ctx, &args).is_err());
752 }
753
754 // -------------------------------------------------------------------------
755 // Thread safety
756 // -------------------------------------------------------------------------
757
758 #[test]
759 fn test_wasm_plugin_is_send_sync() {
760 fn assert_send_sync<T: Send + Sync>() {}
761 assert_send_sync::<WasmPlugin>();
762 }
763}