1use 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#[derive(Debug, Clone)]
47pub enum PluginError {
48 LoadError(String),
50
51 InstantiationError(String),
53
54 ExecutionError(String),
56
57 Timeout(String),
59
60 MemoryExceeded(String),
62
63 SecurityViolation(String),
65
66 InvalidManifest(String),
68
69 HookNotFound(String),
71
72 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#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum PluginState {
97 Loading,
99
100 Running,
102
103 Paused,
105
106 Error(String),
108
109 Unloading,
111}
112
113pub struct LoadedPlugin {
115 pub metadata: PluginMetadata,
117
118 pub state: PluginState,
120
121 pub path: PathBuf,
123
124 module: Module,
127
128 instance_pre: OnceLock<InstancePre<StoreCtx>>,
134
135 #[allow(dead_code)]
137 sandbox: PluginSandbox,
138
139 instance_data: RwLock<PluginInstanceData>,
141
142 loaded_at: Instant,
144
145 last_invoked: RwLock<Option<Instant>>,
147
148 invocation_count: std::sync::atomic::AtomicU64,
150}
151
152struct PluginInstanceData {
154 memory_used: usize,
156
157 fuel_consumed: u64,
159
160 #[allow(dead_code)]
162 state: HashMap<String, Vec<u8>>,
163}
164
165impl LoadedPlugin {
166 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 #[allow(dead_code)]
195 pub(crate) fn module(&self) -> &Module {
196 &self.module
197 }
198
199 pub fn memory_used(&self) -> usize {
201 self.instance_data.read().memory_used
202 }
203
204 pub fn invocation_count(&self) -> u64 {
206 self.invocation_count
207 .load(std::sync::atomic::Ordering::Relaxed)
208 }
209
210 pub fn uptime(&self) -> Duration {
212 self.loaded_at.elapsed()
213 }
214
215 pub fn last_invoked(&self) -> Option<Instant> {
217 *self.last_invoked.read()
218 }
219
220 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
228pub struct WasmPluginRuntime {
230 config: PluginRuntimeConfig,
232
233 engine: Engine,
236
237 linker: Linker<StoreCtx>,
242
243 epoch_stop: Arc<AtomicBool>,
245
246 #[allow(dead_code)]
248 host_functions: Arc<HostFunctionRegistry>,
249
250 kv: KvBackend,
253
254 module_cache: RwLock<HashMap<PathBuf, Module>>,
257
258 default_policy: SecurityPolicy,
260
261 created_at: Instant,
263}
264
265impl WasmPluginRuntime {
266 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 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 let mut linker: Linker<StoreCtx> = Linker::new(&engine);
285 register_kv_imports(&mut linker)?;
286 register_crypto_imports(&mut linker)?;
287
288 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 pub fn kv(&self) -> &KvBackend {
339 &self.kv
340 }
341
342 #[allow(dead_code)]
345 pub(crate) fn linker(&self) -> &Linker<StoreCtx> {
346 &self.linker
347 }
348
349 #[allow(dead_code)]
352 pub(crate) fn engine(&self) -> &Engine {
353 &self.engine
354 }
355
356 pub fn config(&self) -> &PluginRuntimeConfig {
360 &self.config
361 }
362
363 pub fn instantiate(
365 &self,
366 manifest: &super::loader::PluginManifest,
367 wasm_bytes: &[u8],
368 ) -> Result<LoadedPlugin, PluginError> {
369 if wasm_bytes.len() < 8 {
371 return Err(PluginError::LoadError("WASM module too small".to_string()));
372 }
373
374 if &wasm_bytes[0..4] != b"\x00asm" {
376 return Err(PluginError::LoadError(
377 "Invalid WASM magic number".to_string(),
378 ));
379 }
380
381 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 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 let module = Module::from_binary(&self.engine, wasm_bytes)
415 .map_err(|e| PluginError::InstantiationError(format!("wasmtime compile: {}", e)))?;
416
417 {
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 pub fn call_hook(
447 &self,
448 plugin: &LoadedPlugin,
449 hook: HookType,
450 args: &[u8],
451 ) -> Result<Vec<u8>, PluginError> {
452 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 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 plugin.record_invocation();
470
471 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 store
482 .set_fuel(self.config.fuel_limit)
483 .map_err(|e| PluginError::RuntimeError(format!("set_fuel: {}", e)))?;
484 }
485 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 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 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 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 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 let _ = dealloc.call(&mut store, (out_ptr, out_len));
551 bytes
552 } else {
553 Vec::new()
554 }
555 }
556 Err(_) => {
557 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 let _ = dealloc.call(&mut store, (in_ptr, in_len));
572
573 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 pub fn call_pre_query(
587 &self,
588 plugin: &LoadedPlugin,
589 ctx: &QueryContext,
590 ) -> Result<PreQueryResult, PluginError> {
591 let args = serde_json::to_vec(ctx).map_err(|e| {
593 PluginError::ExecutionError(format!("Failed to serialize context: {}", e))
594 })?;
595
596 let result = self.call_hook(plugin, HookType::PreQuery, &args)?;
598
599 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 pub fn call_authenticate(
611 &self,
612 plugin: &LoadedPlugin,
613 request: &AuthRequest,
614 ) -> Result<AuthResult, PluginError> {
615 let args = serde_json::to_vec(request).map_err(|e| {
617 PluginError::ExecutionError(format!("Failed to serialize request: {}", e))
618 })?;
619
620 let result = self.call_hook(plugin, HookType::Authenticate, &args)?;
622
623 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 pub fn call_route(
635 &self,
636 plugin: &LoadedPlugin,
637 ctx: &QueryContext,
638 ) -> Result<RouteResult, PluginError> {
639 let args = serde_json::to_vec(ctx).map_err(|e| {
641 PluginError::ExecutionError(format!("Failed to serialize context: {}", e))
642 })?;
643
644 let result = self.call_hook(plugin, HookType::Route, &args)?;
646
647 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 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 self.epoch_stop
674 .store(true, std::sync::atomic::Ordering::Relaxed);
675 }
676}
677
678#[derive(Debug, Clone)]
680pub struct RuntimeStats {
681 pub uptime: Duration,
683
684 pub cached_modules: usize,
686
687 pub fuel_metering_enabled: bool,
689
690 pub memory_limit: usize,
692
693 pub timeout: Duration,
695}
696
697impl 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
717fn 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
733fn 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
746fn 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" => 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 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 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 #[test]
961 fn test_call_hook_enforces_timeout() {
962 let config = PluginRuntimeConfig {
963 fuel_metering: false, 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 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 #[test]
1056 fn test_call_hook_roundtrips_real_wasm() {
1057 let config = PluginRuntimeConfig {
1058 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 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 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 #[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![], ..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 #[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 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 fn build_kv_test_module(engine: &Engine) -> Module {
1144 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 #[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 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 assert_eq!(
1207 runtime.kv().get("kv-test-plugin", b"key"),
1208 Some(b"value".to_vec())
1209 );
1210 assert_eq!(runtime.kv().get("other-plugin", b"key"), None);
1212 }
1213
1214 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 #[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 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 let out = runtime
1280 .call_hook(&plugin, HookType::PreQuery, &[])
1281 .expect("pre_query call");
1282 assert_eq!(&out[..], b"live-value");
1283 }
1284
1285 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 #[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 #[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 #[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}