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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct WasmToolInput {
100 pub input: String,
101 pub workspace_root: String,
102}
103
104#[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#[derive(Debug, Clone)]
120pub struct WasmExecutionResultV2 {
121 pub output: String,
122 pub error: Option<String>,
123}
124
125#[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
138fn pack_ptr_len(ptr: u32, len: u32) -> i64 {
144 (ptr as i64) | ((len as i64) << 32)
145}
146
147fn 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#[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 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 struct PluginState {
175 wasi: wasmi_wasi::WasiCtx,
176 limits: StoreLimits,
177 log_buffer: Vec<String>,
178 }
179
180 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 pub fn create_engine() -> anyhow::Result<Engine> {
195 Ok(make_engine())
196 }
197
198 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 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 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 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 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 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 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 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 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(&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 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 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 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 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 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 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 fn register_host_functions(
773 linker: &mut Linker<PluginState>,
774 policy: &WasmIsolationPolicy,
775 ) -> anyhow::Result<()> {
776 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 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 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#[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 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 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 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 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 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 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 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 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 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 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 self.preflight_v2(container, policy)?;
1460
1461 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_v2_imports(&module, policy, &options.capabilities)?;
1477
1478 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 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 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 let mut linker: Linker<PluginState> = Linker::new(&engine);
1525
1526 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(&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 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 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 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 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 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 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 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 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 fn register_host_functions(
1677 linker: &mut Linker<PluginState>,
1678 policy: &WasmIsolationPolicy,
1679 ) -> anyhow::Result<()> {
1680 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 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 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 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 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 if module_name == "wasi_snapshot_preview1" {
1802 continue;
1803 }
1804 if module_name == "az" {
1806 let func_name = import.name();
1807 if func_name == "az_log" {
1809 continue;
1810 }
1811 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 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 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 pub struct ModuleCache;
1867
1868 impl ModuleCache {
1869 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 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 match unsafe { Module::deserialize_file(engine, &cwasm_path) } {
1901 Ok(module) => return Ok(module),
1902 Err(_e) => {
1903 }
1906 }
1907 }
1908 }
1909 }
1910
1911 let module = Module::from_file(engine, wasm_path)
1913 .map_err(|e| anyhow!("failed to compile module at {}: {e}", wasm_path.display()))?;
1914
1915 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#[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#[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 #[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 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 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 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 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 #[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 #[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 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 #[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}