Skip to main content

agentzero_plugins/
wasm.rs

1use anyhow::anyhow;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::path::PathBuf;
5
6#[cfg(feature = "wasm-runtime")]
7pub use runtime_impl::ModuleCache;
8
9/// Type aliases for the active WASM engine/module, usable by downstream crates
10/// (e.g. `agentzero-infra`) for plugin warming without depending on wasmi/wasmtime directly.
11#[cfg(all(feature = "wasm-runtime", not(feature = "wasm-jit")))]
12pub type WasmEngine = wasmi::Engine;
13#[cfg(all(feature = "wasm-runtime", not(feature = "wasm-jit")))]
14pub type WasmModule = wasmi::Module;
15#[cfg(feature = "wasm-jit")]
16pub type WasmEngine = wasmtime::Engine;
17#[cfg(feature = "wasm-jit")]
18pub type WasmModule = wasmtime::Module;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WasmIsolationPolicy {
22    pub max_execution_ms: u64,
23    pub max_module_bytes: u64,
24    pub max_memory_mb: u32,
25    pub allow_network: bool,
26    pub allow_fs_write: bool,
27    pub allow_fs_read: bool,
28    pub allowed_host_calls: Vec<String>,
29}
30
31impl Default for WasmIsolationPolicy {
32    fn default() -> Self {
33        Self {
34            max_execution_ms: 30_000,
35            max_module_bytes: 5 * 1024 * 1024,
36            max_memory_mb: 256,
37            allow_network: false,
38            allow_fs_write: false,
39            allow_fs_read: false,
40            allowed_host_calls: Vec::new(),
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WasmPluginContainer {
47    pub id: String,
48    pub module_path: PathBuf,
49    pub entrypoint: String,
50    pub max_execution_ms: u64,
51    pub max_memory_mb: u32,
52    pub allow_network: bool,
53    pub allow_fs_write: bool,
54}
55
56impl WasmPluginContainer {
57    pub fn validate(&self) -> anyhow::Result<()> {
58        if self.id.trim().is_empty() {
59            return Err(anyhow!("plugin id cannot be empty"));
60        }
61        if self.entrypoint.trim().is_empty() {
62            return Err(anyhow!("plugin entrypoint cannot be empty"));
63        }
64        if self.max_execution_ms == 0 {
65            return Err(anyhow!("max_execution_ms must be > 0"));
66        }
67        if self.max_memory_mb == 0 {
68            return Err(anyhow!("max_memory_mb must be > 0"));
69        }
70        if self.module_path.extension().and_then(|e| e.to_str()) != Some("wasm") {
71            return Err(anyhow!("plugin module must be a .wasm file"));
72        }
73        Ok(())
74    }
75}
76
77pub struct WasmPluginRuntime;
78
79// ---------------------------------------------------------------------------
80// ABI v1 types (backward-compatible)
81// ---------------------------------------------------------------------------
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct WasmExecutionRequest {
85    pub input: Value,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct WasmExecutionResult {
90    pub status_code: i32,
91}
92
93// ---------------------------------------------------------------------------
94// ABI v2 types
95// ---------------------------------------------------------------------------
96
97/// Input passed from the host to a v2 plugin via linear memory.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct WasmToolInput {
100    pub input: String,
101    pub workspace_root: String,
102}
103
104/// Output returned from a v2 plugin via linear memory.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct WasmToolOutput {
107    pub output: String,
108    #[serde(default)]
109    pub error: Option<String>,
110}
111
112impl WasmToolOutput {
113    pub fn is_error(&self) -> bool {
114        self.error.is_some()
115    }
116}
117
118/// Result of a v2 plugin execution.
119#[derive(Debug, Clone)]
120pub struct WasmExecutionResultV2 {
121    pub output: String,
122    pub error: Option<String>,
123}
124
125/// Options for v2 execution that go beyond the container/policy.
126#[derive(Debug, Clone, Default)]
127pub struct WasmV2Options {
128    pub workspace_root: String,
129    pub capabilities: Vec<String>,
130}
131
132impl Default for WasmPluginRuntime {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Helper: pack/unpack i64 as (ptr, len) pair
140// ---------------------------------------------------------------------------
141
142/// Pack a (ptr, len) pair into a single i64.
143fn pack_ptr_len(ptr: u32, len: u32) -> i64 {
144    (ptr as i64) | ((len as i64) << 32)
145}
146
147/// Unpack an i64 into a (ptr, len) pair.
148fn unpack_ptr_len(packed: i64) -> (u32, u32) {
149    let ptr = (packed & 0xFFFF_FFFF) as u32;
150    let len = ((packed >> 32) & 0xFFFF_FFFF) as u32;
151    (ptr, len)
152}
153
154// ---------------------------------------------------------------------------
155// wasm-runtime (wasmi interpreter): lightweight, pure-Rust, no_std-compatible
156// ---------------------------------------------------------------------------
157#[cfg(all(feature = "wasm-runtime", not(feature = "wasm-jit")))]
158mod runtime_impl {
159    use super::*;
160    use anyhow::Context;
161    use std::path::Path;
162    use wasmi::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
163
164    /// Approximate fuel units per millisecond of execution time.
165    /// wasmi consumes ~1 fuel per instruction; ~100M instructions/sec on
166    /// typical hardware ≈ 100K fuel/ms.
167    const FUEL_PER_MS: u64 = 100_000;
168
169    fn compute_fuel(timeout_ms: u64) -> u64 {
170        timeout_ms.saturating_mul(FUEL_PER_MS)
171    }
172
173    /// Store data for wasmi: WASI context + memory limits + log buffer.
174    struct PluginState {
175        wasi: wasmi_wasi::WasiCtx,
176        limits: StoreLimits,
177        log_buffer: Vec<String>,
178    }
179
180    /// Create a wasmi Engine with fuel metering enabled.
181    fn make_engine() -> Engine {
182        let mut config = Config::default();
183        config.consume_fuel(true);
184        Engine::new(&config)
185    }
186
187    impl WasmPluginRuntime {
188        pub fn new() -> Self {
189            Self
190        }
191
192        /// Create a new WASM engine with fuel metering enabled.
193        /// Share this across plugins for efficient resource use.
194        pub fn create_engine() -> anyhow::Result<Engine> {
195            Ok(make_engine())
196        }
197
198        /// Pre-compile a WASM module from the given path.
199        /// The returned module can be reused across multiple executions.
200        pub fn compile_module(
201            engine: &Engine,
202            wasm_path: &std::path::Path,
203        ) -> anyhow::Result<Module> {
204            let bytes = std::fs::read(wasm_path)
205                .with_context(|| format!("failed to read module at {}", wasm_path.display()))?;
206            Module::new(engine, &bytes)
207                .map_err(|e| anyhow!("failed to compile module at {}: {e}", wasm_path.display()))
208        }
209
210        /// Execute a v2 plugin with a pre-compiled engine and module.
211        /// This avoids disk I/O and module compilation on the hot path.
212        pub fn execute_v2_precompiled(
213            engine: &Engine,
214            module: &Module,
215            container: &WasmPluginContainer,
216            input: &str,
217            options: &WasmV2Options,
218            policy: &WasmIsolationPolicy,
219        ) -> anyhow::Result<WasmExecutionResultV2> {
220            // Preflight checks (skip module file read — already compiled)
221            container.validate()?;
222            if container.max_execution_ms > policy.max_execution_ms {
223                return Err(anyhow!(
224                    "max_execution_ms exceeds policy limit ({} > {})",
225                    container.max_execution_ms,
226                    policy.max_execution_ms
227                ));
228            }
229            if container.max_memory_mb > policy.max_memory_mb {
230                return Err(anyhow!(
231                    "max_memory_mb exceeds policy limit ({} > {})",
232                    container.max_memory_mb,
233                    policy.max_memory_mb
234                ));
235            }
236            if container.allow_network && !policy.allow_network {
237                return Err(anyhow!(
238                    "network access is not permitted by isolation policy"
239                ));
240            }
241            if container.allow_fs_write && !policy.allow_fs_write {
242                return Err(anyhow!(
243                    "filesystem write is not permitted by isolation policy"
244                ));
245            }
246
247            validate_v2_imports(module, policy, &options.capabilities)?;
248
249            let mut wasi_builder = wasmi_wasi::WasiCtxBuilder::new();
250            wasi_builder.inherit_stderr();
251
252            if policy.allow_fs_read && !options.workspace_root.is_empty() {
253                let workspace_path = std::path::Path::new(&options.workspace_root);
254                if workspace_path.exists() {
255                    match wasmi_wasi::sync::Dir::open_ambient_dir(
256                        workspace_path,
257                        wasmi_wasi::sync::ambient_authority(),
258                    ) {
259                        Ok(dir) => {
260                            let _ = wasi_builder.preopened_dir(dir, ".");
261                        }
262                        Err(e) => {
263                            tracing::warn!(
264                                path = %options.workspace_root,
265                                error = %e,
266                                "failed to preopen workspace dir"
267                            );
268                        }
269                    }
270                }
271            }
272
273            let wasi = wasi_builder.build();
274            let effective_memory_mb = container.max_memory_mb.min(policy.max_memory_mb);
275            let limits = StoreLimitsBuilder::new()
276                .memory_size((effective_memory_mb as usize) * 1024 * 1024)
277                .build();
278            let state = PluginState {
279                wasi,
280                limits,
281                log_buffer: Vec::new(),
282            };
283            let mut store = Store::new(engine, state);
284            store.limiter(|s| &mut s.limits);
285
286            let effective_timeout_ms = container.max_execution_ms.min(policy.max_execution_ms);
287            store
288                .set_fuel(compute_fuel(effective_timeout_ms))
289                .map_err(|e| anyhow!("failed to set fuel: {e}"))?;
290
291            let mut linker: Linker<PluginState> = Linker::new(engine);
292            wasmi_wasi::sync::add_to_linker(&mut linker, |s: &mut PluginState| &mut s.wasi)
293                .map_err(|e| anyhow!("failed to add WASI p1 to linker: {e}"))?;
294            register_host_functions(&mut linker, policy)?;
295
296            let instance = linker
297                .instantiate_and_start(&mut store, module)
298                .map_err(|e| anyhow!("failed to instantiate v2 plugin module: {e}"))?;
299
300            let tool_input = WasmToolInput {
301                input: input.to_string(),
302                workspace_root: options.workspace_root.clone(),
303            };
304            let input_json =
305                serde_json::to_string(&tool_input).context("failed to serialize v2 tool input")?;
306
307            let az_alloc = instance
308                .get_typed_func::<i32, i32>(&store, "az_alloc")
309                .map_err(|e| {
310                    anyhow!("v2 plugin missing 'az_alloc' export (expected fn(i32) -> i32): {e}")
311                })?;
312
313            let input_bytes = input_json.as_bytes();
314            let input_len = input_bytes.len() as i32;
315            let input_ptr = az_alloc
316                .call(&mut store, input_len)
317                .map_err(|e| anyhow!("az_alloc call failed: {e}"))?;
318
319            let memory = instance
320                .get_memory(&store, "memory")
321                .ok_or_else(|| anyhow!("v2 plugin does not export 'memory'"))?;
322
323            let mem_data = memory.data_mut(&mut store);
324            let start = input_ptr as usize;
325            let end = start + input_bytes.len();
326            if end > mem_data.len() {
327                return Err(anyhow!(
328                    "az_alloc returned ptr {input_ptr} but memory size is {} (need {end})",
329                    mem_data.len()
330                ));
331            }
332            mem_data[start..end].copy_from_slice(input_bytes);
333
334            let az_tool_execute = instance
335                .get_typed_func::<(i32, i32), i64>(&store, "az_tool_execute")
336                .map_err(|e| anyhow!("v2 plugin missing 'az_tool_execute' export: {e}"))?;
337
338            let result_packed = match az_tool_execute.call(&mut store, (input_ptr, input_len)) {
339                Ok(packed) => packed,
340                Err(err) => {
341                    let err_text = err.to_string();
342                    if err_text.contains("out of fuel") || err_text.contains("fuel") {
343                        return Err(anyhow!(
344                            "plugin execution exceeded time limit ({} ms)",
345                            effective_timeout_ms
346                        ));
347                    }
348                    return Err(anyhow!("az_tool_execute call failed: {err}"));
349                }
350            };
351
352            let (out_ptr, out_len) = unpack_ptr_len(result_packed);
353            if out_len == 0 {
354                return Ok(WasmExecutionResultV2 {
355                    output: String::new(),
356                    error: Some("plugin returned empty output".to_string()),
357                });
358            }
359
360            let mem_data = memory.data(&store);
361            let out_start = out_ptr as usize;
362            let out_end = out_start + out_len as usize;
363            if out_end > mem_data.len() {
364                return Err(anyhow!(
365                    "plugin output ptr/len ({out_ptr}, {out_len}) exceeds memory bounds ({})",
366                    mem_data.len()
367                ));
368            }
369
370            let output_json = std::str::from_utf8(&mem_data[out_start..out_end])
371                .map_err(|e| anyhow!("plugin output is not valid UTF-8: {e}"))?;
372
373            let tool_output: WasmToolOutput = serde_json::from_str(output_json).map_err(|e| {
374                anyhow!("plugin output is not valid JSON: {e} (raw: {output_json})")
375            })?;
376
377            Ok(WasmExecutionResultV2 {
378                output: tool_output.output,
379                error: tool_output.error,
380            })
381        }
382
383        // -------------------------------------------------------------------
384        // v1 API (backward-compatible)
385        // -------------------------------------------------------------------
386
387        pub fn preflight(&self, container: &WasmPluginContainer) -> anyhow::Result<()> {
388            self.preflight_with_policy(container, &WasmIsolationPolicy::default())
389        }
390
391        pub fn preflight_with_policy(
392            &self,
393            container: &WasmPluginContainer,
394            policy: &WasmIsolationPolicy,
395        ) -> anyhow::Result<()> {
396            container.validate()?;
397            if container.max_execution_ms > policy.max_execution_ms {
398                return Err(anyhow!(
399                    "max_execution_ms exceeds policy limit ({} > {})",
400                    container.max_execution_ms,
401                    policy.max_execution_ms
402                ));
403            }
404            if container.max_memory_mb > policy.max_memory_mb {
405                return Err(anyhow!(
406                    "max_memory_mb exceeds policy limit ({} > {})",
407                    container.max_memory_mb,
408                    policy.max_memory_mb
409                ));
410            }
411            if container.allow_network && !policy.allow_network {
412                return Err(anyhow!(
413                    "network access is not permitted by isolation policy"
414                ));
415            }
416            if container.allow_fs_write && !policy.allow_fs_write {
417                return Err(anyhow!(
418                    "filesystem write is not permitted by isolation policy"
419                ));
420            }
421
422            let path = Path::new(&container.module_path);
423            if !path.exists() {
424                return Err(anyhow!("plugin module does not exist: {}", path.display()));
425            }
426            let metadata = std::fs::metadata(path)
427                .with_context(|| format!("failed to read metadata for {}", path.display()))?;
428            if metadata.len() > policy.max_module_bytes {
429                return Err(anyhow!(
430                    "plugin module exceeds size policy ({} > {} bytes)",
431                    metadata.len(),
432                    policy.max_module_bytes
433                ));
434            }
435
436            let engine = Engine::default();
437            let bytes = std::fs::read(path)
438                .with_context(|| format!("failed to read module at {}", path.display()))?;
439            let module = Module::new(&engine, &bytes)
440                .map_err(|e| anyhow!("failed to compile module at {}: {e}", path.display()))?;
441            validate_host_call_allowlist(&module, policy)?;
442
443            Ok(())
444        }
445
446        fn preflight_v2(
447            &self,
448            container: &WasmPluginContainer,
449            policy: &WasmIsolationPolicy,
450        ) -> anyhow::Result<()> {
451            container.validate()?;
452
453            if container.max_execution_ms > policy.max_execution_ms {
454                return Err(anyhow!(
455                    "max_execution_ms exceeds policy limit ({} > {})",
456                    container.max_execution_ms,
457                    policy.max_execution_ms
458                ));
459            }
460            if container.max_memory_mb > policy.max_memory_mb {
461                return Err(anyhow!(
462                    "max_memory_mb exceeds policy limit ({} > {})",
463                    container.max_memory_mb,
464                    policy.max_memory_mb
465                ));
466            }
467            if container.allow_network && !policy.allow_network {
468                return Err(anyhow!(
469                    "network access is not permitted by isolation policy"
470                ));
471            }
472            if container.allow_fs_write && !policy.allow_fs_write {
473                return Err(anyhow!(
474                    "filesystem write is not permitted by isolation policy"
475                ));
476            }
477
478            let path = Path::new(&container.module_path);
479            if !path.exists() {
480                return Err(anyhow!("plugin module does not exist: {}", path.display()));
481            }
482            let metadata = std::fs::metadata(path)
483                .with_context(|| format!("failed to read metadata for {}", path.display()))?;
484            if metadata.len() > policy.max_module_bytes {
485                return Err(anyhow!(
486                    "plugin module exceeds size policy ({} > {} bytes)",
487                    metadata.len(),
488                    policy.max_module_bytes
489                ));
490            }
491
492            Ok(())
493        }
494
495        pub fn execute(
496            &self,
497            container: &WasmPluginContainer,
498            request: &WasmExecutionRequest,
499        ) -> anyhow::Result<WasmExecutionResult> {
500            self.execute_with_policy(container, request, &WasmIsolationPolicy::default())
501        }
502
503        pub fn execute_with_policy(
504            &self,
505            container: &WasmPluginContainer,
506            _request: &WasmExecutionRequest,
507            policy: &WasmIsolationPolicy,
508        ) -> anyhow::Result<WasmExecutionResult> {
509            self.preflight_with_policy(container, policy)?;
510
511            let engine = make_engine();
512            let bytes = std::fs::read(&container.module_path).map_err(|e| {
513                anyhow!(
514                    "failed to read module at {}: {e}",
515                    container.module_path.display()
516                )
517            })?;
518            let module = Module::new(&engine, &bytes).map_err(|e| {
519                anyhow!(
520                    "failed to compile module at {}: {e}",
521                    container.module_path.display()
522                )
523            })?;
524            validate_host_call_allowlist(&module, policy)?;
525
526            let effective_memory_mb = container.max_memory_mb.min(policy.max_memory_mb);
527            let limits = StoreLimitsBuilder::new()
528                .memory_size((effective_memory_mb as usize) * 1024 * 1024)
529                .build();
530            let wasi = wasmi_wasi::WasiCtxBuilder::new().build();
531            let state = PluginState {
532                wasi,
533                limits,
534                log_buffer: Vec::new(),
535            };
536            let mut store = Store::new(&engine, state);
537            store.limiter(|s| &mut s.limits);
538
539            let effective_timeout_ms = container.max_execution_ms.min(policy.max_execution_ms);
540            store
541                .set_fuel(compute_fuel(effective_timeout_ms))
542                .map_err(|e| anyhow!("failed to set fuel: {e}"))?;
543
544            let linker = Linker::new(&engine);
545            let instance = linker
546                .instantiate_and_start(&mut store, &module)
547                .map_err(|e| anyhow!("failed to instantiate plugin module: {e}"))?;
548
549            let entrypoint = instance
550                .get_typed_func::<(), i32>(&store, &container.entrypoint)
551                .map_err(|e| {
552                    anyhow!(
553                        "missing or incompatible entrypoint '{}' (expected fn() -> i32): {e}",
554                        container.entrypoint
555                    )
556                })?;
557
558            let call_result = entrypoint.call(&mut store, ());
559            let status_code = match call_result {
560                Ok(status) => status,
561                Err(err) => {
562                    let err_text = err.to_string();
563                    if err_text.contains("out of fuel") || err_text.contains("fuel") {
564                        return Err(anyhow!(
565                            "plugin execution exceeded time limit ({} ms)",
566                            effective_timeout_ms
567                        ));
568                    }
569                    return Err(anyhow!("plugin entrypoint call failed: {err}"));
570                }
571            };
572
573            Ok(WasmExecutionResult { status_code })
574        }
575
576        // -------------------------------------------------------------------
577        // v2 API: JSON input/output, WASI, host callbacks
578        // -------------------------------------------------------------------
579
580        pub fn execute_v2(
581            &self,
582            container: &WasmPluginContainer,
583            input: &str,
584            options: &WasmV2Options,
585        ) -> anyhow::Result<WasmExecutionResultV2> {
586            self.execute_v2_with_policy(container, input, options, &WasmIsolationPolicy::default())
587        }
588
589        pub fn execute_v2_with_policy(
590            &self,
591            container: &WasmPluginContainer,
592            input: &str,
593            options: &WasmV2Options,
594            policy: &WasmIsolationPolicy,
595        ) -> anyhow::Result<WasmExecutionResultV2> {
596            self.preflight_v2(container, policy)?;
597
598            let engine = make_engine();
599            let bytes = std::fs::read(&container.module_path).map_err(|e| {
600                anyhow!(
601                    "failed to compile module at {}: {e}",
602                    container.module_path.display()
603                )
604            })?;
605            let module = Module::new(&engine, &bytes).map_err(|e| {
606                anyhow!(
607                    "failed to compile module at {}: {e}",
608                    container.module_path.display()
609                )
610            })?;
611
612            validate_v2_imports(&module, policy, &options.capabilities)?;
613
614            // Build WASI context with capabilities gated by policy
615            let mut wasi_builder = wasmi_wasi::WasiCtxBuilder::new();
616            wasi_builder.inherit_stderr();
617
618            if policy.allow_fs_read && !options.workspace_root.is_empty() {
619                let workspace_path = std::path::Path::new(&options.workspace_root);
620                if workspace_path.exists() {
621                    match wasmi_wasi::sync::Dir::open_ambient_dir(
622                        workspace_path,
623                        wasmi_wasi::sync::ambient_authority(),
624                    ) {
625                        Ok(dir) => {
626                            if policy.allow_fs_write {
627                                let _ = wasi_builder.preopened_dir(dir, ".");
628                            } else {
629                                // wasmi_wasi doesn't have fine-grained perms
630                                // like wasmtime; preopened dirs are read-write.
631                                // For read-only, we still preopen but rely on
632                                // WASM sandbox isolation.
633                                let _ = wasi_builder.preopened_dir(dir, ".");
634                            }
635                        }
636                        Err(e) => {
637                            tracing::warn!(
638                                path = %options.workspace_root,
639                                error = %e,
640                                "failed to preopen workspace dir"
641                            );
642                        }
643                    }
644                }
645            }
646
647            let wasi = wasi_builder.build();
648
649            // Memory limits
650            let effective_memory_mb = container.max_memory_mb.min(policy.max_memory_mb);
651            let limits = StoreLimitsBuilder::new()
652                .memory_size((effective_memory_mb as usize) * 1024 * 1024)
653                .build();
654            let state = PluginState {
655                wasi,
656                limits,
657                log_buffer: Vec::new(),
658            };
659            let mut store = Store::new(&engine, state);
660            store.limiter(|s| &mut s.limits);
661
662            let effective_timeout_ms = container.max_execution_ms.min(policy.max_execution_ms);
663            store
664                .set_fuel(compute_fuel(effective_timeout_ms))
665                .map_err(|e| anyhow!("failed to set fuel: {e}"))?;
666
667            // Linker: add WASI preview1 + host functions
668            let mut linker: Linker<PluginState> = Linker::new(&engine);
669
670            wasmi_wasi::sync::add_to_linker(&mut linker, |s: &mut PluginState| &mut s.wasi)
671                .map_err(|e| anyhow!("failed to add WASI p1 to linker: {e}"))?;
672
673            // Register host functions in the "az" namespace
674            register_host_functions(&mut linker, policy)?;
675
676            let instance = linker
677                .instantiate_and_start(&mut store, &module)
678                .map_err(|e| anyhow!("failed to instantiate v2 plugin module: {e}"))?;
679
680            // Build the input JSON
681            let tool_input = WasmToolInput {
682                input: input.to_string(),
683                workspace_root: options.workspace_root.clone(),
684            };
685            let input_json =
686                serde_json::to_string(&tool_input).context("failed to serialize v2 tool input")?;
687
688            // Allocate space in WASM memory for the input via az_alloc
689            let az_alloc = instance
690                .get_typed_func::<i32, i32>(&store, "az_alloc")
691                .map_err(|e| {
692                    anyhow!("v2 plugin missing 'az_alloc' export (expected fn(i32) -> i32): {e}")
693                })?;
694
695            let input_bytes = input_json.as_bytes();
696            let input_len = input_bytes.len() as i32;
697
698            let input_ptr = az_alloc
699                .call(&mut store, input_len)
700                .map_err(|e| anyhow!("az_alloc call failed: {e}"))?;
701
702            // Write input JSON into WASM linear memory
703            let memory = instance
704                .get_memory(&store, "memory")
705                .ok_or_else(|| anyhow!("v2 plugin does not export 'memory'"))?;
706
707            let mem_data = memory.data_mut(&mut store);
708            let start = input_ptr as usize;
709            let end = start + input_bytes.len();
710            if end > mem_data.len() {
711                return Err(anyhow!(
712                    "az_alloc returned ptr {input_ptr} but memory size is {} (need {end})",
713                    mem_data.len()
714                ));
715            }
716            mem_data[start..end].copy_from_slice(input_bytes);
717
718            // Call az_tool_execute(ptr, len) -> i64
719            let az_tool_execute = instance
720                .get_typed_func::<(i32, i32), i64>(&store, "az_tool_execute")
721                .map_err(|e| anyhow!("v2 plugin missing 'az_tool_execute' export: {e}"))?;
722
723            let result_packed = match az_tool_execute.call(&mut store, (input_ptr, input_len)) {
724                Ok(packed) => packed,
725                Err(err) => {
726                    let err_text = err.to_string();
727                    if err_text.contains("out of fuel") || err_text.contains("fuel") {
728                        return Err(anyhow!(
729                            "plugin execution exceeded time limit ({} ms)",
730                            effective_timeout_ms
731                        ));
732                    }
733                    return Err(anyhow!("az_tool_execute call failed: {err}"));
734                }
735            };
736
737            // Unpack the result pointer and length
738            let (out_ptr, out_len) = unpack_ptr_len(result_packed);
739            if out_len == 0 {
740                return Ok(WasmExecutionResultV2 {
741                    output: String::new(),
742                    error: Some("plugin returned empty output".to_string()),
743                });
744            }
745
746            // Read output JSON from WASM linear memory
747            let mem_data = memory.data(&store);
748            let out_start = out_ptr as usize;
749            let out_end = out_start + out_len as usize;
750            if out_end > mem_data.len() {
751                return Err(anyhow!(
752                    "plugin output ptr/len ({out_ptr}, {out_len}) exceeds memory bounds ({})",
753                    mem_data.len()
754                ));
755            }
756
757            let output_json = std::str::from_utf8(&mem_data[out_start..out_end])
758                .map_err(|e| anyhow!("plugin output is not valid UTF-8: {e}"))?;
759
760            let tool_output: WasmToolOutput = serde_json::from_str(output_json).map_err(|e| {
761                anyhow!("plugin output is not valid JSON: {e} (raw: {output_json})")
762            })?;
763
764            Ok(WasmExecutionResultV2 {
765                output: tool_output.output,
766                error: tool_output.error,
767            })
768        }
769    }
770
771    /// Register `az_*` host functions in the linker under the "az" namespace.
772    fn register_host_functions(
773        linker: &mut Linker<PluginState>,
774        policy: &WasmIsolationPolicy,
775    ) -> anyhow::Result<()> {
776        // az_log(level: i32, msg_ptr: i32, msg_len: i32)
777        linker
778            .func_wrap(
779                "az",
780                "az_log",
781                |mut caller: wasmi::Caller<'_, PluginState>,
782                 level: i32,
783                 msg_ptr: i32,
784                 msg_len: i32| {
785                    let memory = caller.get_export("memory").and_then(|e| e.into_memory());
786                    if let Some(memory) = memory {
787                        let msg_opt = {
788                            let data = memory.data(&caller);
789                            let start = msg_ptr as usize;
790                            let end = start + msg_len as usize;
791                            if end <= data.len() {
792                                std::str::from_utf8(&data[start..end])
793                                    .ok()
794                                    .map(|s| s.to_owned())
795                            } else {
796                                None
797                            }
798                        };
799                        if let Some(msg) = msg_opt {
800                            let level_str = match level {
801                                0 => "ERROR",
802                                1 => "WARN",
803                                2 => "INFO",
804                                3 => "DEBUG",
805                                _ => "TRACE",
806                            };
807                            caller
808                                .data_mut()
809                                .log_buffer
810                                .push(format!("[{level_str}] {msg}"));
811                        }
812                    }
813                },
814            )
815            .map_err(|e| anyhow!("failed to register az_log: {e}"))?;
816
817        // az_env_get(key_ptr: i32, key_len: i32) -> i64
818        if policy
819            .allowed_host_calls
820            .iter()
821            .any(|h| h == "az::az_env_get")
822        {
823            linker
824                .func_wrap(
825                    "az",
826                    "az_env_get",
827                    |mut caller: wasmi::Caller<'_, PluginState>,
828                     key_ptr: i32,
829                     key_len: i32|
830                     -> i64 {
831                        let memory = caller.get_export("memory").and_then(|e| e.into_memory());
832                        let Some(memory) = memory else {
833                            return 0;
834                        };
835
836                        let data = memory.data(&caller);
837                        let start = key_ptr as usize;
838                        let end = start + key_len as usize;
839                        if end > data.len() {
840                            return 0;
841                        }
842                        let Ok(key) = std::str::from_utf8(&data[start..end]) else {
843                            return 0;
844                        };
845                        let Ok(value) = std::env::var(key) else {
846                            return 0;
847                        };
848
849                        let az_alloc = caller
850                            .get_export("az_alloc")
851                            .and_then(|e| e.into_func())
852                            .and_then(|f| f.typed::<i32, i32>(&caller).ok());
853                        let Some(az_alloc) = az_alloc else {
854                            return 0;
855                        };
856
857                        let value_bytes = value.as_bytes();
858                        let Ok(ptr) = az_alloc.call(&mut caller, value_bytes.len() as i32) else {
859                            return 0;
860                        };
861
862                        let mem = caller.get_export("memory").and_then(|e| e.into_memory());
863                        if let Some(mem) = mem {
864                            let data = mem.data_mut(&mut caller);
865                            let s = ptr as usize;
866                            let e = s + value_bytes.len();
867                            if e <= data.len() {
868                                data[s..e].copy_from_slice(value_bytes);
869                                return pack_ptr_len(ptr as u32, value_bytes.len() as u32);
870                            }
871                        }
872                        0
873                    },
874                )
875                .map_err(|e| anyhow!("failed to register az_env_get: {e}"))?;
876        }
877
878        Ok(())
879    }
880
881    fn validate_v2_imports(
882        module: &Module,
883        policy: &WasmIsolationPolicy,
884        capabilities: &[String],
885    ) -> anyhow::Result<()> {
886        for import in module.imports() {
887            let module_name = import.module();
888            if module_name == "wasi_snapshot_preview1" {
889                continue;
890            }
891            if module_name == "az" {
892                let func_name = import.name();
893                if func_name == "az_log" {
894                    continue;
895                }
896                let key = format!("az::{func_name}");
897                if capabilities
898                    .iter()
899                    .any(|c| c == &key || c == &format!("host:{func_name}"))
900                    && policy.allowed_host_calls.iter().any(|h| h == &key)
901                {
902                    continue;
903                }
904                return Err(anyhow!(
905                    "host function `{key}` is not permitted by isolation policy"
906                ));
907            }
908            let key = format!("{}::{}", module_name, import.name());
909            if !policy
910                .allowed_host_calls
911                .iter()
912                .any(|allowed| allowed == &key)
913            {
914                return Err(anyhow!(
915                    "host call `{key}` is not allowed by isolation policy"
916                ));
917            }
918        }
919        Ok(())
920    }
921
922    fn validate_host_call_allowlist(
923        module: &Module,
924        policy: &WasmIsolationPolicy,
925    ) -> anyhow::Result<()> {
926        for import in module.imports() {
927            let key = format!("{}::{}", import.module(), import.name());
928            if !policy
929                .allowed_host_calls
930                .iter()
931                .any(|allowed| allowed == &key)
932            {
933                return Err(anyhow!(
934                    "host call `{key}` is not allowed by isolation policy"
935                ));
936            }
937        }
938        Ok(())
939    }
940
941    // -----------------------------------------------------------------------
942    // Module cache: wasmi has no AOT compilation — passthrough only
943    // -----------------------------------------------------------------------
944
945    pub struct ModuleCache;
946
947    impl ModuleCache {
948        pub fn load_or_compile(
949            engine: &Engine,
950            wasm_path: &Path,
951            _expected_sha256: &str,
952        ) -> anyhow::Result<Module> {
953            let bytes = std::fs::read(wasm_path)
954                .with_context(|| format!("failed to read module at {}", wasm_path.display()))?;
955            Module::new(engine, &bytes)
956                .map_err(|e| anyhow!("failed to compile module at {}: {e}", wasm_path.display()))
957        }
958    }
959}
960
961// ---------------------------------------------------------------------------
962// wasm-jit feature: full wasmtime JIT-backed implementation
963// ---------------------------------------------------------------------------
964#[cfg(feature = "wasm-jit")]
965mod runtime_impl {
966    use super::*;
967    use anyhow::Context;
968    use std::path::Path;
969    use std::sync::{
970        atomic::{AtomicBool, Ordering},
971        Arc,
972    };
973    use std::time::{Duration, Instant};
974    use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
975    use wasmtime_wasi::p1::WasiP1Ctx;
976    use wasmtime_wasi::WasiCtxBuilder;
977
978    /// Combined store state for v2 plugins: WASI context + memory limits.
979    struct PluginState {
980        wasi: WasiP1Ctx,
981        limits: StoreLimits,
982        log_buffer: Vec<String>,
983    }
984
985    impl WasmPluginRuntime {
986        pub fn new() -> Self {
987            Self
988        }
989
990        /// Create a new wasmtime engine with epoch interruption enabled.
991        /// Share this across plugins for efficient resource use.
992        pub fn create_engine() -> anyhow::Result<Engine> {
993            let mut config = Config::new();
994            config.epoch_interruption(true);
995            Engine::new(&config).map_err(|e| anyhow!("failed to configure wasmtime engine: {e}"))
996        }
997
998        /// Pre-compile a WASM module from the given path.
999        /// The returned module can be reused across multiple executions.
1000        pub fn compile_module(
1001            engine: &Engine,
1002            wasm_path: &std::path::Path,
1003        ) -> anyhow::Result<Module> {
1004            Module::from_file(engine, wasm_path)
1005                .map_err(|e| anyhow!("failed to compile module at {}: {e}", wasm_path.display()))
1006        }
1007
1008        /// Execute a v2 plugin with a pre-compiled engine and module.
1009        /// This avoids disk I/O and module compilation on the hot path.
1010        pub fn execute_v2_precompiled(
1011            engine: &Engine,
1012            module: &Module,
1013            container: &WasmPluginContainer,
1014            input: &str,
1015            options: &WasmV2Options,
1016            policy: &WasmIsolationPolicy,
1017        ) -> anyhow::Result<WasmExecutionResultV2> {
1018            // Preflight checks (skip module file read — already compiled)
1019            container.validate()?;
1020            if container.max_execution_ms > policy.max_execution_ms {
1021                return Err(anyhow!(
1022                    "max_execution_ms exceeds policy limit ({} > {})",
1023                    container.max_execution_ms,
1024                    policy.max_execution_ms
1025                ));
1026            }
1027            if container.max_memory_mb > policy.max_memory_mb {
1028                return Err(anyhow!(
1029                    "max_memory_mb exceeds policy limit ({} > {})",
1030                    container.max_memory_mb,
1031                    policy.max_memory_mb
1032                ));
1033            }
1034            if container.allow_network && !policy.allow_network {
1035                return Err(anyhow!(
1036                    "network access is not permitted by isolation policy"
1037                ));
1038            }
1039            if container.allow_fs_write && !policy.allow_fs_write {
1040                return Err(anyhow!(
1041                    "filesystem write is not permitted by isolation policy"
1042                ));
1043            }
1044
1045            validate_v2_imports(module, policy, &options.capabilities)?;
1046
1047            // Build WASI context with capabilities gated by policy
1048            let mut wasi_builder = WasiCtxBuilder::new();
1049            wasi_builder.inherit_stderr();
1050
1051            if policy.allow_fs_read && !options.workspace_root.is_empty() {
1052                let perms = if policy.allow_fs_write {
1053                    wasmtime_wasi::DirPerms::all()
1054                } else {
1055                    wasmtime_wasi::DirPerms::READ
1056                };
1057                let file_perms = if policy.allow_fs_write {
1058                    wasmtime_wasi::FilePerms::all()
1059                } else {
1060                    wasmtime_wasi::FilePerms::READ
1061                };
1062                if let Err(e) =
1063                    wasi_builder.preopened_dir(&options.workspace_root, ".", perms, file_perms)
1064                {
1065                    tracing::warn!(
1066                        path = %options.workspace_root,
1067                        error = %e,
1068                        "failed to preopen workspace dir"
1069                    );
1070                }
1071            }
1072
1073            let wasi = wasi_builder.build_p1();
1074            let effective_memory_mb = container.max_memory_mb.min(policy.max_memory_mb);
1075            let limits = StoreLimitsBuilder::new()
1076                .memory_size((effective_memory_mb as usize) * 1024 * 1024)
1077                .build();
1078            let state = PluginState {
1079                wasi,
1080                limits,
1081                log_buffer: Vec::new(),
1082            };
1083            let mut store = Store::new(engine, state);
1084            store.limiter(|s: &mut PluginState| &mut s.limits);
1085            store.set_epoch_deadline(1);
1086
1087            let mut linker: Linker<PluginState> = Linker::new(engine);
1088            wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s: &mut PluginState| &mut s.wasi)
1089                .map_err(|e| anyhow!("failed to add WASI p1 to linker: {e}"))?;
1090            register_host_functions(&mut linker, policy)?;
1091
1092            let instance = linker
1093                .instantiate(&mut store, module)
1094                .map_err(|e| anyhow!("failed to instantiate v2 plugin module: {e}"))?;
1095
1096            // Epoch timeout thread
1097            let effective_timeout_ms = container.max_execution_ms.min(policy.max_execution_ms);
1098            let timer_engine = engine.clone();
1099            let timer_cancel = Arc::new(AtomicBool::new(false));
1100            let timer_cancel_worker = Arc::clone(&timer_cancel);
1101            let timer_handle = std::thread::spawn(move || {
1102                let deadline = Instant::now() + Duration::from_millis(effective_timeout_ms);
1103                while Instant::now() < deadline {
1104                    if timer_cancel_worker.load(Ordering::Relaxed) {
1105                        return;
1106                    }
1107                    std::thread::sleep(Duration::from_millis(2));
1108                }
1109                if !timer_cancel_worker.load(Ordering::Relaxed) {
1110                    timer_engine.increment_epoch();
1111                }
1112            });
1113
1114            struct TimerGuard {
1115                cancel: Arc<AtomicBool>,
1116                handle: Option<std::thread::JoinHandle<()>>,
1117            }
1118            impl Drop for TimerGuard {
1119                fn drop(&mut self) {
1120                    self.cancel.store(true, Ordering::Relaxed);
1121                    if let Some(h) = self.handle.take() {
1122                        let _ = h.join();
1123                    }
1124                }
1125            }
1126            let _timer_guard = TimerGuard {
1127                cancel: Arc::clone(&timer_cancel),
1128                handle: Some(timer_handle),
1129            };
1130
1131            let tool_input = WasmToolInput {
1132                input: input.to_string(),
1133                workspace_root: options.workspace_root.clone(),
1134            };
1135            let input_json =
1136                serde_json::to_string(&tool_input).context("failed to serialize v2 tool input")?;
1137
1138            let az_alloc = instance
1139                .get_typed_func::<i32, i32>(&mut store, "az_alloc")
1140                .map_err(|e| {
1141                    anyhow!("v2 plugin missing 'az_alloc' export (expected fn(i32) -> i32): {e}")
1142                })?;
1143
1144            let input_bytes = input_json.as_bytes();
1145            let input_len = input_bytes.len() as i32;
1146            let input_ptr = az_alloc
1147                .call(&mut store, input_len)
1148                .map_err(|e| anyhow!("az_alloc call failed: {e}"))?;
1149
1150            let memory = instance
1151                .get_memory(&mut store, "memory")
1152                .ok_or_else(|| anyhow!("v2 plugin does not export 'memory'"))?;
1153
1154            let mem_data = memory.data_mut(&mut store);
1155            let start = input_ptr as usize;
1156            let end = start + input_bytes.len();
1157            if end > mem_data.len() {
1158                return Err(anyhow!(
1159                    "az_alloc returned ptr {input_ptr} but memory size is {} (need {end})",
1160                    mem_data.len()
1161                ));
1162            }
1163            mem_data[start..end].copy_from_slice(input_bytes);
1164
1165            let az_tool_execute = instance
1166                .get_typed_func::<(i32, i32), i64>(&mut store, "az_tool_execute")
1167                .map_err(|e| anyhow!("v2 plugin missing 'az_tool_execute' export: {e}"))?;
1168
1169            let started = Instant::now();
1170            let result_packed = match az_tool_execute.call(&mut store, (input_ptr, input_len)) {
1171                Ok(packed) => packed,
1172                Err(err) => {
1173                    let err_text = err.to_string();
1174                    let timed_out =
1175                        started.elapsed() >= Duration::from_millis(effective_timeout_ms);
1176                    if err_text.contains("epoch deadline exceeded")
1177                        || err_text.contains("interrupt")
1178                        || err_text.contains("interrupted")
1179                        || err_text.contains("deadline")
1180                        || timed_out
1181                    {
1182                        return Err(anyhow!(
1183                            "plugin execution exceeded time limit ({} ms)",
1184                            effective_timeout_ms
1185                        ));
1186                    }
1187                    return Err(anyhow!("az_tool_execute call failed: {err}"));
1188                }
1189            };
1190
1191            let (out_ptr, out_len) = unpack_ptr_len(result_packed);
1192            if out_len == 0 {
1193                return Ok(WasmExecutionResultV2 {
1194                    output: String::new(),
1195                    error: Some("plugin returned empty output".to_string()),
1196                });
1197            }
1198
1199            let mem_data = memory.data(&store);
1200            let out_start = out_ptr as usize;
1201            let out_end = out_start + out_len as usize;
1202            if out_end > mem_data.len() {
1203                return Err(anyhow!(
1204                    "plugin output ptr/len ({out_ptr}, {out_len}) exceeds memory bounds ({})",
1205                    mem_data.len()
1206                ));
1207            }
1208
1209            let output_json = std::str::from_utf8(&mem_data[out_start..out_end])
1210                .map_err(|e| anyhow!("plugin output is not valid UTF-8: {e}"))?;
1211
1212            let tool_output: WasmToolOutput = serde_json::from_str(output_json).map_err(|e| {
1213                anyhow!("plugin output is not valid JSON: {e} (raw: {output_json})")
1214            })?;
1215
1216            Ok(WasmExecutionResultV2 {
1217                output: tool_output.output,
1218                error: tool_output.error,
1219            })
1220        }
1221
1222        // -------------------------------------------------------------------
1223        // v1 API (backward-compatible, unchanged)
1224        // -------------------------------------------------------------------
1225
1226        pub fn preflight(&self, container: &WasmPluginContainer) -> anyhow::Result<()> {
1227            self.preflight_with_policy(container, &WasmIsolationPolicy::default())
1228        }
1229
1230        pub fn preflight_with_policy(
1231            &self,
1232            container: &WasmPluginContainer,
1233            policy: &WasmIsolationPolicy,
1234        ) -> anyhow::Result<()> {
1235            container.validate()?;
1236            if container.max_execution_ms > policy.max_execution_ms {
1237                return Err(anyhow!(
1238                    "max_execution_ms exceeds policy limit ({} > {})",
1239                    container.max_execution_ms,
1240                    policy.max_execution_ms
1241                ));
1242            }
1243            if container.max_memory_mb > policy.max_memory_mb {
1244                return Err(anyhow!(
1245                    "max_memory_mb exceeds policy limit ({} > {})",
1246                    container.max_memory_mb,
1247                    policy.max_memory_mb
1248                ));
1249            }
1250            if container.allow_network && !policy.allow_network {
1251                return Err(anyhow!(
1252                    "network access is not permitted by isolation policy"
1253                ));
1254            }
1255            if container.allow_fs_write && !policy.allow_fs_write {
1256                return Err(anyhow!(
1257                    "filesystem write is not permitted by isolation policy"
1258                ));
1259            }
1260
1261            let path = Path::new(&container.module_path);
1262            if !path.exists() {
1263                return Err(anyhow!("plugin module does not exist: {}", path.display()));
1264            }
1265            let metadata = std::fs::metadata(path)
1266                .with_context(|| format!("failed to read metadata for {}", path.display()))?;
1267            if metadata.len() > policy.max_module_bytes {
1268                return Err(anyhow!(
1269                    "plugin module exceeds size policy ({} > {} bytes)",
1270                    metadata.len(),
1271                    policy.max_module_bytes
1272                ));
1273            }
1274
1275            let engine = Engine::default();
1276            let module = Module::from_file(&engine, path)
1277                .map_err(|e| anyhow!("failed to compile module at {}: {e}", path.display()))?;
1278            validate_host_call_allowlist(&module, policy)?;
1279
1280            Ok(())
1281        }
1282
1283        /// Preflight for v2 plugins — checks container/policy constraints and
1284        /// file existence/size, but does NOT run v1 import validation (v2
1285        /// modules use WASI and az namespace imports that v1 validation
1286        /// rejects).
1287        fn preflight_v2(
1288            &self,
1289            container: &WasmPluginContainer,
1290            policy: &WasmIsolationPolicy,
1291        ) -> anyhow::Result<()> {
1292            container.validate()?;
1293
1294            if container.max_execution_ms > policy.max_execution_ms {
1295                return Err(anyhow!(
1296                    "max_execution_ms exceeds policy limit ({} > {})",
1297                    container.max_execution_ms,
1298                    policy.max_execution_ms
1299                ));
1300            }
1301            if container.max_memory_mb > policy.max_memory_mb {
1302                return Err(anyhow!(
1303                    "max_memory_mb exceeds policy limit ({} > {})",
1304                    container.max_memory_mb,
1305                    policy.max_memory_mb
1306                ));
1307            }
1308            if container.allow_network && !policy.allow_network {
1309                return Err(anyhow!(
1310                    "network access is not permitted by isolation policy"
1311                ));
1312            }
1313            if container.allow_fs_write && !policy.allow_fs_write {
1314                return Err(anyhow!(
1315                    "filesystem write is not permitted by isolation policy"
1316                ));
1317            }
1318
1319            let path = Path::new(&container.module_path);
1320            if !path.exists() {
1321                return Err(anyhow!("plugin module does not exist: {}", path.display()));
1322            }
1323            let metadata = std::fs::metadata(path)
1324                .with_context(|| format!("failed to read metadata for {}", path.display()))?;
1325            if metadata.len() > policy.max_module_bytes {
1326                return Err(anyhow!(
1327                    "plugin module exceeds size policy ({} > {} bytes)",
1328                    metadata.len(),
1329                    policy.max_module_bytes
1330                ));
1331            }
1332
1333            Ok(())
1334        }
1335
1336        pub fn execute(
1337            &self,
1338            container: &WasmPluginContainer,
1339            request: &WasmExecutionRequest,
1340        ) -> anyhow::Result<WasmExecutionResult> {
1341            self.execute_with_policy(container, request, &WasmIsolationPolicy::default())
1342        }
1343
1344        pub fn execute_with_policy(
1345            &self,
1346            container: &WasmPluginContainer,
1347            _request: &WasmExecutionRequest,
1348            policy: &WasmIsolationPolicy,
1349        ) -> anyhow::Result<WasmExecutionResult> {
1350            self.preflight_with_policy(container, policy)?;
1351
1352            let mut config = Config::new();
1353            config.epoch_interruption(true);
1354            let engine = Engine::new(&config)
1355                .map_err(|e| anyhow!("failed to configure wasmtime engine: {e}"))?;
1356            let module = Module::from_file(&engine, &container.module_path).map_err(|e| {
1357                anyhow!(
1358                    "failed to compile module at {}: {e}",
1359                    container.module_path.display()
1360                )
1361            })?;
1362            validate_host_call_allowlist(&module, policy)?;
1363
1364            let effective_memory_mb = container.max_memory_mb.min(policy.max_memory_mb);
1365            let limits = StoreLimitsBuilder::new()
1366                .memory_size((effective_memory_mb as usize) * 1024 * 1024)
1367                .build();
1368            let mut store = Store::new(&engine, limits);
1369            store.limiter(|limiter: &mut StoreLimits| limiter);
1370            store.set_epoch_deadline(1);
1371
1372            let effective_timeout_ms = container.max_execution_ms.min(policy.max_execution_ms);
1373            let timer_engine = engine.clone();
1374            let timer_cancel = Arc::new(AtomicBool::new(false));
1375            let timer_cancel_worker = Arc::clone(&timer_cancel);
1376            let timer_handle = std::thread::spawn(move || {
1377                let deadline = Instant::now() + Duration::from_millis(effective_timeout_ms);
1378                while Instant::now() < deadline {
1379                    if timer_cancel_worker.load(Ordering::Relaxed) {
1380                        return;
1381                    }
1382                    std::thread::sleep(Duration::from_millis(2));
1383                }
1384                if !timer_cancel_worker.load(Ordering::Relaxed) {
1385                    timer_engine.increment_epoch();
1386                }
1387            });
1388
1389            let linker = Linker::new(&engine);
1390            let instance = linker
1391                .instantiate(&mut store, &module)
1392                .map_err(|e| anyhow!("failed to instantiate plugin module: {e}"))?;
1393
1394            let entrypoint = instance
1395                .get_typed_func::<(), i32>(&mut store, &container.entrypoint)
1396                .map_err(|e| {
1397                    anyhow!(
1398                        "missing or incompatible entrypoint '{}' (expected fn() -> i32): {e}",
1399                        container.entrypoint
1400                    )
1401                })?;
1402
1403            let started = Instant::now();
1404            let call_result: Result<i32, wasmtime::Error> = entrypoint.call(&mut store, ());
1405            let status_code = match call_result {
1406                Ok(status) => status,
1407                Err(err) => {
1408                    let err_text = err.to_string();
1409                    let timed_out =
1410                        started.elapsed() >= Duration::from_millis(effective_timeout_ms);
1411                    if err_text.contains("epoch deadline exceeded")
1412                        || err_text.contains("interrupt")
1413                        || err_text.contains("interrupted")
1414                        || err_text.contains("deadline")
1415                        || timed_out
1416                    {
1417                        timer_cancel.store(true, Ordering::Relaxed);
1418                        let _ = timer_handle.join();
1419                        return Err(anyhow!(
1420                            "plugin execution exceeded time limit ({} ms)",
1421                            effective_timeout_ms
1422                        ));
1423                    }
1424                    timer_cancel.store(true, Ordering::Relaxed);
1425                    let _ = timer_handle.join();
1426                    return Err(anyhow!("plugin entrypoint call failed: {err}"));
1427                }
1428            };
1429            timer_cancel.store(true, Ordering::Relaxed);
1430            let _ = timer_handle.join();
1431
1432            Ok(WasmExecutionResult { status_code })
1433        }
1434
1435        // -------------------------------------------------------------------
1436        // v2 API: JSON input/output, WASI, host callbacks
1437        // -------------------------------------------------------------------
1438
1439        /// Execute a v2 plugin: write JSON input to WASM memory, call
1440        /// `az_tool_execute`, read JSON output back.
1441        pub fn execute_v2(
1442            &self,
1443            container: &WasmPluginContainer,
1444            input: &str,
1445            options: &WasmV2Options,
1446        ) -> anyhow::Result<WasmExecutionResultV2> {
1447            self.execute_v2_with_policy(container, input, options, &WasmIsolationPolicy::default())
1448        }
1449
1450        pub fn execute_v2_with_policy(
1451            &self,
1452            container: &WasmPluginContainer,
1453            input: &str,
1454            options: &WasmV2Options,
1455            policy: &WasmIsolationPolicy,
1456        ) -> anyhow::Result<WasmExecutionResultV2> {
1457            // Preflight checks (v2-specific — skips v1 import validation
1458            // because v2 modules use WASI and az namespace imports).
1459            self.preflight_v2(container, policy)?;
1460
1461            // Engine with epoch interruption
1462            let mut config = Config::new();
1463            config.epoch_interruption(true);
1464            let engine = Engine::new(&config)
1465                .map_err(|e| anyhow!("failed to configure wasmtime engine: {e}"))?;
1466            let module = Module::from_file(&engine, &container.module_path).map_err(|e| {
1467                anyhow!(
1468                    "failed to compile module at {}: {e}",
1469                    container.module_path.display()
1470                )
1471            })?;
1472
1473            // Validate imports against policy (skip WASI imports which are
1474            // provided by the WASI layer, and skip az_* host imports which
1475            // we provide ourselves).
1476            validate_v2_imports(&module, policy, &options.capabilities)?;
1477
1478            // Build WASI context with capabilities gated by policy
1479            let mut wasi_builder = WasiCtxBuilder::new();
1480            wasi_builder.inherit_stderr();
1481
1482            if policy.allow_fs_read && !options.workspace_root.is_empty() {
1483                let perms = if policy.allow_fs_write {
1484                    wasmtime_wasi::DirPerms::all()
1485                } else {
1486                    wasmtime_wasi::DirPerms::READ
1487                };
1488                let file_perms = if policy.allow_fs_write {
1489                    wasmtime_wasi::FilePerms::all()
1490                } else {
1491                    wasmtime_wasi::FilePerms::READ
1492                };
1493                // Preopened dir can fail if path doesn't exist — that's fine,
1494                // just skip it with a warning.
1495                if let Err(e) =
1496                    wasi_builder.preopened_dir(&options.workspace_root, ".", perms, file_perms)
1497                {
1498                    tracing::warn!(
1499                        path = %options.workspace_root,
1500                        error = %e,
1501                        "failed to preopen workspace dir"
1502                    );
1503                }
1504            }
1505
1506            let wasi = wasi_builder.build_p1();
1507
1508            // Memory limits
1509            let effective_memory_mb = container.max_memory_mb.min(policy.max_memory_mb);
1510            let limits = StoreLimitsBuilder::new()
1511                .memory_size((effective_memory_mb as usize) * 1024 * 1024)
1512                .build();
1513
1514            let state = PluginState {
1515                wasi,
1516                limits,
1517                log_buffer: Vec::new(),
1518            };
1519            let mut store = Store::new(&engine, state);
1520            store.limiter(|s: &mut PluginState| &mut s.limits);
1521            store.set_epoch_deadline(1);
1522
1523            // Linker: add WASI p1 + host functions
1524            let mut linker: Linker<PluginState> = Linker::new(&engine);
1525
1526            // Add WASI preview1 functions (always available — individual
1527            // capabilities are gated by what we preopen above)
1528            wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s: &mut PluginState| &mut s.wasi)
1529                .map_err(|e| anyhow!("failed to add WASI p1 to linker: {e}"))?;
1530
1531            // Register host functions in the "az" namespace
1532            register_host_functions(&mut linker, policy)?;
1533
1534            let instance = linker
1535                .instantiate(&mut store, &module)
1536                .map_err(|e| anyhow!("failed to instantiate v2 plugin module: {e}"))?;
1537
1538            // Epoch timeout thread
1539            let effective_timeout_ms = container.max_execution_ms.min(policy.max_execution_ms);
1540            let timer_engine = engine.clone();
1541            let timer_cancel = Arc::new(AtomicBool::new(false));
1542            let timer_cancel_worker = Arc::clone(&timer_cancel);
1543            let timer_handle = std::thread::spawn(move || {
1544                let deadline = Instant::now() + Duration::from_millis(effective_timeout_ms);
1545                while Instant::now() < deadline {
1546                    if timer_cancel_worker.load(Ordering::Relaxed) {
1547                        return;
1548                    }
1549                    std::thread::sleep(Duration::from_millis(2));
1550                }
1551                if !timer_cancel_worker.load(Ordering::Relaxed) {
1552                    timer_engine.increment_epoch();
1553                }
1554            });
1555
1556            // Build the input JSON
1557            let tool_input = WasmToolInput {
1558                input: input.to_string(),
1559                workspace_root: options.workspace_root.clone(),
1560            };
1561            let input_json =
1562                serde_json::to_string(&tool_input).context("failed to serialize v2 tool input")?;
1563
1564            // Guard that cancels the timer thread on drop (any exit path).
1565            struct TimerGuard {
1566                cancel: Arc<AtomicBool>,
1567                handle: Option<std::thread::JoinHandle<()>>,
1568            }
1569            impl Drop for TimerGuard {
1570                fn drop(&mut self) {
1571                    self.cancel.store(true, Ordering::Relaxed);
1572                    if let Some(h) = self.handle.take() {
1573                        let _ = h.join();
1574                    }
1575                }
1576            }
1577            let _timer_guard = TimerGuard {
1578                cancel: Arc::clone(&timer_cancel),
1579                handle: Some(timer_handle),
1580            };
1581
1582            // Allocate space in WASM memory for the input via az_alloc
1583            let az_alloc = instance
1584                .get_typed_func::<i32, i32>(&mut store, "az_alloc")
1585                .map_err(|e| {
1586                    anyhow!("v2 plugin missing 'az_alloc' export (expected fn(i32) -> i32): {e}")
1587                })?;
1588
1589            let input_bytes = input_json.as_bytes();
1590            let input_len = input_bytes.len() as i32;
1591
1592            let started = Instant::now();
1593
1594            let input_ptr = az_alloc
1595                .call(&mut store, input_len)
1596                .map_err(|e| anyhow!("az_alloc call failed: {e}"))?;
1597
1598            // Write input JSON into WASM linear memory
1599            let memory = instance
1600                .get_memory(&mut store, "memory")
1601                .ok_or_else(|| anyhow!("v2 plugin does not export 'memory'"))?;
1602
1603            let mem_data = memory.data_mut(&mut store);
1604            let start = input_ptr as usize;
1605            let end = start + input_bytes.len();
1606            if end > mem_data.len() {
1607                return Err(anyhow!(
1608                    "az_alloc returned ptr {input_ptr} but memory size is {} (need {end})",
1609                    mem_data.len()
1610                ));
1611            }
1612            mem_data[start..end].copy_from_slice(input_bytes);
1613
1614            // Call az_tool_execute(ptr, len) -> i64
1615            let az_tool_execute = instance
1616                .get_typed_func::<(i32, i32), i64>(&mut store, "az_tool_execute")
1617                .map_err(|e| anyhow!("v2 plugin missing 'az_tool_execute' export: {e}"))?;
1618
1619            let result_packed = match az_tool_execute.call(&mut store, (input_ptr, input_len)) {
1620                Ok(packed) => packed,
1621                Err(err) => {
1622                    let err_text = err.to_string();
1623                    let timed_out =
1624                        started.elapsed() >= Duration::from_millis(effective_timeout_ms);
1625                    if err_text.contains("epoch deadline exceeded")
1626                        || err_text.contains("interrupt")
1627                        || err_text.contains("interrupted")
1628                        || err_text.contains("deadline")
1629                        || timed_out
1630                    {
1631                        return Err(anyhow!(
1632                            "plugin execution exceeded time limit ({} ms)",
1633                            effective_timeout_ms
1634                        ));
1635                    }
1636                    return Err(anyhow!("az_tool_execute call failed: {err}"));
1637                }
1638            };
1639            // _timer_guard drops here, cancelling the timer thread
1640
1641            // Unpack the result pointer and length
1642            let (out_ptr, out_len) = unpack_ptr_len(result_packed);
1643            if out_len == 0 {
1644                return Ok(WasmExecutionResultV2 {
1645                    output: String::new(),
1646                    error: Some("plugin returned empty output".to_string()),
1647                });
1648            }
1649
1650            // Read output JSON from WASM linear memory
1651            let mem_data = memory.data(&store);
1652            let out_start = out_ptr as usize;
1653            let out_end = out_start + out_len as usize;
1654            if out_end > mem_data.len() {
1655                return Err(anyhow!(
1656                    "plugin output ptr/len ({out_ptr}, {out_len}) exceeds memory bounds ({})",
1657                    mem_data.len()
1658                ));
1659            }
1660
1661            let output_json = std::str::from_utf8(&mem_data[out_start..out_end])
1662                .map_err(|e| anyhow!("plugin output is not valid UTF-8: {e}"))?;
1663
1664            let tool_output: WasmToolOutput = serde_json::from_str(output_json).map_err(|e| {
1665                anyhow!("plugin output is not valid JSON: {e} (raw: {output_json})")
1666            })?;
1667
1668            Ok(WasmExecutionResultV2 {
1669                output: tool_output.output,
1670                error: tool_output.error,
1671            })
1672        }
1673    }
1674
1675    /// Register `az_*` host functions in the linker under the "az" namespace.
1676    fn register_host_functions(
1677        linker: &mut Linker<PluginState>,
1678        policy: &WasmIsolationPolicy,
1679    ) -> anyhow::Result<()> {
1680        // az_log(level: i32, msg_ptr: i32, msg_len: i32)
1681        // Always available — logging is not a security-sensitive operation.
1682        linker
1683            .func_wrap(
1684                "az",
1685                "az_log",
1686                |mut caller: wasmtime::Caller<'_, PluginState>,
1687                 level: i32,
1688                 msg_ptr: i32,
1689                 msg_len: i32| {
1690                    let memory = caller.get_export("memory").and_then(|e| e.into_memory());
1691                    if let Some(memory) = memory {
1692                        // Extract message from WASM memory, then drop the
1693                        // immutable borrow before pushing to log_buffer.
1694                        let msg_opt = {
1695                            let data = memory.data(&caller);
1696                            let start = msg_ptr as usize;
1697                            let end = start + msg_len as usize;
1698                            if end <= data.len() {
1699                                std::str::from_utf8(&data[start..end])
1700                                    .ok()
1701                                    .map(|s| s.to_owned())
1702                            } else {
1703                                None
1704                            }
1705                        };
1706                        if let Some(msg) = msg_opt {
1707                            let level_str = match level {
1708                                0 => "ERROR",
1709                                1 => "WARN",
1710                                2 => "INFO",
1711                                3 => "DEBUG",
1712                                _ => "TRACE",
1713                            };
1714                            caller
1715                                .data_mut()
1716                                .log_buffer
1717                                .push(format!("[{level_str}] {msg}"));
1718                        }
1719                    }
1720                },
1721            )
1722            .map_err(|e| anyhow!("failed to register az_log: {e}"))?;
1723
1724        // az_env_get(key_ptr: i32, key_len: i32) -> i64
1725        // Gated by allowed_host_calls containing "az::az_env_get"
1726        if policy
1727            .allowed_host_calls
1728            .iter()
1729            .any(|h| h == "az::az_env_get")
1730        {
1731            linker
1732                .func_wrap(
1733                    "az",
1734                    "az_env_get",
1735                    |mut caller: wasmtime::Caller<'_, PluginState>,
1736                     key_ptr: i32,
1737                     key_len: i32|
1738                     -> i64 {
1739                        let memory = caller.get_export("memory").and_then(|e| e.into_memory());
1740                        let Some(memory) = memory else {
1741                            return 0;
1742                        };
1743
1744                        let data = memory.data(&caller);
1745                        let start = key_ptr as usize;
1746                        let end = start + key_len as usize;
1747                        if end > data.len() {
1748                            return 0;
1749                        }
1750                        let Ok(key) = std::str::from_utf8(&data[start..end]) else {
1751                            return 0;
1752                        };
1753                        let Ok(value) = std::env::var(key) else {
1754                            return 0;
1755                        };
1756
1757                        // Allocate space in plugin memory and write the value
1758                        let az_alloc = caller
1759                            .get_export("az_alloc")
1760                            .and_then(|e| e.into_func())
1761                            .and_then(|f| f.typed::<i32, i32>(&caller).ok());
1762                        let Some(az_alloc) = az_alloc else {
1763                            return 0;
1764                        };
1765
1766                        let value_bytes = value.as_bytes();
1767                        let Ok(ptr) = az_alloc.call(&mut caller, value_bytes.len() as i32) else {
1768                            return 0;
1769                        };
1770
1771                        let mem = caller.get_export("memory").and_then(|e| e.into_memory());
1772                        if let Some(mem) = mem {
1773                            let data = mem.data_mut(&mut caller);
1774                            let s = ptr as usize;
1775                            let e = s + value_bytes.len();
1776                            if e <= data.len() {
1777                                data[s..e].copy_from_slice(value_bytes);
1778                                return pack_ptr_len(ptr as u32, value_bytes.len() as u32);
1779                            }
1780                        }
1781                        0
1782                    },
1783                )
1784                .map_err(|e| anyhow!("failed to register az_env_get: {e}"))?;
1785        }
1786
1787        Ok(())
1788    }
1789
1790    /// Validate v2 module imports against policy. WASI imports (module
1791    /// "wasi_snapshot_preview1") and registered "az" host functions are
1792    /// allowed; everything else must be in the allowlist.
1793    fn validate_v2_imports(
1794        module: &Module,
1795        policy: &WasmIsolationPolicy,
1796        capabilities: &[String],
1797    ) -> anyhow::Result<()> {
1798        for import in module.imports() {
1799            let module_name = import.module();
1800            // WASI preview1 imports are handled by the WASI layer
1801            if module_name == "wasi_snapshot_preview1" {
1802                continue;
1803            }
1804            // az namespace host functions are registered by us
1805            if module_name == "az" {
1806                let func_name = import.name();
1807                // az_log is always allowed
1808                if func_name == "az_log" {
1809                    continue;
1810                }
1811                // Other az functions must be in capabilities and policy
1812                let key = format!("az::{func_name}");
1813                if capabilities
1814                    .iter()
1815                    .any(|c| c == &key || c == &format!("host:{func_name}"))
1816                    && policy.allowed_host_calls.iter().any(|h| h == &key)
1817                {
1818                    continue;
1819                }
1820                return Err(anyhow!(
1821                    "host function `{key}` is not permitted by isolation policy"
1822                ));
1823            }
1824            // Everything else must be in the v1-style allowlist
1825            let key = format!("{}::{}", module_name, import.name());
1826            if !policy
1827                .allowed_host_calls
1828                .iter()
1829                .any(|allowed| allowed == &key)
1830            {
1831                return Err(anyhow!(
1832                    "host call `{key}` is not allowed by isolation policy"
1833                ));
1834            }
1835        }
1836        Ok(())
1837    }
1838
1839    /// Validate v1 module imports (original behavior — no WASI, no az namespace).
1840    fn validate_host_call_allowlist(
1841        module: &Module,
1842        policy: &WasmIsolationPolicy,
1843    ) -> anyhow::Result<()> {
1844        for import in module.imports() {
1845            let key = format!("{}::{}", import.module(), import.name());
1846            if !policy
1847                .allowed_host_calls
1848                .iter()
1849                .any(|allowed| allowed == &key)
1850            {
1851                return Err(anyhow!(
1852                    "host call `{key}` is not allowed by isolation policy"
1853                ));
1854            }
1855        }
1856        Ok(())
1857    }
1858
1859    // -----------------------------------------------------------------------
1860    // Module cache: AOT-compiled modules stored alongside the .wasm file.
1861    // -----------------------------------------------------------------------
1862
1863    /// Cached AOT module storage. The cache file is stored at
1864    /// `{wasm_dir}/.cache/plugin.cwasm` with a `source.sha256` sidecar
1865    /// for invalidation.
1866    pub struct ModuleCache;
1867
1868    impl ModuleCache {
1869        /// Load a module from cache or compile fresh. On successful
1870        /// compilation, the AOT artifact is written to the cache directory
1871        /// for faster future loads.
1872        ///
1873        /// Cache location: `{wasm_dir}/.cache/plugin.cwasm`
1874        /// Invalidation:   `{wasm_dir}/.cache/source.sha256`
1875        ///
1876        /// # Safety
1877        /// Uses `Module::deserialize_file()` when the SHA-256 matches.
1878        /// This is safe because:
1879        /// - wasmtime validates the serialization format on load
1880        /// - SHA-256 mismatch triggers recompilation
1881        /// - wasmtime version mismatch triggers recompilation (automatic)
1882        pub fn load_or_compile(
1883            engine: &Engine,
1884            wasm_path: &Path,
1885            expected_sha256: &str,
1886        ) -> anyhow::Result<Module> {
1887            let cache_dir = wasm_path
1888                .parent()
1889                .ok_or_else(|| anyhow!("wasm_path has no parent directory"))?
1890                .join(".cache");
1891
1892            let cwasm_path = cache_dir.join("plugin.cwasm");
1893            let sha_path = cache_dir.join("source.sha256");
1894
1895            // Try loading from cache if the SHA256 matches
1896            if cwasm_path.exists() && sha_path.exists() {
1897                if let Ok(cached_sha) = std::fs::read_to_string(&sha_path) {
1898                    if cached_sha.trim() == expected_sha256 && !expected_sha256.is_empty() {
1899                        // Safety: SHA256 verified, wasmtime checks format internally
1900                        match unsafe { Module::deserialize_file(engine, &cwasm_path) } {
1901                            Ok(module) => return Ok(module),
1902                            Err(_e) => {
1903                                // Cache is stale (e.g. wasmtime version mismatch),
1904                                // fall through to recompilation.
1905                            }
1906                        }
1907                    }
1908                }
1909            }
1910
1911            // Compile from source
1912            let module = Module::from_file(engine, wasm_path)
1913                .map_err(|e| anyhow!("failed to compile module at {}: {e}", wasm_path.display()))?;
1914
1915            // Persist AOT artifact (best-effort — cache miss is not fatal)
1916            if !expected_sha256.is_empty() {
1917                if let Err(e) = Self::write_cache(&module, &cache_dir, expected_sha256) {
1918                    tracing::warn!(error = %e, "failed to write module cache");
1919                }
1920            }
1921
1922            Ok(module)
1923        }
1924
1925        fn write_cache(module: &Module, cache_dir: &Path, sha256: &str) -> anyhow::Result<()> {
1926            std::fs::create_dir_all(cache_dir)
1927                .with_context(|| format!("failed to create cache dir {}", cache_dir.display()))?;
1928
1929            let cwasm_path = cache_dir.join("plugin.cwasm");
1930            let sha_path = cache_dir.join("source.sha256");
1931
1932            let serialized = module
1933                .serialize()
1934                .map_err(|e| anyhow!("failed to serialize module: {e}"))?;
1935            std::fs::write(&cwasm_path, serialized)
1936                .with_context(|| format!("failed to write {}", cwasm_path.display()))?;
1937            std::fs::write(&sha_path, sha256)
1938                .with_context(|| format!("failed to write {}", sha_path.display()))?;
1939
1940            Ok(())
1941        }
1942    }
1943}
1944
1945// ---------------------------------------------------------------------------
1946// Stub implementation when wasm-runtime is disabled
1947// ---------------------------------------------------------------------------
1948#[cfg(not(feature = "wasm-runtime"))]
1949mod runtime_impl {
1950    use super::*;
1951
1952    impl WasmPluginRuntime {
1953        pub fn new() -> Self {
1954            Self
1955        }
1956
1957        pub fn preflight(&self, _container: &WasmPluginContainer) -> anyhow::Result<()> {
1958            Err(anyhow!(
1959                "WASM runtime is not available (built without wasm-runtime feature)"
1960            ))
1961        }
1962
1963        pub fn preflight_with_policy(
1964            &self,
1965            _container: &WasmPluginContainer,
1966            _policy: &WasmIsolationPolicy,
1967        ) -> anyhow::Result<()> {
1968            Err(anyhow!(
1969                "WASM runtime is not available (built without wasm-runtime feature)"
1970            ))
1971        }
1972
1973        pub fn execute(
1974            &self,
1975            _container: &WasmPluginContainer,
1976            _request: &WasmExecutionRequest,
1977        ) -> anyhow::Result<WasmExecutionResult> {
1978            Err(anyhow!(
1979                "WASM runtime is not available (built without wasm-runtime feature)"
1980            ))
1981        }
1982
1983        pub fn execute_with_policy(
1984            &self,
1985            _container: &WasmPluginContainer,
1986            _request: &WasmExecutionRequest,
1987            _policy: &WasmIsolationPolicy,
1988        ) -> anyhow::Result<WasmExecutionResult> {
1989            Err(anyhow!(
1990                "WASM runtime is not available (built without wasm-runtime feature)"
1991            ))
1992        }
1993
1994        pub fn execute_v2(
1995            &self,
1996            _container: &WasmPluginContainer,
1997            _input: &str,
1998            _options: &WasmV2Options,
1999        ) -> anyhow::Result<WasmExecutionResultV2> {
2000            Err(anyhow!(
2001                "WASM runtime is not available (built without wasm-runtime feature)"
2002            ))
2003        }
2004
2005        pub fn execute_v2_with_policy(
2006            &self,
2007            _container: &WasmPluginContainer,
2008            _input: &str,
2009            _options: &WasmV2Options,
2010            _policy: &WasmIsolationPolicy,
2011        ) -> anyhow::Result<WasmExecutionResultV2> {
2012            Err(anyhow!(
2013                "WASM runtime is not available (built without wasm-runtime feature)"
2014            ))
2015        }
2016    }
2017}
2018
2019// ---------------------------------------------------------------------------
2020// Tests
2021// ---------------------------------------------------------------------------
2022
2023#[cfg(all(test, feature = "wasm-runtime"))]
2024mod tests {
2025    use super::{
2026        WasmExecutionRequest, WasmIsolationPolicy, WasmPluginContainer, WasmPluginRuntime,
2027        WasmV2Options,
2028    };
2029    use serde_json::json;
2030    use std::fs;
2031    use std::time::{SystemTime, UNIX_EPOCH};
2032
2033    fn unique_suffix() -> u128 {
2034        SystemTime::now()
2035            .duration_since(UNIX_EPOCH)
2036            .expect("clock should be after epoch")
2037            .as_nanos()
2038    }
2039
2040    // =======================================================================
2041    // v1 tests (unchanged)
2042    // =======================================================================
2043
2044    #[test]
2045    fn rejects_non_wasm_paths() {
2046        let container = WasmPluginContainer {
2047            id: "plugin-1".to_string(),
2048            module_path: "plugin.txt".into(),
2049            entrypoint: "run".to_string(),
2050            max_execution_ms: 1000,
2051            max_memory_mb: 64,
2052            allow_network: false,
2053            allow_fs_write: false,
2054        };
2055        assert!(container.validate().is_err());
2056    }
2057
2058    #[test]
2059    fn preflight_rejects_missing_file() {
2060        let runtime = WasmPluginRuntime::new();
2061        let container = WasmPluginContainer {
2062            id: "plugin-1".to_string(),
2063            module_path: "missing_plugin.wasm".into(),
2064            entrypoint: "run".to_string(),
2065            max_execution_ms: 1000,
2066            max_memory_mb: 64,
2067            allow_network: false,
2068            allow_fs_write: false,
2069        };
2070        assert!(runtime.preflight(&container).is_err());
2071    }
2072
2073    #[test]
2074    fn preflight_rejects_policy_violating_capabilities() {
2075        let runtime = WasmPluginRuntime::new();
2076        let container = WasmPluginContainer {
2077            id: "plugin-1".to_string(),
2078            module_path: "missing_plugin.wasm".into(),
2079            entrypoint: "run".to_string(),
2080            max_execution_ms: 1000,
2081            max_memory_mb: 64,
2082            allow_network: true,
2083            allow_fs_write: false,
2084        };
2085        let policy = WasmIsolationPolicy::default();
2086        let result = runtime.preflight_with_policy(&container, &policy);
2087        assert!(result.is_err());
2088        assert!(result
2089            .expect_err("policy violation should fail")
2090            .to_string()
2091            .contains("network access is not permitted"));
2092    }
2093
2094    #[test]
2095    fn preflight_rejects_disallowed_host_imports() {
2096        let runtime = WasmPluginRuntime::new();
2097        let path = std::env::temp_dir().join(format!("disallowed-import-{}.wasm", unique_suffix()));
2098        let bytes = wat::parse_str(
2099            r#"(module
2100                (import "env" "log" (func $log (param i32)))
2101                (func (export "run") (result i32)
2102                    i32.const 1
2103                    call $log
2104                    i32.const 0)
2105            )"#,
2106        )
2107        .expect("wat should compile");
2108        fs::write(&path, bytes).expect("temp wasm file should be created");
2109
2110        let container = WasmPluginContainer {
2111            id: "plugin-1".to_string(),
2112            module_path: path.clone(),
2113            entrypoint: "run".to_string(),
2114            max_execution_ms: 1000,
2115            max_memory_mb: 64,
2116            allow_network: false,
2117            allow_fs_write: false,
2118        };
2119
2120        let err = runtime
2121            .preflight_with_policy(&container, &WasmIsolationPolicy::default())
2122            .expect_err("unknown host import should fail");
2123        assert!(err
2124            .to_string()
2125            .contains("host call `env::log` is not allowed"));
2126
2127        fs::remove_file(path).expect("temp wasm file should be removed");
2128    }
2129
2130    #[test]
2131    fn preflight_accepts_allowlisted_host_imports() {
2132        let runtime = WasmPluginRuntime::new();
2133        let path =
2134            std::env::temp_dir().join(format!("allowlisted-import-{}.wasm", unique_suffix()));
2135        let bytes = wat::parse_str(
2136            r#"(module
2137                (import "env" "log" (func $log (param i32)))
2138                (func (export "run") (result i32)
2139                    i32.const 0)
2140            )"#,
2141        )
2142        .expect("wat should compile");
2143        fs::write(&path, bytes).expect("temp wasm file should be created");
2144
2145        let container = WasmPluginContainer {
2146            id: "plugin-1".to_string(),
2147            module_path: path.clone(),
2148            entrypoint: "run".to_string(),
2149            max_execution_ms: 1000,
2150            max_memory_mb: 64,
2151            allow_network: false,
2152            allow_fs_write: false,
2153        };
2154        let policy = WasmIsolationPolicy {
2155            allowed_host_calls: vec!["env::log".to_string()],
2156            ..WasmIsolationPolicy::default()
2157        };
2158        runtime
2159            .preflight_with_policy(&container, &policy)
2160            .expect("allowlisted import should pass");
2161
2162        fs::remove_file(path).expect("temp wasm file should be removed");
2163    }
2164
2165    #[test]
2166    fn preflight_rejects_oversized_module() {
2167        let runtime = WasmPluginRuntime::new();
2168        let path = std::env::temp_dir().join(format!("oversized-{}.wasm", unique_suffix()));
2169        fs::write(&path, vec![1_u8; 32]).expect("temp wasm file should be created");
2170
2171        let container = WasmPluginContainer {
2172            id: "plugin-1".to_string(),
2173            module_path: path.clone(),
2174            entrypoint: "run".to_string(),
2175            max_execution_ms: 1000,
2176            max_memory_mb: 64,
2177            allow_network: false,
2178            allow_fs_write: false,
2179        };
2180        let policy = WasmIsolationPolicy {
2181            max_module_bytes: 8,
2182            ..WasmIsolationPolicy::default()
2183        };
2184
2185        let result = runtime.preflight_with_policy(&container, &policy);
2186        assert!(result.is_err());
2187        assert!(result
2188            .expect_err("oversized module should fail")
2189            .to_string()
2190            .contains("exceeds size policy"));
2191
2192        fs::remove_file(path).expect("temp wasm file should be removed");
2193    }
2194
2195    #[test]
2196    fn execute_runs_exported_entrypoint() {
2197        let runtime = WasmPluginRuntime::new();
2198        let path = std::env::temp_dir().join(format!("execute-ok-{}.wasm", unique_suffix()));
2199        let bytes = wat::parse_str(
2200            r#"(module
2201                (func (export "run") (result i32)
2202                    i32.const 7)
2203            )"#,
2204        )
2205        .expect("wat should compile");
2206        fs::write(&path, bytes).expect("temp wasm file should be created");
2207
2208        let container = WasmPluginContainer {
2209            id: "plugin-1".to_string(),
2210            module_path: path.clone(),
2211            entrypoint: "run".to_string(),
2212            max_execution_ms: 1000,
2213            max_memory_mb: 64,
2214            allow_network: false,
2215            allow_fs_write: false,
2216        };
2217
2218        let result = runtime
2219            .execute(
2220                &container,
2221                &WasmExecutionRequest {
2222                    input: json!({"hello": "world"}),
2223                },
2224            )
2225            .expect("execution should succeed");
2226        assert_eq!(result.status_code, 7);
2227
2228        fs::remove_file(path).expect("temp wasm file should be removed");
2229    }
2230
2231    #[test]
2232    fn execute_fails_for_missing_entrypoint() {
2233        let runtime = WasmPluginRuntime::new();
2234        let path = std::env::temp_dir().join(format!("execute-missing-{}.wasm", unique_suffix()));
2235        let bytes = wat::parse_str(
2236            r#"(module
2237                (func (export "not_run") (result i32)
2238                    i32.const 1)
2239            )"#,
2240        )
2241        .expect("wat should compile");
2242        fs::write(&path, bytes).expect("temp wasm file should be created");
2243
2244        let container = WasmPluginContainer {
2245            id: "plugin-1".to_string(),
2246            module_path: path.clone(),
2247            entrypoint: "run".to_string(),
2248            max_execution_ms: 1000,
2249            max_memory_mb: 64,
2250            allow_network: false,
2251            allow_fs_write: false,
2252        };
2253
2254        let err = runtime
2255            .execute(&container, &WasmExecutionRequest { input: json!({}) })
2256            .expect_err("missing entrypoint should fail");
2257        assert!(err
2258            .to_string()
2259            .contains("missing or incompatible entrypoint"));
2260
2261        fs::remove_file(path).expect("temp wasm file should be removed");
2262    }
2263
2264    #[test]
2265    fn execute_rejects_module_exceeding_memory_limit() {
2266        let runtime = WasmPluginRuntime::new();
2267        let path = std::env::temp_dir().join(format!("memory-limit-{}.wasm", unique_suffix()));
2268        let bytes = wat::parse_str(
2269            r#"(module
2270                (memory 40)
2271                (func (export "run") (result i32)
2272                    i32.const 0)
2273            )"#,
2274        )
2275        .expect("wat should compile");
2276        fs::write(&path, bytes).expect("temp wasm file should be created");
2277
2278        let container = WasmPluginContainer {
2279            id: "plugin-1".to_string(),
2280            module_path: path.clone(),
2281            entrypoint: "run".to_string(),
2282            max_execution_ms: 1000,
2283            max_memory_mb: 1,
2284            allow_network: false,
2285            allow_fs_write: false,
2286        };
2287
2288        let err = runtime
2289            .execute(&container, &WasmExecutionRequest { input: json!({}) })
2290            .expect_err("oversized module memory should fail");
2291        assert!(err
2292            .to_string()
2293            .contains("failed to instantiate plugin module"));
2294
2295        fs::remove_file(path).expect("temp wasm file should be removed");
2296    }
2297
2298    #[test]
2299    fn execute_times_out_long_running_module() {
2300        let runtime = WasmPluginRuntime::new();
2301        let path = std::env::temp_dir().join(format!("timeout-{}.wasm", unique_suffix()));
2302        let bytes = wat::parse_str(
2303            r#"(module
2304                (func (export "run") (result i32)
2305                    (loop
2306                        br 0)
2307                    i32.const 0)
2308            )"#,
2309        )
2310        .expect("wat should compile");
2311        fs::write(&path, bytes).expect("temp wasm file should be created");
2312
2313        let container = WasmPluginContainer {
2314            id: "plugin-1".to_string(),
2315            module_path: path.clone(),
2316            entrypoint: "run".to_string(),
2317            max_execution_ms: 1,
2318            max_memory_mb: 64,
2319            allow_network: false,
2320            allow_fs_write: false,
2321        };
2322        let policy = WasmIsolationPolicy {
2323            max_execution_ms: 1,
2324            ..WasmIsolationPolicy::default()
2325        };
2326
2327        let err = runtime
2328            .execute_with_policy(
2329                &container,
2330                &WasmExecutionRequest { input: json!({}) },
2331                &policy,
2332            )
2333            .expect_err("infinite loop should time out");
2334        let err_text = err.to_string();
2335        assert!(
2336            err_text.contains("plugin execution exceeded time limit"),
2337            "unexpected timeout error: {err_text}"
2338        );
2339
2340        fs::remove_file(path).expect("temp wasm file should be removed");
2341    }
2342
2343    // =======================================================================
2344    // v2 tests
2345    // =======================================================================
2346
2347    /// Build a minimal v2 plugin WAT that implements az_alloc + az_tool_execute.
2348    /// The plugin echoes back the input with a prefix.
2349    fn v2_echo_plugin_wat() -> &'static str {
2350        r#"(module
2351            ;; 1 page = 64KB of linear memory
2352            (memory (export "memory") 1)
2353
2354            ;; Bump allocator state at byte 0
2355            (global $bump (mut i32) (i32.const 4))
2356
2357            ;; az_alloc: bump allocator
2358            (func (export "az_alloc") (param $size i32) (result i32)
2359                (local $ptr i32)
2360                global.get $bump
2361                local.set $ptr
2362                global.get $bump
2363                local.get $size
2364                i32.add
2365                global.set $bump
2366                local.get $ptr
2367            )
2368
2369            ;; az_tool_name: return "echo_plugin"
2370            (data (i32.const 65000) "echo_plugin")
2371            (func (export "az_tool_name") (result i64)
2372                ;; ptr=65000, len=11 -> pack as i64
2373                i64.const 65000                 ;; ptr
2374                i64.const 11
2375                i64.const 32
2376                i64.shl                         ;; len << 32
2377                i64.or
2378            )
2379
2380            ;; az_tool_execute: copy input to output wrapped in JSON
2381            ;; For simplicity, return a fixed JSON response.
2382            ;; Real plugins use the SDK; this is a WAT test fixture.
2383            (data (i32.const 64000) "{\"output\":\"echo:ok\",\"error\":null}")
2384            (func (export "az_tool_execute") (param $in_ptr i32) (param $in_len i32) (result i64)
2385                ;; Return the static JSON at offset 64000, length 33
2386                i64.const 64000
2387                i64.const 33
2388                i64.const 32
2389                i64.shl
2390                i64.or
2391            )
2392        )"#
2393    }
2394
2395    #[test]
2396    fn v2_execute_round_trip() {
2397        let runtime = WasmPluginRuntime::new();
2398        let path = std::env::temp_dir().join(format!("v2-echo-{}.wasm", unique_suffix()));
2399        let bytes = wat::parse_str(v2_echo_plugin_wat()).expect("v2 wat should compile");
2400        fs::write(&path, &bytes).expect("temp wasm file should be created");
2401
2402        let container = WasmPluginContainer {
2403            id: "echo-plugin".to_string(),
2404            module_path: path.clone(),
2405            entrypoint: "az_tool_execute".to_string(),
2406            max_execution_ms: 5000,
2407            max_memory_mb: 64,
2408            allow_network: false,
2409            allow_fs_write: false,
2410        };
2411
2412        let options = WasmV2Options {
2413            workspace_root: String::new(),
2414            capabilities: vec![],
2415        };
2416
2417        let result = runtime
2418            .execute_v2(&container, r#"{"task":"hello"}"#, &options)
2419            .expect("v2 execution should succeed");
2420        assert_eq!(result.output, "echo:ok");
2421        assert!(result.error.is_none());
2422
2423        fs::remove_file(path).expect("temp wasm file should be removed");
2424    }
2425
2426    #[test]
2427    fn v2_execute_missing_az_alloc_fails() {
2428        let runtime = WasmPluginRuntime::new();
2429        let path = std::env::temp_dir().join(format!("v2-no-alloc-{}.wasm", unique_suffix()));
2430        let bytes = wat::parse_str(
2431            r#"(module
2432                (memory (export "memory") 1)
2433                (func (export "az_tool_execute") (param i32) (param i32) (result i64)
2434                    i64.const 0)
2435            )"#,
2436        )
2437        .expect("wat should compile");
2438        fs::write(&path, &bytes).expect("temp wasm file should be created");
2439
2440        let container = WasmPluginContainer {
2441            id: "no-alloc".to_string(),
2442            module_path: path.clone(),
2443            entrypoint: "az_tool_execute".to_string(),
2444            max_execution_ms: 5000,
2445            max_memory_mb: 64,
2446            allow_network: false,
2447            allow_fs_write: false,
2448        };
2449
2450        let err = runtime
2451            .execute_v2(&container, "{}", &WasmV2Options::default())
2452            .expect_err("missing az_alloc should fail");
2453        assert!(
2454            err.to_string().contains("az_alloc"),
2455            "unexpected error: {err}"
2456        );
2457
2458        fs::remove_file(path).expect("temp wasm file should be removed");
2459    }
2460
2461    #[test]
2462    fn v2_execute_with_az_log_host_function() {
2463        let runtime = WasmPluginRuntime::new();
2464        let path = std::env::temp_dir().join(format!("v2-log-{}.wasm", unique_suffix()));
2465        // Plugin that calls az_log then returns success
2466        let bytes = wat::parse_str(
2467            r#"(module
2468                (import "az" "az_log" (func $az_log (param i32 i32 i32)))
2469                (memory (export "memory") 1)
2470                (global $bump (mut i32) (i32.const 4))
2471
2472                (func (export "az_alloc") (param $size i32) (result i32)
2473                    (local $ptr i32)
2474                    global.get $bump
2475                    local.set $ptr
2476                    global.get $bump
2477                    local.get $size
2478                    i32.add
2479                    global.set $bump
2480                    local.get $ptr
2481                )
2482
2483                ;; "hello from plugin" at offset 64000
2484                (data (i32.const 64000) "hello from plugin")
2485                ;; Response JSON at offset 64100
2486                (data (i32.const 64100) "{\"output\":\"logged\",\"error\":null}")
2487
2488                (func (export "az_tool_execute") (param $in_ptr i32) (param $in_len i32) (result i64)
2489                    ;; Call az_log(level=2/INFO, ptr=64000, len=17)
2490                    i32.const 2
2491                    i32.const 64000
2492                    i32.const 17
2493                    call $az_log
2494
2495                    ;; Return response: ptr=64100, len=32
2496                    i64.const 64100
2497                    i64.const 32
2498                    i64.const 32
2499                    i64.shl
2500                    i64.or
2501                )
2502            )"#,
2503        )
2504        .expect("wat should compile");
2505        fs::write(&path, &bytes).expect("temp wasm file should be created");
2506
2507        let container = WasmPluginContainer {
2508            id: "log-plugin".to_string(),
2509            module_path: path.clone(),
2510            entrypoint: "az_tool_execute".to_string(),
2511            max_execution_ms: 5000,
2512            max_memory_mb: 64,
2513            allow_network: false,
2514            allow_fs_write: false,
2515        };
2516
2517        let options = WasmV2Options {
2518            workspace_root: String::new(),
2519            capabilities: vec![],
2520        };
2521
2522        let result = runtime
2523            .execute_v2(&container, "{}", &options)
2524            .expect("v2 execution with az_log should succeed");
2525        assert_eq!(result.output, "logged");
2526        assert!(result.error.is_none());
2527
2528        fs::remove_file(path).expect("temp wasm file should be removed");
2529    }
2530
2531    #[test]
2532    fn v2_execute_rejects_undeclared_host_function() {
2533        let runtime = WasmPluginRuntime::new();
2534        let path = std::env::temp_dir().join(format!("v2-undeclared-{}.wasm", unique_suffix()));
2535        // Plugin that tries to import az_env_get without it being in the policy
2536        let bytes = wat::parse_str(
2537            r#"(module
2538                (import "az" "az_env_get" (func $az_env_get (param i32 i32) (result i64)))
2539                (memory (export "memory") 1)
2540                (global $bump (mut i32) (i32.const 4))
2541                (func (export "az_alloc") (param $size i32) (result i32)
2542                    (local $ptr i32)
2543                    global.get $bump
2544                    local.set $ptr
2545                    global.get $bump
2546                    local.get $size
2547                    i32.add
2548                    global.set $bump
2549                    local.get $ptr
2550                )
2551                (func (export "az_tool_execute") (param i32) (param i32) (result i64)
2552                    i64.const 0)
2553            )"#,
2554        )
2555        .expect("wat should compile");
2556        fs::write(&path, &bytes).expect("temp wasm file should be created");
2557
2558        let container = WasmPluginContainer {
2559            id: "undeclared-host".to_string(),
2560            module_path: path.clone(),
2561            entrypoint: "az_tool_execute".to_string(),
2562            max_execution_ms: 5000,
2563            max_memory_mb: 64,
2564            allow_network: false,
2565            allow_fs_write: false,
2566        };
2567
2568        // No az_env_get in capabilities or policy
2569        let err = runtime
2570            .execute_v2(&container, "{}", &WasmV2Options::default())
2571            .expect_err("undeclared host function should fail");
2572        assert!(
2573            err.to_string().contains("not permitted"),
2574            "unexpected error: {err}"
2575        );
2576
2577        fs::remove_file(path).expect("temp wasm file should be removed");
2578    }
2579
2580    #[test]
2581    fn v2_execute_times_out() {
2582        let runtime = WasmPluginRuntime::new();
2583        let path = std::env::temp_dir().join(format!("v2-timeout-{}.wasm", unique_suffix()));
2584        let bytes = wat::parse_str(
2585            r#"(module
2586                (memory (export "memory") 1)
2587                (global $bump (mut i32) (i32.const 4))
2588                (func (export "az_alloc") (param $size i32) (result i32)
2589                    (local $ptr i32)
2590                    global.get $bump
2591                    local.set $ptr
2592                    global.get $bump
2593                    local.get $size
2594                    i32.add
2595                    global.set $bump
2596                    local.get $ptr
2597                )
2598                (func (export "az_tool_execute") (param i32) (param i32) (result i64)
2599                    (loop br 0)
2600                    i64.const 0)
2601            )"#,
2602        )
2603        .expect("wat should compile");
2604        fs::write(&path, &bytes).expect("temp wasm file should be created");
2605
2606        let container = WasmPluginContainer {
2607            id: "timeout-v2".to_string(),
2608            module_path: path.clone(),
2609            entrypoint: "az_tool_execute".to_string(),
2610            max_execution_ms: 1,
2611            max_memory_mb: 64,
2612            allow_network: false,
2613            allow_fs_write: false,
2614        };
2615        let policy = WasmIsolationPolicy {
2616            max_execution_ms: 1,
2617            ..WasmIsolationPolicy::default()
2618        };
2619
2620        let err = runtime
2621            .execute_v2_with_policy(&container, "{}", &WasmV2Options::default(), &policy)
2622            .expect_err("infinite loop should time out");
2623        assert!(
2624            err.to_string().contains("exceeded time limit"),
2625            "unexpected error: {err}"
2626        );
2627
2628        fs::remove_file(path).expect("temp wasm file should be removed");
2629    }
2630
2631    // =======================================================================
2632    // pack/unpack tests
2633    // =======================================================================
2634
2635    #[test]
2636    fn pack_unpack_ptr_len_round_trip() {
2637        use super::{pack_ptr_len, unpack_ptr_len};
2638
2639        let packed = pack_ptr_len(1024, 256);
2640        let (ptr, len) = unpack_ptr_len(packed);
2641        assert_eq!(ptr, 1024);
2642        assert_eq!(len, 256);
2643
2644        let packed2 = pack_ptr_len(0, 0);
2645        let (ptr2, len2) = unpack_ptr_len(packed2);
2646        assert_eq!(ptr2, 0);
2647        assert_eq!(len2, 0);
2648
2649        let packed3 = pack_ptr_len(u32::MAX, u32::MAX);
2650        let (ptr3, len3) = unpack_ptr_len(packed3);
2651        assert_eq!(ptr3, u32::MAX);
2652        assert_eq!(len3, u32::MAX);
2653    }
2654
2655    // =======================================================================
2656    // ModuleCache tests (wasmi — compile-from-source, no AOT cache)
2657    // =======================================================================
2658
2659    #[cfg(not(feature = "wasm-jit"))]
2660    mod cache_tests_wasmi {
2661        use super::super::ModuleCache;
2662        use super::*;
2663
2664        #[test]
2665        fn module_cache_compiles_from_source() {
2666            let dir = std::env::temp_dir().join(format!("cache-wasmi-{}", unique_suffix()));
2667            fs::create_dir_all(&dir).expect("create dir");
2668            let wasm_path = dir.join("plugin.wasm");
2669            let bytes = wat::parse_str(v2_echo_plugin_wat()).expect("wat should compile");
2670            fs::write(&wasm_path, &bytes).expect("write wasm");
2671
2672            let engine = wasmi::Engine::default();
2673
2674            let _module = ModuleCache::load_or_compile(&engine, &wasm_path, "some-sha256")
2675                .expect("wasmi compile from source");
2676
2677            // wasmi has no AOT cache — no .cwasm files created
2678            let cache_dir = dir.join(".cache");
2679            assert!(!cache_dir.exists());
2680
2681            fs::remove_dir_all(dir).ok();
2682        }
2683
2684        #[test]
2685        fn module_cache_handles_invalid_wasm() {
2686            let dir = std::env::temp_dir().join(format!("cache-wasmi-invalid-{}", unique_suffix()));
2687            fs::create_dir_all(&dir).expect("create dir");
2688            let wasm_path = dir.join("plugin.wasm");
2689            fs::write(&wasm_path, b"not valid wasm").expect("write bad wasm");
2690
2691            let engine = wasmi::Engine::default();
2692
2693            let result = ModuleCache::load_or_compile(&engine, &wasm_path, "sha");
2694            assert!(result.is_err(), "invalid wasm should fail compilation");
2695
2696            fs::remove_dir_all(dir).ok();
2697        }
2698
2699        #[test]
2700        fn module_cache_handles_missing_file() {
2701            let engine = wasmi::Engine::default();
2702            let result = ModuleCache::load_or_compile(
2703                &engine,
2704                std::path::Path::new("/nonexistent.wasm"),
2705                "sha",
2706            );
2707            assert!(result.is_err(), "missing file should fail");
2708        }
2709    }
2710
2711    // =======================================================================
2712    // ModuleCache tests (wasmtime — AOT cache with .cwasm files)
2713    // =======================================================================
2714
2715    #[cfg(feature = "wasm-jit")]
2716    mod cache_tests_wasmtime {
2717        use super::super::ModuleCache;
2718        use super::*;
2719
2720        #[test]
2721        fn module_cache_compiles_and_caches() {
2722            use sha2::{Digest, Sha256};
2723
2724            let dir = std::env::temp_dir().join(format!("cache-test-{}", unique_suffix()));
2725            fs::create_dir_all(&dir).expect("create dir");
2726            let wasm_path = dir.join("plugin.wasm");
2727            let bytes = wat::parse_str(v2_echo_plugin_wat()).expect("wat should compile");
2728            fs::write(&wasm_path, &bytes).expect("write wasm");
2729
2730            let sha = format!("{:x}", Sha256::new_with_prefix(&bytes).finalize());
2731
2732            let mut config = wasmtime::Config::new();
2733            config.epoch_interruption(true);
2734            let engine = wasmtime::Engine::new(&config).expect("engine");
2735
2736            let _module =
2737                ModuleCache::load_or_compile(&engine, &wasm_path, &sha).expect("first compile");
2738
2739            let cache_dir = dir.join(".cache");
2740            assert!(cache_dir.join("plugin.cwasm").exists());
2741            assert!(cache_dir.join("source.sha256").exists());
2742
2743            let _module2 =
2744                ModuleCache::load_or_compile(&engine, &wasm_path, &sha).expect("cached load");
2745
2746            fs::remove_dir_all(dir).ok();
2747        }
2748
2749        #[test]
2750        fn module_cache_invalidates_on_sha_mismatch() {
2751            use sha2::{Digest, Sha256};
2752
2753            let dir = std::env::temp_dir().join(format!("cache-inval-{}", unique_suffix()));
2754            fs::create_dir_all(&dir).expect("create dir");
2755            let wasm_path = dir.join("plugin.wasm");
2756            let bytes = wat::parse_str(v2_echo_plugin_wat()).expect("wat should compile");
2757            fs::write(&wasm_path, &bytes).expect("write wasm");
2758
2759            let sha = format!("{:x}", Sha256::new_with_prefix(&bytes).finalize());
2760
2761            let mut config = wasmtime::Config::new();
2762            config.epoch_interruption(true);
2763            let engine = wasmtime::Engine::new(&config).expect("engine");
2764
2765            ModuleCache::load_or_compile(&engine, &wasm_path, &sha).expect("first compile");
2766
2767            let _module = ModuleCache::load_or_compile(&engine, &wasm_path, "different_sha256")
2768                .expect("recompile on sha mismatch");
2769
2770            fs::remove_dir_all(dir).ok();
2771        }
2772
2773        #[test]
2774        fn module_cache_handles_corrupt_cwasm() {
2775            use sha2::{Digest, Sha256};
2776
2777            let dir = std::env::temp_dir().join(format!("cache-corrupt-{}", unique_suffix()));
2778            fs::create_dir_all(&dir).expect("create dir");
2779            let wasm_path = dir.join("plugin.wasm");
2780            let bytes = wat::parse_str(v2_echo_plugin_wat()).expect("wat should compile");
2781            fs::write(&wasm_path, &bytes).expect("write wasm");
2782
2783            let sha = format!("{:x}", Sha256::new_with_prefix(&bytes).finalize());
2784
2785            let cache_dir = dir.join(".cache");
2786            fs::create_dir_all(&cache_dir).expect("create cache dir");
2787            fs::write(cache_dir.join("plugin.cwasm"), b"corrupt data").expect("write corrupt");
2788            fs::write(cache_dir.join("source.sha256"), &sha).expect("write sha");
2789
2790            let mut config = wasmtime::Config::new();
2791            config.epoch_interruption(true);
2792            let engine = wasmtime::Engine::new(&config).expect("engine");
2793
2794            let _module = ModuleCache::load_or_compile(&engine, &wasm_path, &sha)
2795                .expect("corrupt cache should fall back to recompilation");
2796
2797            fs::remove_dir_all(dir).ok();
2798        }
2799    }
2800}