Skip to main content

heliosdb_proxy/plugins/
runtime.rs

1//! WASM Plugin Runtime
2//!
3//! Real wasmtime-backed plugin executor.
4//!
5//! ## ABI
6//!
7//! Plugins export, at minimum:
8//!
9//! - `memory` (default linear memory).
10//! - `alloc(size: i32) -> i32` — host calls this to obtain a slot
11//!   in plugin memory it can write input bytes into.
12//! - `dealloc(ptr: i32, size: i32)` — host calls this to free either
13//!   the input slot (after the call) or the output slot (after the
14//!   host has read the result).
15//! - One function per declared hook, with one of two signatures:
16//!   - **Result-returning hooks** (`pre_query`, `route`,
17//!     `authenticate`, `rewrite`): `(ptr: i32, len: i32) -> i64`
18//!     where the i64 is `(result_ptr << 32) | result_len`.
19//!     `result_ptr == 0 && result_len == 0` is a valid "no result"
20//!     reply (host treats it as the default per-hook outcome).
21//!   - **Observer hooks** (`post_query`, `metrics`, `on_connect`,
22//!     `on_disconnect`): `(ptr: i32, len: i32)` with no return —
23//!     the host ignores any output the plugin may have written.
24//!
25//! The runtime tries the result-returning signature first; if the
26//! exported function has the no-return shape it falls back.
27
28use std::collections::HashMap;
29use std::path::PathBuf;
30use std::sync::atomic::AtomicBool;
31use std::sync::{Arc, OnceLock};
32use std::time::{Duration, Instant};
33
34use parking_lot::RwLock;
35use wasmtime::{Engine, Instance, InstancePre, Linker, Memory, Module, Store, TypedFunc};
36
37use super::config::PluginRuntimeConfig;
38use super::host_functions::HostFunctionRegistry;
39use super::host_imports::{register_crypto_imports, register_kv_imports, KvBackend, StoreCtx};
40use super::sandbox::{PluginSandbox, ResourceLimits, SecurityPolicy};
41use super::{
42    AuthRequest, AuthResult, HookType, PluginMetadata, PreQueryResult, QueryContext, RouteResult,
43};
44
45/// Error types for plugin operations
46#[derive(Debug, Clone)]
47pub enum PluginError {
48    /// Failed to load plugin
49    LoadError(String),
50
51    /// Failed to instantiate plugin
52    InstantiationError(String),
53
54    /// Plugin execution failed
55    ExecutionError(String),
56
57    /// Plugin timed out
58    Timeout(String),
59
60    /// Memory limit exceeded
61    MemoryExceeded(String),
62
63    /// Security policy violation
64    SecurityViolation(String),
65
66    /// Invalid plugin manifest
67    InvalidManifest(String),
68
69    /// Hook not found
70    HookNotFound(String),
71
72    /// Internal runtime error
73    RuntimeError(String),
74}
75
76impl std::fmt::Display for PluginError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            PluginError::LoadError(msg) => write!(f, "Load error: {}", msg),
80            PluginError::InstantiationError(msg) => write!(f, "Instantiation error: {}", msg),
81            PluginError::ExecutionError(msg) => write!(f, "Execution error: {}", msg),
82            PluginError::Timeout(msg) => write!(f, "Timeout: {}", msg),
83            PluginError::MemoryExceeded(msg) => write!(f, "Memory exceeded: {}", msg),
84            PluginError::SecurityViolation(msg) => write!(f, "Security violation: {}", msg),
85            PluginError::InvalidManifest(msg) => write!(f, "Invalid manifest: {}", msg),
86            PluginError::HookNotFound(msg) => write!(f, "Hook not found: {}", msg),
87            PluginError::RuntimeError(msg) => write!(f, "Runtime error: {}", msg),
88        }
89    }
90}
91
92impl std::error::Error for PluginError {}
93
94/// Plugin state
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum PluginState {
97    /// Plugin is loading
98    Loading,
99
100    /// Plugin is ready
101    Running,
102
103    /// Plugin is paused
104    Paused,
105
106    /// Plugin has errored
107    Error(String),
108
109    /// Plugin is unloading
110    Unloading,
111}
112
113/// A loaded and instantiated plugin
114pub struct LoadedPlugin {
115    /// Plugin metadata
116    pub metadata: PluginMetadata,
117
118    /// Current state
119    pub state: PluginState,
120
121    /// File path
122    pub path: PathBuf,
123
124    /// Compiled wasmtime module — cheap to clone (internally Arc'd)
125    /// and shared across invocations. Replaces the prior Vec<u8> stub.
126    module: Module,
127
128    /// Pre-resolved instantiation plan (module imports linked against the
129    /// runtime's shared `Linker`). Computed once on the first hook call and
130    /// reused for every subsequent call, so per-dispatch cost drops to
131    /// `Store::new` + `InstancePre::instantiate` — no per-call `Linker`
132    /// allocation, host-import re-registration, or import-name resolution.
133    instance_pre: OnceLock<InstancePre<StoreCtx>>,
134
135    /// Security sandbox
136    #[allow(dead_code)]
137    sandbox: PluginSandbox,
138
139    /// Instance data (mock for non-wasmtime builds)
140    instance_data: RwLock<PluginInstanceData>,
141
142    /// Creation timestamp
143    loaded_at: Instant,
144
145    /// Last invocation timestamp
146    last_invoked: RwLock<Option<Instant>>,
147
148    /// Invocation count
149    invocation_count: std::sync::atomic::AtomicU64,
150}
151
152/// Plugin instance data
153struct PluginInstanceData {
154    /// Plugin memory usage
155    memory_used: usize,
156
157    /// Fuel consumed (if metering enabled)
158    fuel_consumed: u64,
159
160    /// Custom state from plugin
161    #[allow(dead_code)]
162    state: HashMap<String, Vec<u8>>,
163}
164
165impl LoadedPlugin {
166    /// Create a new loaded plugin
167    pub fn new(
168        metadata: PluginMetadata,
169        path: PathBuf,
170        module: Module,
171        sandbox: PluginSandbox,
172    ) -> Self {
173        Self {
174            metadata,
175            state: PluginState::Running,
176            path,
177            module,
178            instance_pre: OnceLock::new(),
179            sandbox,
180            instance_data: RwLock::new(PluginInstanceData {
181                memory_used: 0,
182                fuel_consumed: 0,
183                state: HashMap::new(),
184            }),
185            loaded_at: Instant::now(),
186            last_invoked: RwLock::new(None),
187            invocation_count: std::sync::atomic::AtomicU64::new(0),
188        }
189    }
190
191    /// Borrow the compiled module (Arc-cheap clone available via
192    /// `plugin.module.clone()` if the caller needs to outlive the
193    /// borrow).
194    #[allow(dead_code)]
195    pub(crate) fn module(&self) -> &Module {
196        &self.module
197    }
198
199    /// Get memory usage
200    pub fn memory_used(&self) -> usize {
201        self.instance_data.read().memory_used
202    }
203
204    /// Get invocation count
205    pub fn invocation_count(&self) -> u64 {
206        self.invocation_count
207            .load(std::sync::atomic::Ordering::Relaxed)
208    }
209
210    /// Get uptime
211    pub fn uptime(&self) -> Duration {
212        self.loaded_at.elapsed()
213    }
214
215    /// Get last invoked time
216    pub fn last_invoked(&self) -> Option<Instant> {
217        *self.last_invoked.read()
218    }
219
220    /// Record an invocation
221    pub fn record_invocation(&self) {
222        self.invocation_count
223            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
224        *self.last_invoked.write() = Some(Instant::now());
225    }
226}
227
228/// WASM plugin runtime
229pub struct WasmPluginRuntime {
230    /// Runtime configuration
231    config: PluginRuntimeConfig,
232
233    /// wasmtime engine — shared across all plugins. Compiles modules
234    /// once; modules cheaply share a reference to it.
235    engine: Engine,
236
237    /// Shared host-import linker, built once with the `env::*` KV and
238    /// crypto imports. Reused to pre-resolve every plugin's instantiation
239    /// plan instead of allocating a fresh `Linker` and re-registering host
240    /// functions on every hook call.
241    linker: Linker<StoreCtx>,
242
243    /// Stop flag for the background epoch ticker thread (see `new`).
244    epoch_stop: Arc<AtomicBool>,
245
246    /// Host function registry
247    #[allow(dead_code)]
248    host_functions: Arc<HostFunctionRegistry>,
249
250    /// Per-plugin KV backend bridged into wasmtime imports. Survives
251    /// across calls so plugins can persist state between hooks.
252    kv: KvBackend,
253
254    /// Module cache (path -> compiled module). Avoids re-compiling
255    /// the same `.wasm` on every load.
256    module_cache: RwLock<HashMap<PathBuf, Module>>,
257
258    /// Default security policy
259    default_policy: SecurityPolicy,
260
261    /// Creation timestamp
262    created_at: Instant,
263}
264
265impl WasmPluginRuntime {
266    /// Create a new WASM runtime
267    pub fn new(config: &PluginRuntimeConfig) -> Result<Self, PluginError> {
268        let host_functions = Arc::new(HostFunctionRegistry::new());
269
270        let mut engine_config = wasmtime::Config::new();
271        if config.fuel_metering {
272            engine_config.consume_fuel(true);
273        }
274        // Epoch-based interrupts let us bound execution time without
275        // polling fuel from inside the call.
276        engine_config.epoch_interruption(true);
277
278        let engine = Engine::new(&engine_config)
279            .map_err(|e| PluginError::RuntimeError(format!("wasmtime engine init: {}", e)))?;
280
281        // Build the host-import linker once. The KV/crypto imports read
282        // their state from each call's `Store` data (Caller<StoreCtx>), so
283        // a single shared linker is correct across all plugins and calls.
284        let mut linker: Linker<StoreCtx> = Linker::new(&engine);
285        register_kv_imports(&mut linker)?;
286        register_crypto_imports(&mut linker)?;
287
288        // Background epoch ticker: bumps the engine epoch every ~1ms so
289        // that per-call epoch deadlines actually enforce a wall-clock
290        // timeout on plugin execution (previously the deadline was set to
291        // u64::MAX, so the configured timeout was never enforced). A
292        // std::thread is used so enforcement works with or without a tokio
293        // runtime; it exits within ~1ms of the runtime being dropped.
294        let epoch_stop = Arc::new(AtomicBool::new(false));
295        {
296            let engine = engine.clone();
297            let stop = epoch_stop.clone();
298            std::thread::Builder::new()
299                .name("wasm-epoch-ticker".into())
300                .spawn(move || {
301                    while !stop.load(std::sync::atomic::Ordering::Relaxed) {
302                        std::thread::sleep(Duration::from_millis(1));
303                        engine.increment_epoch();
304                    }
305                })
306                .ok();
307        }
308
309        let default_policy = SecurityPolicy {
310            allowed_hosts: vec!["localhost".to_string()],
311            allowed_paths: vec![config.plugin_dir.clone()],
312            max_memory: config.memory_limit,
313            max_execution_time: config.timeout,
314            allow_network: false,
315            allow_filesystem: false,
316        };
317
318        Ok(Self {
319            config: config.clone(),
320            engine,
321            linker,
322            epoch_stop,
323            host_functions,
324            kv: KvBackend::with_limits(
325                config.kv_max_value_bytes,
326                config.kv_max_keys_per_plugin,
327                config.kv_max_plugins,
328                config.kv_max_total_bytes,
329            ),
330            module_cache: RwLock::new(HashMap::new()),
331            default_policy,
332            created_at: Instant::now(),
333        })
334    }
335
336    /// Expose the per-plugin KV backend so admin/test code can seed
337    /// or inspect a plugin's state without going through WASM.
338    pub fn kv(&self) -> &KvBackend {
339        &self.kv
340    }
341
342    /// Borrow the shared host-import linker (used by the manager/tests to
343    /// pre-resolve modules against the same import set).
344    #[allow(dead_code)]
345    pub(crate) fn linker(&self) -> &Linker<StoreCtx> {
346        &self.linker
347    }
348
349    /// Expose the engine so tests + the plugin manager can build new
350    /// `Store`s against it.
351    #[allow(dead_code)]
352    pub(crate) fn engine(&self) -> &Engine {
353        &self.engine
354    }
355
356    /// Expose the runtime config so the plugin manager can consult
357    /// fields it owns (e.g. `trust_root`) without holding a separate
358    /// copy.
359    pub fn config(&self) -> &PluginRuntimeConfig {
360        &self.config
361    }
362
363    /// Instantiate a plugin from manifest and WASM bytes
364    pub fn instantiate(
365        &self,
366        manifest: &super::loader::PluginManifest,
367        wasm_bytes: &[u8],
368    ) -> Result<LoadedPlugin, PluginError> {
369        // Validate WASM module (basic magic number check)
370        if wasm_bytes.len() < 8 {
371            return Err(PluginError::LoadError("WASM module too small".to_string()));
372        }
373
374        // WASM magic number: 0x00 0x61 0x73 0x6d (\\0asm)
375        if &wasm_bytes[0..4] != b"\x00asm" {
376            return Err(PluginError::LoadError(
377                "Invalid WASM magic number".to_string(),
378            ));
379        }
380
381        // Build metadata from manifest
382        let metadata = PluginMetadata {
383            name: manifest.name.clone(),
384            version: manifest.version.clone(),
385            description: manifest.description.clone(),
386            author: manifest.author.clone(),
387            hooks: manifest.hooks.clone(),
388            permissions: manifest.permissions.clone(),
389            min_memory: manifest.min_memory,
390            max_memory: manifest.max_memory.min(self.config.memory_limit),
391        };
392
393        // Build sandbox with merged policy
394        let resource_limits = ResourceLimits {
395            max_memory: metadata.max_memory,
396            max_execution_time: self.config.timeout,
397            max_fuel: if self.config.fuel_metering {
398                Some(self.config.fuel_limit)
399            } else {
400                None
401            },
402            max_table_elements: 10000,
403            max_instances: 1,
404        };
405
406        let sandbox = PluginSandbox::new(
407            self.default_policy.clone(),
408            resource_limits,
409            manifest.permissions.clone(),
410        );
411
412        // Compile via wasmtime — this validates the module and produces
413        // an Arc-wrapped Module ready for repeated instantiation.
414        let module = Module::from_binary(&self.engine, wasm_bytes)
415            .map_err(|e| PluginError::InstantiationError(format!("wasmtime compile: {}", e)))?;
416
417        // Cache the compiled Module (cheap clone on hit).
418        {
419            let mut cache = self.module_cache.write();
420            cache.insert(manifest.path.clone(), module.clone());
421        }
422
423        Ok(LoadedPlugin::new(
424            metadata,
425            manifest.path.clone(),
426            module,
427            sandbox,
428        ))
429    }
430
431    /// Call a hook on a plugin via wasmtime.
432    ///
433    /// 1. Build a fresh `Store` (wasmtime stores are not Sync, so each
434    ///    invocation is isolated).
435    /// 2. Apply fuel metering (per-call fuel cap) when configured.
436    /// 3. Instantiate the module against an empty Linker — host
437    ///    functions are TODO; plugins that import them will fail at
438    ///    instantiation with a clear error message.
439    /// 4. Look up `memory`, `alloc`, `dealloc`, and the named hook
440    ///    function exports.
441    /// 5. Allocate a slot in plugin memory, write `args`, call the
442    ///    hook, decode `(result_ptr, result_len)`, copy the result
443    ///    bytes out.
444    /// 6. Free both input and output slots via `dealloc`.
445    /// 7. Drop the store; the plugin's per-call state is gone.
446    pub fn call_hook(
447        &self,
448        plugin: &LoadedPlugin,
449        hook: HookType,
450        args: &[u8],
451    ) -> Result<Vec<u8>, PluginError> {
452        // Check if plugin supports this hook
453        if !plugin.metadata.hooks.contains(&hook) {
454            return Err(PluginError::HookNotFound(format!(
455                "Plugin {} does not support hook {:?}",
456                plugin.metadata.name, hook
457            )));
458        }
459
460        // Check state
461        if plugin.state != PluginState::Running {
462            return Err(PluginError::ExecutionError(format!(
463                "Plugin {} is not running (state: {:?})",
464                plugin.metadata.name, plugin.state
465            )));
466        }
467
468        // Record invocation
469        plugin.record_invocation();
470
471        // Fresh per-call store; not Sync, so we never share across calls.
472        // The data carries the plugin's identity + a clone of the shared
473        // KV backend so host imports can route to the right namespace.
474        let store_ctx = StoreCtx {
475            plugin_name: plugin.metadata.name.clone(),
476            kv: self.kv.clone(),
477        };
478        let mut store: Store<StoreCtx> = Store::new(&self.engine, store_ctx);
479        if self.config.fuel_metering {
480            // wasmtime's set_fuel returns Result; cap is per-call.
481            store
482                .set_fuel(self.config.fuel_limit)
483                .map_err(|e| PluginError::RuntimeError(format!("set_fuel: {}", e)))?;
484        }
485        // Epoch interruption was enabled at engine init and a background
486        // ticker bumps the engine epoch every ~1ms. Set the deadline to
487        // `timeout` worth of ticks so a runaway plugin traps at its
488        // configured wall-clock timeout instead of blocking the caller
489        // indefinitely. (Set after Store::new, before the call.)
490        let deadline_ticks = self.config.timeout.as_millis().max(1).min(u64::MAX as u128) as u64;
491        store.set_epoch_deadline(deadline_ticks);
492
493        // Instantiate from the plugin's pre-resolved plan, computed once
494        // against the runtime's shared host-import linker and cached on the
495        // plugin. Per call this is just a linear-memory/table init — no
496        // Linker allocation, no host-import re-registration, no import-name
497        // resolution.
498        let instance_pre = match plugin.instance_pre.get() {
499            Some(ip) => ip,
500            None => {
501                let ip = self.linker.instantiate_pre(&plugin.module).map_err(|e| {
502                    PluginError::InstantiationError(format!(
503                        "pre-instantiate {}: {}",
504                        plugin.metadata.name, e
505                    ))
506                })?;
507                // Race-tolerant: if another thread set it first, keep theirs.
508                let _ = plugin.instance_pre.set(ip);
509                plugin.instance_pre.get().expect("just set")
510            }
511        };
512        let instance = instance_pre.instantiate(&mut store).map_err(|e| {
513            PluginError::InstantiationError(format!("instantiate {}: {}", plugin.metadata.name, e))
514        })?;
515
516        let memory = instance.get_memory(&mut store, "memory").ok_or_else(|| {
517            PluginError::ExecutionError(format!(
518                "plugin {} does not export `memory`",
519                plugin.metadata.name
520            ))
521        })?;
522
523        let alloc = get_typed::<_, i32, i32>(&instance, &mut store, "alloc")?;
524        let dealloc = get_typed::<_, (i32, i32), ()>(&instance, &mut store, "dealloc")?;
525
526        // Allocate input slot inside the plugin's address space and
527        // copy `args` in.
528        let in_len = args.len() as i32;
529        let in_ptr = alloc
530            .call(&mut store, in_len)
531            .map_err(|e| PluginError::ExecutionError(format!("alloc({}): {}", in_len, e)))?;
532        if in_len > 0 {
533            write_memory(&memory, &mut store, in_ptr, args)?;
534        }
535
536        // Try the result-returning ABI first; if the export has the
537        // observer ABI (no return), fall back to that.
538        let export_name = hook.export_name();
539        let result_bytes = match get_typed::<_, (i32, i32), i64>(&instance, &mut store, export_name)
540        {
541            Ok(hook_fn) => {
542                let packed = hook_fn.call(&mut store, (in_ptr, in_len)).map_err(|e| {
543                    PluginError::ExecutionError(format!("hook {} call: {}", export_name, e))
544                })?;
545                let out_ptr = (packed >> 32) as i32;
546                let out_len = (packed & 0xFFFF_FFFF) as i32;
547                if out_len > 0 {
548                    let bytes = read_memory(&memory, &store, out_ptr, out_len)?;
549                    // Free the plugin-allocated output slot.
550                    let _ = dealloc.call(&mut store, (out_ptr, out_len));
551                    bytes
552                } else {
553                    Vec::new()
554                }
555            }
556            Err(_) => {
557                // Observer ABI: (i32, i32) → ()
558                let observer = get_typed::<_, (i32, i32), ()>(&instance, &mut store, export_name)?;
559                observer.call(&mut store, (in_ptr, in_len)).map_err(|e| {
560                    PluginError::ExecutionError(format!(
561                        "observer hook {} call: {}",
562                        export_name, e
563                    ))
564                })?;
565                Vec::new()
566            }
567        };
568
569        // Free the input slot. Best-effort; failure here doesn't
570        // abort the call (the store is about to be dropped anyway).
571        let _ = dealloc.call(&mut store, (in_ptr, in_len));
572
573        // Update per-plugin instance accounting.
574        if self.config.fuel_metering {
575            if let Ok(remaining) = store.get_fuel() {
576                let consumed = self.config.fuel_limit.saturating_sub(remaining);
577                plugin.instance_data.write().fuel_consumed = consumed;
578            }
579        }
580        plugin.instance_data.write().memory_used = memory.data_size(&store);
581
582        Ok(result_bytes)
583    }
584
585    /// Call pre-query hook
586    pub fn call_pre_query(
587        &self,
588        plugin: &LoadedPlugin,
589        ctx: &QueryContext,
590    ) -> Result<PreQueryResult, PluginError> {
591        // Serialize context
592        let args = serde_json::to_vec(ctx).map_err(|e| {
593            PluginError::ExecutionError(format!("Failed to serialize context: {}", e))
594        })?;
595
596        // Call the hook
597        let result = self.call_hook(plugin, HookType::PreQuery, &args)?;
598
599        // Deserialize result (or return default)
600        if result.is_empty() {
601            return Ok(PreQueryResult::Continue);
602        }
603
604        serde_json::from_slice(&result).map_err(|e| {
605            PluginError::ExecutionError(format!("Failed to deserialize result: {}", e))
606        })
607    }
608
609    /// Call authenticate hook
610    pub fn call_authenticate(
611        &self,
612        plugin: &LoadedPlugin,
613        request: &AuthRequest,
614    ) -> Result<AuthResult, PluginError> {
615        // Serialize request
616        let args = serde_json::to_vec(request).map_err(|e| {
617            PluginError::ExecutionError(format!("Failed to serialize request: {}", e))
618        })?;
619
620        // Call the hook
621        let result = self.call_hook(plugin, HookType::Authenticate, &args)?;
622
623        // Deserialize result (or return default)
624        if result.is_empty() {
625            return Ok(AuthResult::Defer);
626        }
627
628        serde_json::from_slice(&result).map_err(|e| {
629            PluginError::ExecutionError(format!("Failed to deserialize result: {}", e))
630        })
631    }
632
633    /// Call route hook
634    pub fn call_route(
635        &self,
636        plugin: &LoadedPlugin,
637        ctx: &QueryContext,
638    ) -> Result<RouteResult, PluginError> {
639        // Serialize context
640        let args = serde_json::to_vec(ctx).map_err(|e| {
641            PluginError::ExecutionError(format!("Failed to serialize context: {}", e))
642        })?;
643
644        // Call the hook
645        let result = self.call_hook(plugin, HookType::Route, &args)?;
646
647        // Deserialize result (or return default)
648        if result.is_empty() {
649            return Ok(RouteResult::Default);
650        }
651
652        serde_json::from_slice(&result).map_err(|e| {
653            PluginError::ExecutionError(format!("Failed to deserialize result: {}", e))
654        })
655    }
656
657    /// Get runtime statistics
658    pub fn stats(&self) -> RuntimeStats {
659        RuntimeStats {
660            uptime: self.created_at.elapsed(),
661            cached_modules: self.module_cache.read().len(),
662            fuel_metering_enabled: self.config.fuel_metering,
663            memory_limit: self.config.memory_limit,
664            timeout: self.config.timeout,
665        }
666    }
667}
668
669impl Drop for WasmPluginRuntime {
670    fn drop(&mut self) {
671        // Signal the epoch ticker thread to exit; it polls the flag every
672        // ~1ms and then releases its Engine clone.
673        self.epoch_stop
674            .store(true, std::sync::atomic::Ordering::Relaxed);
675    }
676}
677
678/// Runtime statistics
679#[derive(Debug, Clone)]
680pub struct RuntimeStats {
681    /// Uptime
682    pub uptime: Duration,
683
684    /// Number of cached modules
685    pub cached_modules: usize,
686
687    /// Whether fuel metering is enabled
688    pub fuel_metering_enabled: bool,
689
690    /// Memory limit per plugin
691    pub memory_limit: usize,
692
693    /// Execution timeout
694    pub timeout: Duration,
695}
696
697// Serialization support for hook types — written by hand because the
698// types live in `super` and we can't derive without touching every
699// field's type chain. Includes hook_context so plugins can read
700// per-request attributes (tenant_id, agent_id, ai_traffic, etc).
701impl serde::Serialize for QueryContext {
702    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
703    where
704        S: serde::Serializer,
705    {
706        use serde::ser::SerializeStruct;
707        let mut state = serializer.serialize_struct("QueryContext", 5)?;
708        state.serialize_field("query", &self.query)?;
709        state.serialize_field("normalized", &self.normalized)?;
710        state.serialize_field("tables", &self.tables)?;
711        state.serialize_field("is_read_only", &self.is_read_only)?;
712        state.serialize_field("hook_context", &self.hook_context)?;
713        state.end()
714    }
715}
716
717/// Look up a typed exported function on an instance, with a uniform
718/// "missing/wrong-shape" error message.
719fn get_typed<T, P, R>(
720    instance: &Instance,
721    store: &mut Store<T>,
722    name: &str,
723) -> Result<TypedFunc<P, R>, PluginError>
724where
725    P: wasmtime::WasmParams,
726    R: wasmtime::WasmResults,
727{
728    instance
729        .get_typed_func::<P, R>(store, name)
730        .map_err(|e| PluginError::ExecutionError(format!("export `{}`: {}", name, e)))
731}
732
733/// Copy `bytes` into the plugin's linear memory at `ptr`. Bounds-
734/// checked via wasmtime's safe `Memory::write`.
735fn write_memory<T>(
736    memory: &Memory,
737    store: &mut Store<T>,
738    ptr: i32,
739    bytes: &[u8],
740) -> Result<(), PluginError> {
741    memory
742        .write(store, ptr as usize, bytes)
743        .map_err(|e| PluginError::ExecutionError(format!("memory.write @ {}: {}", ptr, e)))
744}
745
746/// Copy `len` bytes out of plugin memory starting at `ptr`.
747fn read_memory<T>(
748    memory: &Memory,
749    store: &Store<T>,
750    ptr: i32,
751    len: i32,
752) -> Result<Vec<u8>, PluginError> {
753    if len <= 0 {
754        return Ok(Vec::new());
755    }
756    let mut out = vec![0u8; len as usize];
757    memory.read(store, ptr as usize, &mut out).map_err(|e| {
758        PluginError::ExecutionError(format!("memory.read @ {}+{}: {}", ptr, len, e))
759    })?;
760    Ok(out)
761}
762
763impl serde::Serialize for AuthRequest {
764    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
765    where
766        S: serde::Serializer,
767    {
768        use serde::ser::SerializeStruct;
769        let mut state = serializer.serialize_struct("AuthRequest", 5)?;
770        state.serialize_field("headers", &self.headers)?;
771        state.serialize_field("username", &self.username)?;
772        state.serialize_field("password", &self.password)?;
773        state.serialize_field("client_ip", &self.client_ip)?;
774        state.serialize_field("database", &self.database)?;
775        state.end()
776    }
777}
778
779impl<'de> serde::Deserialize<'de> for PreQueryResult {
780    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
781    where
782        D: serde::Deserializer<'de>,
783    {
784        #[derive(serde::Deserialize)]
785        struct Helper {
786            action: String,
787            #[serde(default)]
788            value: Option<String>,
789            #[serde(default)]
790            data: Option<Vec<u8>>,
791        }
792
793        let helper = Helper::deserialize(deserializer)?;
794        match helper.action.as_str() {
795            "continue" => Ok(PreQueryResult::Continue),
796            "rewrite" => Ok(PreQueryResult::Rewrite(helper.value.unwrap_or_default())),
797            "block" => Ok(PreQueryResult::Block(helper.value.unwrap_or_default())),
798            "cached" => Ok(PreQueryResult::Cached(helper.data.unwrap_or_default())),
799            _ => Ok(PreQueryResult::Continue),
800        }
801    }
802}
803
804impl<'de> serde::Deserialize<'de> for AuthResult {
805    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
806    where
807        D: serde::Deserializer<'de>,
808    {
809        #[derive(serde::Deserialize)]
810        struct Helper {
811            action: String,
812            #[serde(default)]
813            identity: Option<IdentityHelper>,
814            #[serde(default)]
815            message: Option<String>,
816        }
817
818        #[derive(serde::Deserialize)]
819        struct IdentityHelper {
820            user_id: String,
821            username: String,
822            #[serde(default)]
823            roles: Vec<String>,
824            #[serde(default)]
825            tenant_id: Option<String>,
826        }
827
828        let helper = Helper::deserialize(deserializer)?;
829        match helper.action.as_str() {
830            "success" => {
831                let id = helper.identity.unwrap_or(IdentityHelper {
832                    user_id: String::new(),
833                    username: String::new(),
834                    roles: Vec::new(),
835                    tenant_id: None,
836                });
837                Ok(AuthResult::Success(super::Identity {
838                    user_id: id.user_id,
839                    username: id.username,
840                    roles: id.roles,
841                    tenant_id: id.tenant_id,
842                    claims: std::collections::HashMap::new(),
843                }))
844            }
845            "denied" => Ok(AuthResult::Denied(helper.message.unwrap_or_default())),
846            "defer" => Ok(AuthResult::Defer),
847            _ => Ok(AuthResult::Defer),
848        }
849    }
850}
851
852impl<'de> serde::Deserialize<'de> for RouteResult {
853    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
854    where
855        D: serde::Deserializer<'de>,
856    {
857        #[derive(serde::Deserialize)]
858        struct Helper {
859            action: String,
860            #[serde(default)]
861            target: Option<String>,
862            #[serde(default)]
863            reason: Option<String>,
864        }
865
866        let helper = Helper::deserialize(deserializer)?;
867        match helper.action.as_str() {
868            "default" => Ok(RouteResult::Default),
869            "node" => Ok(RouteResult::Node(helper.target.unwrap_or_default())),
870            "primary" => Ok(RouteResult::Primary),
871            "standby" => Ok(RouteResult::Standby),
872            "branch" => Ok(RouteResult::Branch(helper.target.unwrap_or_default())),
873            // Block carries a human-readable reason in its own field so
874            // it doesn't overload `target` (which is a node identifier
875            // for the other variants).
876            "block" => Ok(RouteResult::Block(
877                helper
878                    .reason
879                    .unwrap_or_else(|| "blocked by plugin".to_string()),
880            )),
881            _ => Ok(RouteResult::Default),
882        }
883    }
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    /// Build a tiny WAT module for runtime tests against a specific
891    /// engine. wasmtime requires Module and instantiating Engine to
892    /// match — so this takes the runtime's engine rather than
893    /// constructing one locally.
894    ///
895    /// Exports `memory`, `alloc`, `dealloc`, a `pre_query` hook that
896    /// ignores its input and returns a fixed payload at offset 1024,
897    /// and a `post_query` observer hook.
898    fn build_test_module(engine: &Engine) -> Module {
899        const PAYLOAD: &[u8] = b"hello-from-wasm";
900        let payload_hex: String = PAYLOAD.iter().map(|b| format!("\\{:02x}", b)).collect();
901        let wat = format!(
902            r#"
903            (module
904              (memory (export "memory") 1)
905
906              ;; Trivial alloc: always returns offset 4096 (test inputs
907              ;; are tiny so non-overlapping reuse is fine here). Real
908              ;; plugins ship a real allocator; the runtime only cares
909              ;; that `alloc` returns a writable address.
910              (func (export "alloc") (param $size i32) (result i32)
911                (i32.const 4096))
912
913              (func (export "dealloc") (param $ptr i32) (param $size i32)
914                (drop (local.get $ptr))
915                (drop (local.get $size)))
916
917              ;; Result-returning hook: writes PAYLOAD at offset 1024 and
918              ;; returns (1024 << 32) | PAYLOAD.len.
919              (func (export "pre_query")
920                (param $in_ptr i32) (param $in_len i32) (result i64)
921                (i64.or
922                  (i64.shl (i64.const 1024) (i64.const 32))
923                  (i64.const {payload_len})))
924
925              ;; Observer hook: takes args, returns nothing.
926              (func (export "post_query")
927                (param $in_ptr i32) (param $in_len i32)
928                (drop (local.get $in_ptr)))
929
930              (data (i32.const 1024) "{payload}")
931            )
932            "#,
933            payload = payload_hex,
934            payload_len = PAYLOAD.len(),
935        );
936        let bytes = wat::parse_str(&wat).expect("wat parses");
937        Module::from_binary(engine, &bytes).expect("module compiles")
938    }
939
940    /// A module whose `pre_query` hook spins forever — used to prove the
941    /// epoch-deadline wall-clock timeout actually interrupts a runaway
942    /// plugin instead of blocking the caller indefinitely.
943    fn build_spin_module(engine: &Engine) -> Module {
944        let wat = r#"
945            (module
946              (memory (export "memory") 1)
947              (func (export "alloc") (param i32) (result i32) (i32.const 4096))
948              (func (export "dealloc") (param i32) (param i32))
949              (func (export "pre_query") (param i32) (param i32) (result i64)
950                (loop $l (br $l))
951                (i64.const 0)))
952        "#;
953        let bytes = wat::parse_str(wat).expect("wat parses");
954        Module::from_binary(engine, &bytes).expect("module compiles")
955    }
956
957    /// A runaway plugin must trap at its configured timeout (enforced by
958    /// the background epoch ticker), not hang the caller. Guarded by a 5s
959    /// join so a regression fails fast instead of hanging the test binary.
960    #[test]
961    fn test_call_hook_enforces_timeout() {
962        let config = PluginRuntimeConfig {
963            fuel_metering: false, // isolate epoch enforcement from fuel
964            timeout: Duration::from_millis(100),
965            ..Default::default()
966        };
967        let runtime = Arc::new(WasmPluginRuntime::new(&config).unwrap());
968
969        let module = build_spin_module(runtime.engine());
970        let metadata = PluginMetadata {
971            name: "spin".to_string(),
972            hooks: vec![HookType::PreQuery],
973            ..Default::default()
974        };
975        let plugin = Arc::new(LoadedPlugin::new(
976            metadata,
977            PathBuf::from("/test/spin.wasm"),
978            module,
979            PluginSandbox::default(),
980        ));
981
982        let (tx, rx) = std::sync::mpsc::channel();
983        {
984            let r = runtime.clone();
985            let p = plugin.clone();
986            std::thread::spawn(move || {
987                let res = r.call_hook(&p, HookType::PreQuery, b"{}");
988                let _ = tx.send(res.is_err());
989            });
990        }
991        match rx.recv_timeout(Duration::from_secs(5)) {
992            Ok(is_err) => assert!(is_err, "runaway plugin should trap with an error"),
993            Err(_) => panic!("call_hook did not return within 5s — epoch timeout not enforced"),
994        }
995    }
996
997    #[test]
998    fn test_plugin_error_display() {
999        let err = PluginError::LoadError("test".to_string());
1000        assert!(err.to_string().contains("Load error"));
1001
1002        let err = PluginError::Timeout("plugin-a".to_string());
1003        assert!(err.to_string().contains("Timeout"));
1004    }
1005
1006    #[test]
1007    fn test_plugin_state() {
1008        assert_eq!(PluginState::Running, PluginState::Running);
1009        assert_ne!(PluginState::Running, PluginState::Paused);
1010    }
1011
1012    #[test]
1013    fn test_runtime_creation() {
1014        let config = PluginRuntimeConfig::default();
1015        let runtime = WasmPluginRuntime::new(&config);
1016        assert!(runtime.is_ok());
1017    }
1018
1019    #[test]
1020    fn test_runtime_stats() {
1021        let config = PluginRuntimeConfig::default();
1022        let runtime = WasmPluginRuntime::new(&config).unwrap();
1023        let stats = runtime.stats();
1024
1025        assert_eq!(stats.cached_modules, 0);
1026        assert!(stats.fuel_metering_enabled);
1027    }
1028
1029    #[test]
1030    fn test_loaded_plugin_invocation_count() {
1031        // Re-use the test module — its compiled `Module` is what
1032        // the LoadedPlugin needs now that the field is wasmtime-typed.
1033        let engine = Engine::default();
1034        let module = build_test_module(&engine);
1035        let metadata = PluginMetadata::default();
1036        let sandbox = PluginSandbox::default();
1037        let plugin = LoadedPlugin::new(
1038            metadata,
1039            PathBuf::from("/test/plugin.wasm"),
1040            module,
1041            sandbox,
1042        );
1043
1044        assert_eq!(plugin.invocation_count(), 0);
1045        plugin.record_invocation();
1046        assert_eq!(plugin.invocation_count(), 1);
1047        plugin.record_invocation();
1048        assert_eq!(plugin.invocation_count(), 2);
1049    }
1050
1051    /// End-to-end: load a WAT-built module, call `pre_query`, observe
1052    /// the plugin's payload comes back through the (ptr, len) ABI.
1053    /// This is the killer test that proves the wasmtime path is
1054    /// real, not a stub.
1055    #[test]
1056    fn test_call_hook_roundtrips_real_wasm() {
1057        let config = PluginRuntimeConfig {
1058            // Disable fuel metering — the test module is trivial and we
1059            // don't want to debug fuel exhaustion in unit tests.
1060            fuel_metering: false,
1061            ..Default::default()
1062        };
1063        let runtime = WasmPluginRuntime::new(&config).unwrap();
1064
1065        let module = build_test_module(runtime.engine());
1066        let metadata = PluginMetadata {
1067            name: "test-roundtrip".to_string(),
1068            hooks: vec![HookType::PreQuery, HookType::PostQuery],
1069            ..Default::default()
1070        };
1071
1072        let plugin = LoadedPlugin::new(
1073            metadata,
1074            PathBuf::from("/test/roundtrip.wasm"),
1075            module,
1076            PluginSandbox::default(),
1077        );
1078        // Force into Running state — Loading would block.
1079        // (LoadedPlugin::new already sets Running by default.)
1080
1081        let bytes = runtime
1082            .call_hook(&plugin, HookType::PreQuery, b"ignored input")
1083            .expect("pre_query call");
1084        assert_eq!(bytes, b"hello-from-wasm");
1085        assert_eq!(plugin.invocation_count(), 1);
1086
1087        // Observer ABI: post_query has no return; should yield empty.
1088        let out = runtime
1089            .call_hook(&plugin, HookType::PostQuery, b"some bytes")
1090            .expect("post_query call");
1091        assert!(out.is_empty());
1092        assert_eq!(plugin.invocation_count(), 2);
1093    }
1094
1095    /// A plugin that doesn't declare a hook in its metadata cannot
1096    /// invoke that hook even if the WASM exports the function.
1097    #[test]
1098    fn test_call_hook_rejects_undeclared_hook() {
1099        let runtime = WasmPluginRuntime::new(&PluginRuntimeConfig::default()).unwrap();
1100        let module = build_test_module(runtime.engine());
1101        let metadata = PluginMetadata {
1102            hooks: vec![], // declares nothing
1103            ..Default::default()
1104        };
1105        let plugin = LoadedPlugin::new(
1106            metadata,
1107            PathBuf::from("/test/empty.wasm"),
1108            module,
1109            PluginSandbox::default(),
1110        );
1111        let err = runtime
1112            .call_hook(&plugin, HookType::PreQuery, &[])
1113            .unwrap_err();
1114        assert!(matches!(err, PluginError::HookNotFound(_)));
1115    }
1116
1117    /// Calling a hook whose export name is missing surfaces as
1118    /// `ExecutionError`, not a panic.
1119    #[test]
1120    fn test_call_hook_missing_export_returns_error() {
1121        let runtime = WasmPluginRuntime::new(&PluginRuntimeConfig::default()).unwrap();
1122        let module = build_test_module(runtime.engine());
1123        let metadata = PluginMetadata {
1124            // Declare a hook the test module doesn't export.
1125            hooks: vec![HookType::Authenticate],
1126            ..Default::default()
1127        };
1128        let plugin = LoadedPlugin::new(
1129            metadata,
1130            PathBuf::from("/test/missing.wasm"),
1131            module,
1132            PluginSandbox::default(),
1133        );
1134        let err = runtime
1135            .call_hook(&plugin, HookType::Authenticate, &[])
1136            .unwrap_err();
1137        assert!(matches!(err, PluginError::ExecutionError(_)));
1138    }
1139
1140    /// Build a WAT module that imports kv_set + kv_get from `env` and
1141    /// calls kv_set on `pre_query`. Used to validate the host-import
1142    /// bridge end-to-end through wasmtime.
1143    fn build_kv_test_module(engine: &Engine) -> Module {
1144        // Layout:
1145        //   offset 100: 3 bytes "key"
1146        //   offset 200: 5 bytes "value"
1147        let wat = r#"
1148            (module
1149              (import "env" "kv_set"
1150                (func $kv_set (param i32 i32 i32 i32) (result i32)))
1151              (memory (export "memory") 1)
1152
1153              (data (i32.const 100) "key")
1154              (data (i32.const 200) "value")
1155
1156              (func (export "alloc") (param i32) (result i32) (i32.const 4096))
1157              (func (export "dealloc") (param i32 i32))
1158
1159              ;; pre_query: kv_set("key", "value"); return 0 (no payload).
1160              (func (export "pre_query")
1161                (param $in_ptr i32) (param $in_len i32) (result i64)
1162                (drop (call $kv_set
1163                  (i32.const 100) (i32.const 3)
1164                  (i32.const 200) (i32.const 5)))
1165                (i64.const 0))
1166            )
1167        "#;
1168        let bytes = wat::parse_str(wat).expect("kv-wat parses");
1169        Module::from_binary(engine, &bytes).expect("kv module compiles")
1170    }
1171
1172    /// Calls a WASM `pre_query` hook that invokes the host's kv_set
1173    /// import. Verifies the value lands in the runtime's KvBackend
1174    /// under the plugin's namespace and is readable from Rust.
1175    #[test]
1176    fn test_host_kv_import_persists_value() {
1177        let config = PluginRuntimeConfig {
1178            fuel_metering: false,
1179            ..Default::default()
1180        };
1181        let runtime = WasmPluginRuntime::new(&config).unwrap();
1182
1183        let module = build_kv_test_module(runtime.engine());
1184        let metadata = PluginMetadata {
1185            name: "kv-test-plugin".to_string(),
1186            hooks: vec![HookType::PreQuery],
1187            ..Default::default()
1188        };
1189
1190        let plugin = LoadedPlugin::new(
1191            metadata,
1192            PathBuf::from("/test/kv.wasm"),
1193            module,
1194            PluginSandbox::default(),
1195        );
1196
1197        // Sanity: namespace empty before the call.
1198        assert_eq!(runtime.kv().get("kv-test-plugin", b"key"), None);
1199
1200        let _ = runtime
1201            .call_hook(&plugin, HookType::PreQuery, &[])
1202            .expect("pre_query call");
1203
1204        // The plugin called kv_set("key", "value") inside WASM; the
1205        // host should have stored it under this plugin's namespace.
1206        assert_eq!(
1207            runtime.kv().get("kv-test-plugin", b"key"),
1208            Some(b"value".to_vec())
1209        );
1210        // And nowhere else.
1211        assert_eq!(runtime.kv().get("other-plugin", b"key"), None);
1212    }
1213
1214    /// Build a WAT module that imports kv_get from `env` and, on
1215    /// `pre_query`, reads the key "seed" into offset 300 and returns
1216    /// the packed `(300 << 32) | bytes_written` so the host can read
1217    /// the value back out of plugin memory.
1218    fn build_kv_read_test_module(engine: &Engine) -> Module {
1219        let wat = r#"
1220            (module
1221              (import "env" "kv_get"
1222                (func $kv_get (param i32 i32 i32 i32) (result i32)))
1223              (memory (export "memory") 1)
1224
1225              (data (i32.const 100) "seed")
1226
1227              (func (export "alloc") (param i32) (result i32) (i32.const 4096))
1228              (func (export "dealloc") (param i32 i32))
1229
1230              ;; pre_query: kv_get("seed") -> offset 300; return (300<<32)|n.
1231              (func (export "pre_query")
1232                (param $in_ptr i32) (param $in_len i32) (result i64)
1233                (local $n i32)
1234                (local.set $n (call $kv_get
1235                  (i32.const 100) (i32.const 4)
1236                  (i32.const 300) (i32.const 64)))
1237                (i64.or
1238                  (i64.shl (i64.const 300) (i64.const 32))
1239                  (i64.extend_i32_u (local.get $n))))
1240            )
1241        "#;
1242        let bytes = wat::parse_str(wat).expect("kv-read-wat parses");
1243        Module::from_binary(engine, &bytes).expect("kv-read module compiles")
1244    }
1245
1246    /// A value written straight into the `KvBackend` — exactly what the
1247    /// `PUT /admin/kv/<plugin>/<key>` admin path does — must be visible
1248    /// to the plugin through its `kv_get` host import.
1249    #[test]
1250    fn test_kv_backend_set_visible_to_plugin_kv_get() {
1251        let config = PluginRuntimeConfig {
1252            fuel_metering: false,
1253            ..PluginRuntimeConfig::default()
1254        };
1255        let runtime = WasmPluginRuntime::new(&config).unwrap();
1256
1257        // Seed as the admin PUT path does: into the shared backend
1258        // under the plugin's namespace.
1259        assert!(runtime
1260            .kv()
1261            .set("kv-read-plugin", b"seed".to_vec(), b"live-value".to_vec()));
1262
1263        let module = build_kv_read_test_module(runtime.engine());
1264        let metadata = PluginMetadata {
1265            name: "kv-read-plugin".to_string(),
1266            hooks: vec![HookType::PreQuery],
1267            ..Default::default()
1268        };
1269
1270        let plugin = LoadedPlugin::new(
1271            metadata,
1272            PathBuf::from("/test/kv-read.wasm"),
1273            module,
1274            PluginSandbox::default(),
1275        );
1276
1277        // The plugin reads "seed" via kv_get and hands the bytes back;
1278        // they must equal the host-written value.
1279        let out = runtime
1280            .call_hook(&plugin, HookType::PreQuery, &[])
1281            .expect("pre_query call");
1282        assert_eq!(&out[..], b"live-value");
1283    }
1284
1285    /// Build a WAT module that imports `env.sha256_hex` and exposes a
1286    /// `pre_query` hook which:
1287    ///   1. computes sha256_hex over an embedded "abc" payload at
1288    ///      offset 100 (3 bytes)
1289    ///   2. writes the 64-byte hex digest at offset 200
1290    ///   3. returns the packed (200 << 32) | 64
1291    ///      so the host can read the digest out of plugin memory.
1292    fn build_sha256_test_module(engine: &Engine) -> Module {
1293        let wat = r#"
1294            (module
1295              (import "env" "sha256_hex"
1296                (func $sha256_hex (param i32 i32 i32) (result i32)))
1297              (memory (export "memory") 1)
1298
1299              (data (i32.const 100) "abc")
1300
1301              (func (export "alloc") (param i32) (result i32) (i32.const 4096))
1302              (func (export "dealloc") (param i32 i32))
1303
1304              (func (export "pre_query")
1305                (param $in_ptr i32) (param $in_len i32) (result i64)
1306                (drop (call $sha256_hex
1307                  (i32.const 100) (i32.const 3)
1308                  (i32.const 200)))
1309                (i64.or
1310                  (i64.shl (i64.const 200) (i64.const 32))
1311                  (i64.const 64)))
1312            )
1313        "#;
1314        let bytes = wat::parse_str(wat).expect("sha256-wat parses");
1315        Module::from_binary(engine, &bytes).expect("sha256 module compiles")
1316    }
1317
1318    /// RouteResult deserialiser handles the new Block variant via a
1319    /// `reason` field separate from `target` (which the other variants
1320    /// use as a node identifier).
1321    #[test]
1322    fn test_route_result_deserialises_block_with_reason() {
1323        let json = r#"{"action":"block","reason":"cross-region read forbidden"}"#;
1324        let r: RouteResult = serde_json::from_str(json).expect("block deserialises");
1325        match r {
1326            RouteResult::Block(reason) => {
1327                assert_eq!(reason, "cross-region read forbidden");
1328            }
1329            other => panic!("expected Block, got {:?}", other),
1330        }
1331    }
1332
1333    /// Block without a reason field falls back to a generic message —
1334    /// keeps the deserialiser permissive when plugins forget the field.
1335    #[test]
1336    fn test_route_result_block_defaults_reason_when_missing() {
1337        let json = r#"{"action":"block"}"#;
1338        let r: RouteResult = serde_json::from_str(json).expect("block deserialises");
1339        match r {
1340            RouteResult::Block(reason) => {
1341                assert!(!reason.is_empty(), "default reason should not be empty");
1342            }
1343            other => panic!("expected Block, got {:?}", other),
1344        }
1345    }
1346
1347    /// SHA-256 of "abc" is the canonical RFC 6234 test vector. Verifies
1348    /// the host-import bridge produces real cryptographic output, not
1349    /// the FNV-flavoured placeholder that audit-chain ships today.
1350    #[test]
1351    fn test_host_sha256_import_matches_rfc_6234_vector() {
1352        const SHA256_OF_ABC: &[u8; 64] =
1353            b"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1354
1355        let config = PluginRuntimeConfig {
1356            fuel_metering: false,
1357            ..Default::default()
1358        };
1359        let runtime = WasmPluginRuntime::new(&config).unwrap();
1360
1361        let module = build_sha256_test_module(runtime.engine());
1362        let metadata = PluginMetadata {
1363            name: "sha256-test-plugin".to_string(),
1364            hooks: vec![HookType::PreQuery],
1365            ..Default::default()
1366        };
1367
1368        let plugin = LoadedPlugin::new(
1369            metadata,
1370            PathBuf::from("/test/sha256.wasm"),
1371            module,
1372            PluginSandbox::default(),
1373        );
1374
1375        let out = runtime
1376            .call_hook(&plugin, HookType::PreQuery, &[])
1377            .expect("pre_query call");
1378        assert_eq!(out.len(), 64);
1379        assert_eq!(&out[..], SHA256_OF_ABC);
1380    }
1381}