Skip to main content

traverse_runtime/executor/
wasm.rs

1//! Wasmtime-backed WASM executor.
2//!
3//! Executes `wasm32-wasi` capability binaries inside a sandboxed Wasmtime engine.
4//! Input is fed via WASI stdin; output is captured from WASI stdout.
5//! No ambient WASI authority is granted — all capabilities are deny-by-default.
6
7use chrono::Utc;
8use semver::{Version, VersionReq};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12use std::collections::{HashMap, VecDeque};
13use std::fmt::Write as _;
14use std::fs;
15use std::sync::{Arc, LazyLock, Mutex};
16use std::time::SystemTime;
17use uuid::Uuid;
18use wasmtime::{
19    Caller, Config, Engine, Extern, Linker, Module, Store, StoreLimits, StoreLimitsBuilder,
20};
21use wasmtime_wasi::WasiCtxBuilder;
22use wasmtime_wasi::p1::WasiP1Ctx;
23use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
24
25use super::{
26    ArtifactType, CapabilityExecutor, ConnectorInvocationEvidence, ExecutorCapability,
27    ExecutorError, ExecutorOutput,
28};
29use crate::events::types::{LifecycleStatus, TraverseEvent};
30use traverse_contracts::{ConnectorRequirement, EventReference, ServiceType};
31
32/// Traverse Host ABI v1 is independently versioned from the runtime crate.
33pub const SUPPORTED_HOST_ABI_VERSION: &str = "1.0.0";
34
35const HOST_ABI_V1_WHITELIST: &str = include_str!("host_abi_v1.json");
36const DEFAULT_FUEL_BUDGET: u64 = 5_000_000;
37const DEFAULT_MEMORY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
38const DEFAULT_TABLE_ELEMENT_LIMIT: usize = 1_024;
39const DEFAULT_INSTANCE_LIMIT: usize = 1;
40const DEFAULT_TABLE_LIMIT: usize = 8;
41const DEFAULT_LINEAR_MEMORY_LIMIT: usize = 1;
42const DEFAULT_MODULE_CACHE_MAX_ENTRIES: usize = 64;
43
44/// Maximum bytes accepted for one `traverse_host::emit_event` payload
45/// (spec 098-capability-event-host-abi FR-008). Enforced before the guest
46/// memory read, and before deserialization.
47const MAX_EVENT_EMIT_PAYLOAD_BYTES: usize = 64 * 1024;
48
49/// `traverse_host::emit_event` accepted the event; it will be published to
50/// `EventBroker` once execution completes (spec 098 acceptance scenario 1).
51const EMIT_EVENT_OK: i32 = 0;
52/// The guest-supplied pointer/length was out of the guest's linear memory
53/// bounds, or the payload exceeded [`MAX_EVENT_EMIT_PAYLOAD_BYTES`], or the
54/// bytes were not a valid JSON object with `event_id`/`version` string
55/// fields (spec 098 FR-008, acceptance scenario 5).
56const EMIT_EVENT_ERR_INVALID_PAYLOAD: i32 = -1;
57/// The event type/version is not declared in the calling capability's
58/// contract `emits` list (spec 098 FR-002, acceptance scenario 2).
59const EMIT_EVENT_ERR_UNDECLARED_EVENT: i32 = -2;
60/// The calling capability's `service_type` is not `Subscribable` (spec 098
61/// FR-003, acceptance scenario 3).
62const EMIT_EVENT_ERR_NOT_SUBSCRIBABLE: i32 = -3;
63
64/// `traverse_host::connector_invoke` is unavailable unless the embedding host
65/// supplies an activated, capability-authorized connector binding. The default
66/// WASM executor deliberately returns this stable failure rather than granting
67/// any ambient authority (Spec 104 FR-002/FR-007).
68const CONNECTOR_INVOKE_ERR_UNBOUND: i32 = -2;
69const CONNECTOR_INVOKE_ERR_INVALID_REQUEST: i32 = -1;
70const CONNECTOR_INVOKE_ERR_UNDECLARED: i32 = -3;
71const CONNECTOR_INVOKE_ERR_UNAUTHORIZED: i32 = -4;
72const CONNECTOR_INVOKE_ERR_PAYLOAD_TOO_LARGE: i32 = -5;
73const CONNECTOR_INVOKE_ERR_EXECUTION_FAILED: i32 = -6;
74const MAX_CONNECTOR_INVOKE_REQUEST_BYTES: usize = 64 * 1024;
75const MAX_CONNECTOR_INVOKE_RESPONSE_BYTES: usize = 64 * 1024;
76
77/// Versioned guest request accepted by `traverse_host::connector_invoke`.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ConnectorInvokeRequest {
80    pub abi_version: String,
81    pub connector_id: String,
82    pub operation: String,
83    pub payload: Value,
84}
85
86/// Non-secret response returned to the guest by a mediated connector.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct ConnectorInvokeResponse {
89    pub abi_version: String,
90    pub result_class: String,
91    pub payload: Value,
92}
93
94/// A host-owned activated connector. Its implementation is never visible to a guest.
95pub trait MediatedConnector: Send + Sync {
96    /// # Errors
97    ///
98    /// Returns a stable, non-secret failure description when the host-owned
99    /// connector cannot complete the requested operation.
100    fn invoke(&self, request: &ConnectorInvokeRequest) -> Result<ConnectorInvokeResponse, String>;
101}
102
103/// The host-owned authorization context for one WASM execution.
104#[derive(Clone)]
105pub struct MediatedConnectorContext {
106    pub declared_requirements: Vec<ConnectorRequirement>,
107    pub activated_connectors: Vec<ActivatedConnector>,
108}
109
110#[derive(Clone)]
111pub struct ActivatedConnector {
112    pub connector_id: String,
113    pub version: String,
114    pub implementation: Arc<dyn MediatedConnector>,
115}
116
117static HOST_ABI_V1_WHITELIST_CACHE: LazyLock<Result<HostAbiWhitelist, String>> =
118    LazyLock::new(|| {
119        serde_json::from_str::<HostAbiWhitelist>(HOST_ABI_V1_WHITELIST).map_err(|e| e.to_string())
120    });
121
122/// A host import observed in a WASM module.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct HostAbiImport {
125    /// Imported module namespace.
126    pub module: String,
127    /// Imported function or item name.
128    pub name: String,
129}
130
131/// Successful load-time ABI validation evidence.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct HostAbiValidation {
134    /// ABI version used for whitelist validation.
135    pub abi_version: String,
136    /// All imports observed in deterministic module/name order.
137    pub imports: Vec<HostAbiImport>,
138}
139
140#[derive(Debug, Clone, Deserialize)]
141struct HostAbiWhitelist {
142    abi_version: String,
143    imports: Vec<HostAbiWhitelistImport>,
144}
145
146#[derive(Debug, Clone, Deserialize)]
147struct HostAbiWhitelistImport {
148    module: String,
149    name: String,
150}
151
152/// Return the Traverse Host ABI versions supported by this runtime.
153#[must_use]
154pub fn supported_host_abi_versions() -> &'static [&'static str] {
155    &[SUPPORTED_HOST_ABI_VERSION]
156}
157
158/// Validate a WASM binary against the declared Traverse Host ABI import whitelist.
159///
160/// # Errors
161///
162/// Returns [`ExecutorError`] when the binary is malformed, the ABI version is unsupported,
163/// or a module imports a host function outside the whitelist.
164pub fn verify_wasm_host_abi_bytes(
165    wasm_bytes: &[u8],
166    abi_version: &str,
167) -> Result<HostAbiValidation, ExecutorError> {
168    let engine = Engine::default();
169    let module = Module::from_binary(&engine, wasm_bytes).map_err(|e| {
170        ExecutorError::MalformedWasmArtifact {
171            error_code: "malformed_wasm_artifact".to_string(),
172            detail: format!("module compile: {e}"),
173        }
174    })?;
175    validate_module_imports(&module, abi_version)
176}
177
178/// Executes `.wasm32-wasi` capability binaries via Wasmtime.
179///
180/// Every invocation creates a fresh Wasmtime `Store` — no state leaks between calls.
181#[derive(Debug)]
182pub struct WasmExecutor {
183    engine: Engine,
184    limits: WasmExecutionLimits,
185    module_cache: Mutex<CompiledModuleCache>,
186    binary_cache: Mutex<LoadedBinaryCache>,
187}
188
189impl WasmExecutor {
190    /// Create a new [`WasmExecutor`] with a default Wasmtime engine.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`ExecutorError::RuntimeSetupFailed`] if Wasmtime cannot initialise.
195    pub fn new() -> Result<Self, ExecutorError> {
196        Self::with_limits(WasmExecutionLimits::default())
197    }
198
199    /// Create a [`WasmExecutor`] with explicit per-invocation resource limits.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`ExecutorError::RuntimeSetupFailed`] if Wasmtime cannot initialise.
204    pub fn with_limits(limits: WasmExecutionLimits) -> Result<Self, ExecutorError> {
205        Self::with_limits_and_cache_config(limits, WasmModuleCacheConfig::default())
206    }
207
208    /// Create a [`WasmExecutor`] with explicit resource limits and module cache bounds.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`ExecutorError::RuntimeSetupFailed`] if Wasmtime cannot initialise.
213    pub fn with_limits_and_cache_config(
214        limits: WasmExecutionLimits,
215        cache_config: WasmModuleCacheConfig,
216    ) -> Result<Self, ExecutorError> {
217        let mut config = Config::new();
218        config.consume_fuel(true);
219        let engine = Engine::new(&config)
220            .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("engine config: {e}")))?;
221        Ok(Self {
222            engine,
223            limits,
224            module_cache: Mutex::new(CompiledModuleCache::new(cache_config.max_entries)),
225            binary_cache: Mutex::new(LoadedBinaryCache::new(cache_config.max_entries)),
226        })
227    }
228
229    /// Return current compiled-module cache counters.
230    #[must_use]
231    pub fn module_cache_stats(&self) -> WasmModuleCacheStats {
232        let cache = self
233            .module_cache
234            .lock()
235            .unwrap_or_else(std::sync::PoisonError::into_inner);
236        cache.stats()
237    }
238
239    /// Return current on-disk binary cache counters.
240    #[must_use]
241    pub fn binary_cache_stats(&self) -> WasmBinaryCacheStats {
242        let cache = self
243            .binary_cache
244            .lock()
245            .unwrap_or_else(std::sync::PoisonError::into_inner);
246        cache.stats()
247    }
248}
249
250/// Per-invocation resource limits for [`WasmExecutor`].
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct WasmExecutionLimits {
253    /// Fuel units available for guest code before it traps as a timeout.
254    pub fuel_budget: u64,
255    /// Maximum bytes for each guest linear memory.
256    pub memory_bytes: usize,
257    /// Maximum elements for each guest table.
258    pub table_elements: usize,
259    /// Maximum instances in the store.
260    pub instances: usize,
261    /// Maximum tables in the store.
262    pub tables: usize,
263    /// Maximum linear memories in the store.
264    pub memories: usize,
265}
266
267impl Default for WasmExecutionLimits {
268    fn default() -> Self {
269        Self {
270            fuel_budget: DEFAULT_FUEL_BUDGET,
271            memory_bytes: DEFAULT_MEMORY_LIMIT_BYTES,
272            table_elements: DEFAULT_TABLE_ELEMENT_LIMIT,
273            instances: DEFAULT_INSTANCE_LIMIT,
274            tables: DEFAULT_TABLE_LIMIT,
275            memories: DEFAULT_LINEAR_MEMORY_LIMIT,
276        }
277    }
278}
279
280/// Bounded compiled-module cache configuration for [`WasmExecutor`].
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct WasmModuleCacheConfig {
283    /// Maximum number of compiled modules retained by checksum.
284    pub max_entries: usize,
285}
286
287impl Default for WasmModuleCacheConfig {
288    fn default() -> Self {
289        Self {
290            max_entries: DEFAULT_MODULE_CACHE_MAX_ENTRIES,
291        }
292    }
293}
294
295/// Snapshot of compiled-module cache counters.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct WasmModuleCacheStats {
298    /// Current retained compiled modules.
299    pub entries: usize,
300    /// Number of executions served from cache.
301    pub hits: u64,
302    /// Number of executions that compiled a module before insertion.
303    pub misses: u64,
304    /// Number of deterministic oldest-entry evictions.
305    pub evictions: u64,
306}
307
308/// Snapshot of on-disk WASM binary cache counters.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct WasmBinaryCacheStats {
311    /// Current retained binary entries.
312    pub entries: usize,
313    /// Number of executions served without a binary read or hash.
314    pub hits: u64,
315    /// Number of executions that required a binary read.
316    pub loads: u64,
317    /// Number of SHA-256 computations required to load binaries.
318    pub hashes: u64,
319    /// Number of deterministic oldest-entry evictions.
320    pub evictions: u64,
321}
322
323#[derive(Debug, Clone)]
324struct CachedModule {
325    module: Module,
326    validation: HostAbiValidation,
327}
328
329#[derive(Debug)]
330struct CompiledModuleCache {
331    max_entries: usize,
332    entries: HashMap<String, CachedModule>,
333    insertion_order: VecDeque<String>,
334    hits: u64,
335    misses: u64,
336    evictions: u64,
337}
338
339impl CompiledModuleCache {
340    fn new(max_entries: usize) -> Self {
341        Self {
342            max_entries: max_entries.max(1),
343            entries: HashMap::new(),
344            insertion_order: VecDeque::new(),
345            hits: 0,
346            misses: 0,
347            evictions: 0,
348        }
349    }
350
351    fn get(&mut self, checksum: &str, abi_version: &str) -> Option<CachedModule> {
352        let cached = self.entries.get(checksum)?;
353        if cached.validation.abi_version != abi_version {
354            self.misses += 1;
355            return None;
356        }
357        self.hits += 1;
358        Some(cached.clone())
359    }
360
361    fn insert(&mut self, checksum: String, cached: CachedModule) {
362        self.misses += 1;
363        while self.entries.len() >= self.max_entries {
364            if let Some(oldest) = self.insertion_order.pop_front()
365                && self.entries.remove(&oldest).is_some()
366            {
367                self.evictions += 1;
368            }
369        }
370        self.insertion_order.push_back(checksum.clone());
371        self.entries.insert(checksum, cached);
372    }
373
374    fn stats(&self) -> WasmModuleCacheStats {
375        WasmModuleCacheStats {
376            entries: self.entries.len(),
377            hits: self.hits,
378            misses: self.misses,
379            evictions: self.evictions,
380        }
381    }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq)]
385struct BinaryFileIdentity {
386    len: u64,
387    modified: SystemTime,
388}
389
390#[derive(Debug, Clone)]
391struct CachedBinary {
392    identity: BinaryFileIdentity,
393    bytes: Arc<[u8]>,
394    checksum: String,
395}
396
397#[derive(Debug)]
398struct LoadedBinaryCache {
399    max_entries: usize,
400    entries: HashMap<String, CachedBinary>,
401    insertion_order: VecDeque<String>,
402    hits: u64,
403    loads: u64,
404    hashes: u64,
405    evictions: u64,
406}
407
408impl LoadedBinaryCache {
409    fn new(max_entries: usize) -> Self {
410        Self {
411            max_entries: max_entries.max(1),
412            entries: HashMap::new(),
413            insertion_order: VecDeque::new(),
414            hits: 0,
415            loads: 0,
416            hashes: 0,
417            evictions: 0,
418        }
419    }
420
421    fn get(&mut self, path: &str, identity: &BinaryFileIdentity) -> Option<CachedBinary> {
422        let cached = self.entries.get(path)?;
423        if cached.identity != *identity {
424            return None;
425        }
426        self.hits += 1;
427        Some(cached.clone())
428    }
429
430    fn insert(&mut self, path: String, cached: CachedBinary) {
431        if let std::collections::hash_map::Entry::Occupied(mut entry) =
432            self.entries.entry(path.clone())
433        {
434            entry.insert(cached);
435            return;
436        }
437        while self.entries.len() >= self.max_entries {
438            if let Some(oldest) = self.insertion_order.pop_front()
439                && self.entries.remove(&oldest).is_some()
440            {
441                self.evictions += 1;
442            }
443        }
444        self.insertion_order.push_back(path.clone());
445        self.entries.insert(path, cached);
446    }
447
448    fn record_load(&mut self) {
449        self.loads += 1;
450        self.hashes += 1;
451    }
452
453    fn stats(&self) -> WasmBinaryCacheStats {
454        WasmBinaryCacheStats {
455            entries: self.entries.len(),
456            hits: self.hits,
457            loads: self.loads,
458            hashes: self.hashes,
459            evictions: self.evictions,
460        }
461    }
462}
463
464struct WasmStoreState {
465    wasi: WasiP1Ctx,
466    limits: StoreLimits,
467    /// Calling capability's id, `emits`, and `service_type` — used by the
468    /// `traverse_host::emit_event` host function to validate emissions
469    /// synchronously, at call time (spec 098-capability-event-host-abi
470    /// FR-002/FR-003).
471    capability_id: String,
472    emits: Vec<EventReference>,
473    service_type: ServiceType,
474    /// Events accepted via `traverse_host::emit_event` during this call.
475    emitted_events: Vec<TraverseEvent>,
476    connector_context: Option<MediatedConnectorContext>,
477    connector_invocation_evidence: Vec<ConnectorInvocationEvidence>,
478}
479
480impl CapabilityExecutor for WasmExecutor {
481    fn execute(
482        &self,
483        capability: &ExecutorCapability,
484        input: &Value,
485    ) -> Result<ExecutorOutput, ExecutorError> {
486        if capability.artifact_type != ArtifactType::Wasm {
487            return Err(ExecutorError::UnsupportedArtifactType);
488        }
489
490        // --- Load binary ---
491        let wasm_path = capability.wasm_binary_path.as_deref().ok_or_else(|| {
492            ExecutorError::BinaryLoadFailed("no wasm_binary_path set".to_string())
493        })?;
494
495        let binary = self.load_binary(wasm_path)?;
496
497        // --- Checksum validation ---
498        if let Some(expected) = capability.wasm_checksum.as_deref()
499            && binary.checksum != expected
500        {
501            return Err(ExecutorError::ChecksumMismatch {
502                expected: expected.to_string(),
503                actual: binary.checksum.clone(),
504            });
505        }
506
507        let abi_version = capability
508            .host_abi_version
509            .as_deref()
510            .unwrap_or(SUPPORTED_HOST_ABI_VERSION);
511
512        self.run_wasm_with_connectors(
513            &binary.bytes,
514            input,
515            abi_version,
516            &capability.capability_id,
517            &capability.emits,
518            capability.service_type.clone(),
519            None,
520            Some(&binary.checksum),
521        )
522    }
523}
524
525impl WasmExecutor {
526    /// Execute pre-loaded WASM bytes with the given input.
527    ///
528    /// Exposed separately so tests can pass raw bytes without needing a file on disk.
529    /// The capability is treated as `Stateless` with no declared `emits` — it
530    /// cannot call `traverse_host::emit_event`. Use
531    /// [`run_bytes_with_capability`](Self::run_bytes_with_capability) to
532    /// exercise the event-emit host function.
533    ///
534    /// # Errors
535    ///
536    /// Returns [`ExecutorError`] if input serialization fails, the WASM module cannot be
537    /// compiled or linked, execution fails, or stdout is not valid JSON.
538    pub fn run_bytes(&self, wasm_bytes: &[u8], input: &Value) -> Result<Value, ExecutorError> {
539        self.run_bytes_with_host_abi(wasm_bytes, input, SUPPORTED_HOST_ABI_VERSION)
540    }
541
542    /// Execute pre-loaded WASM bytes with an explicit Traverse Host ABI version.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`ExecutorError`] if ABI validation fails or execution cannot complete.
547    pub fn run_bytes_with_host_abi(
548        &self,
549        wasm_bytes: &[u8],
550        input: &Value,
551        abi_version: &str,
552    ) -> Result<Value, ExecutorError> {
553        self.run_wasm(
554            wasm_bytes,
555            input,
556            abi_version,
557            "test-capability",
558            &[],
559            ServiceType::Stateless,
560        )
561        .map(|output| output.value)
562    }
563
564    /// Execute pre-loaded WASM bytes as a specific capability, exercising
565    /// `traverse_host::emit_event` validation against `emits`/`service_type`
566    /// exactly as [`CapabilityExecutor::execute`] does.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`ExecutorError`] if ABI validation fails or execution cannot complete.
571    pub fn run_bytes_with_capability(
572        &self,
573        wasm_bytes: &[u8],
574        input: &Value,
575        capability_id: &str,
576        emits: &[EventReference],
577        service_type: ServiceType,
578    ) -> Result<ExecutorOutput, ExecutorError> {
579        self.run_wasm(
580            wasm_bytes,
581            input,
582            SUPPORTED_HOST_ABI_VERSION,
583            capability_id,
584            emits,
585            service_type,
586        )
587    }
588
589    /// Execute bytes with host-owned, activated connector bindings. This is the
590    /// only API that can enable `connector_invoke`; callers that do not supply
591    /// this context retain the deny-by-default handler.
592    ///
593    /// # Errors
594    ///
595    /// Returns [`ExecutorError`] when the module, guest memory, or execution
596    /// cannot be completed safely.
597    pub fn run_bytes_with_mediated_connectors(
598        &self,
599        wasm_bytes: &[u8],
600        input: &Value,
601        capability_id: &str,
602        connector_context: MediatedConnectorContext,
603    ) -> Result<ExecutorOutput, ExecutorError> {
604        self.run_wasm_with_connectors(
605            wasm_bytes,
606            input,
607            SUPPORTED_HOST_ABI_VERSION,
608            capability_id,
609            &[],
610            ServiceType::Stateless,
611            Some(connector_context),
612            None,
613        )
614    }
615
616    #[allow(clippy::too_many_arguments)]
617    fn run_wasm(
618        &self,
619        wasm_bytes: &[u8],
620        input: &Value,
621        abi_version: &str,
622        capability_id: &str,
623        emits: &[EventReference],
624        service_type: ServiceType,
625    ) -> Result<ExecutorOutput, ExecutorError> {
626        self.run_wasm_with_connectors(
627            wasm_bytes,
628            input,
629            abi_version,
630            capability_id,
631            emits,
632            service_type,
633            None,
634            None,
635        )
636    }
637
638    #[allow(clippy::too_many_arguments)]
639    fn run_wasm_with_connectors(
640        &self,
641        wasm_bytes: &[u8],
642        input: &Value,
643        abi_version: &str,
644        capability_id: &str,
645        emits: &[EventReference],
646        service_type: ServiceType,
647        connector_context: Option<MediatedConnectorContext>,
648        checksum: Option<&str>,
649    ) -> Result<ExecutorOutput, ExecutorError> {
650        let input_json = serde_json::to_string(input)
651            .map_err(|e| ExecutorError::ExecutionFailed(format!("input serialization: {e}")))?;
652
653        let checksum = checksum.map_or_else(|| sha256_hex(wasm_bytes), str::to_string);
654        let cached_module = self.compiled_module(wasm_bytes, &checksum, abi_version)?;
655
656        // Clone pipe reference before passing to builder — needed to read output after execution
657        let stdout_pipe = MemoryOutputPipe::new(65536);
658        let stdout_ref = stdout_pipe.clone();
659
660        // Build a WASI context: stdin = input JSON, stdout = captured buffer
661        // No filesystem, no network, no env vars — deny-by-default
662        let wasi_ctx: WasiP1Ctx = WasiCtxBuilder::new()
663            .stdin(MemoryInputPipe::new(input_json.into_bytes()))
664            .stdout(stdout_pipe)
665            .build_p1();
666
667        let mut linker: Linker<WasmStoreState> = Linker::new(&self.engine);
668        wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |s| &mut s.wasi)
669            .map_err(|e| ExecutorError::RuntimeSetupFailed(e.to_string()))?;
670        linker
671            .func_wrap("traverse_host", "emit_event", handle_emit_event)
672            .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("func_wrap emit_event: {e}")))?;
673        #[allow(clippy::expect_used)]
674        linker
675            .func_wrap("traverse_host", "connector_invoke", handle_connector_invoke)
676            .expect("connector_invoke host function registration should not conflict");
677
678        let mut store = Store::new(
679            &self.engine,
680            WasmStoreState {
681                wasi: wasi_ctx,
682                limits: self.store_limits(),
683                capability_id: capability_id.to_string(),
684                emits: emits.to_vec(),
685                service_type,
686                emitted_events: Vec::new(),
687                connector_context,
688                connector_invocation_evidence: Vec::new(),
689            },
690        );
691        store.limiter(|state| &mut state.limits);
692        store
693            .set_fuel(self.limits.fuel_budget)
694            .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("set fuel: {e}")))?;
695
696        linker
697            .module(&mut store, "", &cached_module.module)
698            .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("module link: {e}")))?;
699
700        let invocation = linker
701            .get_default(&mut store, "")
702            .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("get_default: {e}")))?
703            .typed::<(), ()>(&store)
704            .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("typed: {e}")))?
705            .call(&mut store, ());
706
707        if let Err(error) = invocation {
708            // WASI preview1 implements `proc_exit` by returning `I32Exit` from
709            // the guest invocation. A zero status is the command's successful
710            // completion signal, so stdout remains the capability result.
711            if !matches!(error.downcast_ref::<wasmtime_wasi::I32Exit>(), Some(exit) if exit.0 == 0)
712            {
713                return Err(classify_wasm_execution_error(&error));
714            }
715        }
716
717        // Extract captured stdout — contents() reads the buffer without consuming it
718        let raw_output = stdout_ref.contents();
719
720        let value = serde_json::from_slice::<Value>(&raw_output).map_err(|e| {
721            ExecutorError::OutputDeserializationFailed(format!(
722                "stdout is not valid JSON: {e} — raw: {}",
723                String::from_utf8_lossy(&raw_output)
724            ))
725        })?;
726
727        let data = store.into_data();
728        Ok(ExecutorOutput {
729            value,
730            emitted_events: data.emitted_events,
731            connector_invocation_evidence: data.connector_invocation_evidence,
732        })
733    }
734
735    fn store_limits(&self) -> StoreLimits {
736        StoreLimitsBuilder::new()
737            .memory_size(self.limits.memory_bytes)
738            .table_elements(self.limits.table_elements)
739            .instances(self.limits.instances)
740            .tables(self.limits.tables)
741            .memories(self.limits.memories)
742            .trap_on_grow_failure(true)
743            .build()
744    }
745
746    fn compiled_module(
747        &self,
748        wasm_bytes: &[u8],
749        checksum: &str,
750        abi_version: &str,
751    ) -> Result<CachedModule, ExecutorError> {
752        {
753            let mut cache = self
754                .module_cache
755                .lock()
756                .unwrap_or_else(std::sync::PoisonError::into_inner);
757            if let Some(cached) = cache.get(checksum, abi_version) {
758                return Ok(cached);
759            }
760        }
761
762        let module = Module::from_binary(&self.engine, wasm_bytes).map_err(|e| {
763            ExecutorError::MalformedWasmArtifact {
764                error_code: "malformed_wasm_artifact".to_string(),
765                detail: format!("module compile: {e}"),
766            }
767        })?;
768        let validation = validate_module_imports(&module, abi_version)?;
769        let cached = CachedModule { module, validation };
770
771        let mut cache = self
772            .module_cache
773            .lock()
774            .unwrap_or_else(std::sync::PoisonError::into_inner);
775        cache.insert(checksum.to_string(), cached.clone());
776        Ok(cached)
777    }
778
779    fn load_binary(&self, wasm_path: &str) -> Result<CachedBinary, ExecutorError> {
780        let metadata = fs::metadata(wasm_path).map_err(|e| {
781            ExecutorError::BinaryLoadFailed(format!("cannot read {wasm_path}: {e}"))
782        })?;
783        let identity = binary_file_identity(wasm_path, metadata.len(), metadata.modified())?;
784
785        let mut cache = self
786            .binary_cache
787            .lock()
788            .unwrap_or_else(std::sync::PoisonError::into_inner);
789        if let Some(cached) = cache.get(wasm_path, &identity) {
790            return Ok(cached);
791        }
792
793        let bytes: Arc<[u8]> = fs::read(wasm_path)
794            .map_err(|e| ExecutorError::BinaryLoadFailed(format!("cannot read {wasm_path}: {e}")))?
795            .into();
796        let cached = CachedBinary {
797            identity,
798            checksum: sha256_hex(&bytes),
799            bytes,
800        };
801        cache.record_load();
802        cache.insert(wasm_path.to_string(), cached.clone());
803        Ok(cached)
804    }
805}
806
807fn binary_file_identity(
808    wasm_path: &str,
809    len: u64,
810    modified: Result<SystemTime, std::io::Error>,
811) -> Result<BinaryFileIdentity, ExecutorError> {
812    let modified = modified
813        .map_err(|e| ExecutorError::BinaryLoadFailed(format!("cannot read {wasm_path}: {e}")))?;
814    Ok(BinaryFileIdentity { len, modified })
815}
816
817/// Host implementation of `traverse_host::emit_event` (spec
818/// 098-capability-event-host-abi FR-001). The guest passes a pointer/length
819/// into its own linear memory holding a JSON payload shaped
820/// `{"event_id": "...", "version": "...", "payload": {...}}`; this function
821/// validates it synchronously, at call time, and never panics or traps on a
822/// malformed or out-of-bounds guest pointer (FR-008) — every failure path
823/// returns a negative status code to the guest instead.
824fn handle_emit_event(mut caller: Caller<'_, WasmStoreState>, ptr: i32, len: i32) -> i32 {
825    // FR-003: checked before any guest memory is touched — rejected
826    // regardless of payload.
827    if caller.data().service_type != ServiceType::Subscribable {
828        return EMIT_EVENT_ERR_NOT_SUBSCRIBABLE;
829    }
830
831    // FR-008: bounds/size checked before any read or deserialization.
832    if ptr < 0 || len < 0 {
833        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
834    }
835    #[allow(clippy::cast_sign_loss)]
836    let (ptr, len) = (ptr as usize, len as usize);
837    if len > MAX_EVENT_EMIT_PAYLOAD_BYTES {
838        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
839    }
840
841    let Some(Extern::Memory(memory)) = caller.get_export("memory") else {
842        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
843    };
844
845    let mut buffer = vec![0u8; len];
846    // `Memory::read` bounds-checks `ptr + len` against actual guest memory
847    // size and returns `Err` rather than panicking or reading out of bounds.
848    if memory.read(&caller, ptr, &mut buffer).is_err() {
849        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
850    }
851
852    let Ok(payload) = serde_json::from_slice::<Value>(&buffer) else {
853        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
854    };
855    let Some(event_type) = payload
856        .get("event_id")
857        .and_then(Value::as_str)
858        .map(str::to_string)
859    else {
860        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
861    };
862    let Some(version) = payload
863        .get("version")
864        .and_then(Value::as_str)
865        .map(str::to_string)
866    else {
867        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
868    };
869    let data = payload
870        .get("payload")
871        .cloned()
872        .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
873
874    // FR-002: declared-emission check, synchronous, at call time.
875    let declared = caller
876        .data()
877        .emits
878        .iter()
879        .any(|decl| decl.event_id == event_type && decl.version == version);
880    if !declared {
881        return EMIT_EVENT_ERR_UNDECLARED_EVENT;
882    }
883
884    let capability_id = caller.data().capability_id.clone();
885    let event = TraverseEvent {
886        id: Uuid::new_v4().to_string(),
887        source: format!("traverse-runtime/{capability_id}"),
888        event_type: event_type.clone(),
889        datacontenttype: "application/json".to_string(),
890        time: Utc::now().to_rfc3339(),
891        data,
892        owner: capability_id.clone(),
893        version: version.clone(),
894        lifecycle_status: LifecycleStatus::Active,
895        deduplication_id: Some(format!("{capability_id}:{event_type}:{version}")),
896        ordering_scope: Some(capability_id),
897        correlation_id: None,
898        causation_id: None,
899        subject_id: None,
900        actor_id: None,
901    };
902    caller.data_mut().emitted_events.push(event);
903    EMIT_EVENT_OK
904}
905
906/// Fail closed until an embedding host supplies an active application binding.
907///
908/// The four integers are the versioned ABI's request pointer/length and
909/// response pointer/capacity. This default handler intentionally neither reads
910/// nor writes guest memory: no request data, host configuration, credentials,
911/// paths, or endpoint can cross the boundary before authorization exists.
912fn handle_connector_invoke(
913    mut caller: Caller<'_, WasmStoreState>,
914    request_ptr: i32,
915    request_len: i32,
916    response_ptr: i32,
917    response_capacity: i32,
918) -> i32 {
919    let Ok((memory, request, request_ptr, response_ptr, response_capacity)) =
920        parse_connector_request(
921            &mut caller,
922            request_ptr,
923            request_len,
924            response_ptr,
925            response_capacity,
926        )
927    else {
928        return CONNECTOR_INVOKE_ERR_INVALID_REQUEST;
929    };
930    let Some(context) = caller.data().connector_context.clone() else {
931        return connector_failure(
932            &mut caller,
933            &request.connector_id,
934            None,
935            "unbound",
936            CONNECTOR_INVOKE_ERR_UNBOUND,
937        );
938    };
939    let connector = match resolve_activated_connector(&context, &request) {
940        Ok(connector) => connector,
941        Err(failure) => {
942            return connector_failure(
943                &mut caller,
944                &request.connector_id,
945                failure.resolved_version.as_deref(),
946                failure.failure_class,
947                failure.code,
948            );
949        }
950    };
951    invoke_activated_connector(
952        &mut caller,
953        memory,
954        request,
955        request_ptr,
956        response_ptr,
957        response_capacity,
958        &connector,
959    )
960}
961
962fn parse_connector_request(
963    caller: &mut Caller<'_, WasmStoreState>,
964    request_ptr: i32,
965    request_len: i32,
966    response_ptr: i32,
967    response_capacity: i32,
968) -> Result<
969    (
970        wasmtime::Memory,
971        ConnectorInvokeRequest,
972        usize,
973        usize,
974        usize,
975    ),
976    (),
977> {
978    if request_ptr < 0 || request_len < 0 || response_ptr < 0 || response_capacity < 0 {
979        return Err(());
980    }
981    #[allow(clippy::cast_sign_loss)]
982    let (request_ptr, request_len, response_ptr, response_capacity) = (
983        request_ptr as usize,
984        request_len as usize,
985        response_ptr as usize,
986        response_capacity as usize,
987    );
988    if request_len > MAX_CONNECTOR_INVOKE_REQUEST_BYTES
989        || response_capacity > MAX_CONNECTOR_INVOKE_RESPONSE_BYTES
990    {
991        return Err(());
992    }
993    let Some(Extern::Memory(memory)) = caller.get_export("memory") else {
994        return Err(());
995    };
996    let mut bytes = vec![0_u8; request_len];
997    if memory.read(&caller, request_ptr, &mut bytes).is_err() {
998        return Err(());
999    }
1000    let Ok(request) = serde_json::from_slice::<ConnectorInvokeRequest>(&bytes) else {
1001        return Err(());
1002    };
1003    if request.abi_version != SUPPORTED_HOST_ABI_VERSION
1004        || request.connector_id.is_empty()
1005        || request.operation.is_empty()
1006        || contains_host_private_data(&request.payload)
1007    {
1008        return Err(());
1009    }
1010    Ok((
1011        memory,
1012        request,
1013        request_ptr,
1014        response_ptr,
1015        response_capacity,
1016    ))
1017}
1018
1019struct ConnectorInvokeFailure {
1020    failure_class: &'static str,
1021    resolved_version: Option<String>,
1022    code: i32,
1023}
1024
1025fn resolve_activated_connector(
1026    context: &MediatedConnectorContext,
1027    request: &ConnectorInvokeRequest,
1028) -> Result<ActivatedConnector, ConnectorInvokeFailure> {
1029    if !context
1030        .declared_requirements
1031        .iter()
1032        .any(|requirement| requirement.connector_id == request.connector_id)
1033    {
1034        return Err(ConnectorInvokeFailure {
1035            failure_class: "undeclared",
1036            resolved_version: None,
1037            code: CONNECTOR_INVOKE_ERR_UNDECLARED,
1038        });
1039    }
1040    let Some(connector) = context
1041        .activated_connectors
1042        .iter()
1043        .find(|connector| connector.connector_id == request.connector_id)
1044    else {
1045        return Err(ConnectorInvokeFailure {
1046            failure_class: "unbound",
1047            resolved_version: None,
1048            code: CONNECTOR_INVOKE_ERR_UNBOUND,
1049        });
1050    };
1051    let compatible = context
1052        .declared_requirements
1053        .iter()
1054        .filter(|requirement| requirement.connector_id == request.connector_id)
1055        .any(|requirement| {
1056            VersionReq::parse(&requirement.version)
1057                .ok()
1058                .zip(Version::parse(&connector.version).ok())
1059                .is_some_and(|(range, version)| range.matches(&version))
1060        });
1061    if !compatible {
1062        return Err(ConnectorInvokeFailure {
1063            failure_class: "incompatible",
1064            resolved_version: Some(connector.version.clone()),
1065            code: CONNECTOR_INVOKE_ERR_UNAUTHORIZED,
1066        });
1067    }
1068    Ok(connector.clone())
1069}
1070
1071fn invoke_activated_connector(
1072    caller: &mut Caller<'_, WasmStoreState>,
1073    memory: wasmtime::Memory,
1074    request: ConnectorInvokeRequest,
1075    _request_ptr: usize,
1076    response_ptr: usize,
1077    response_capacity: usize,
1078    connector: &ActivatedConnector,
1079) -> i32 {
1080    let Ok(response) = connector.implementation.invoke(&request) else {
1081        return connector_failure(
1082            caller,
1083            &request.connector_id,
1084            Some(&connector.version),
1085            "execution_failed",
1086            CONNECTOR_INVOKE_ERR_EXECUTION_FAILED,
1087        );
1088    };
1089    if response.abi_version != SUPPORTED_HOST_ABI_VERSION
1090        || response.result_class.is_empty()
1091        || contains_host_private_data(&response.payload)
1092    {
1093        return connector_failure(
1094            caller,
1095            &request.connector_id,
1096            Some(&connector.version),
1097            "unauthorized_output",
1098            CONNECTOR_INVOKE_ERR_UNAUTHORIZED,
1099        );
1100    }
1101    #[allow(clippy::expect_used)]
1102    let response_bytes =
1103        serde_json::to_vec(&response).expect("connector response uses serializable JSON value");
1104    if response_bytes.len() > response_capacity
1105        || response_bytes.len() > MAX_CONNECTOR_INVOKE_RESPONSE_BYTES
1106    {
1107        return connector_failure(
1108            caller,
1109            &request.connector_id,
1110            Some(&connector.version),
1111            "bounded_io",
1112            CONNECTOR_INVOKE_ERR_PAYLOAD_TOO_LARGE,
1113        );
1114    }
1115    if memory
1116        .write(&mut *caller, response_ptr, &response_bytes)
1117        .is_err()
1118    {
1119        return connector_failure(
1120            caller,
1121            &request.connector_id,
1122            Some(&connector.version),
1123            "invalid_response_memory",
1124            CONNECTOR_INVOKE_ERR_INVALID_REQUEST,
1125        );
1126    }
1127    caller
1128        .data_mut()
1129        .connector_invocation_evidence
1130        .push(ConnectorInvocationEvidence {
1131            connector_id: request.connector_id,
1132            resolved_version: Some(connector.version.clone()),
1133            result_class: response.result_class,
1134            failure_class: None,
1135        });
1136    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1137    {
1138        response_bytes.len() as i32
1139    }
1140}
1141
1142fn connector_failure(
1143    caller: &mut Caller<'_, WasmStoreState>,
1144    connector_id: &str,
1145    resolved_version: Option<&str>,
1146    failure_class: &str,
1147    code: i32,
1148) -> i32 {
1149    caller
1150        .data_mut()
1151        .connector_invocation_evidence
1152        .push(ConnectorInvocationEvidence {
1153            connector_id: connector_id.to_string(),
1154            resolved_version: resolved_version.map(str::to_string),
1155            result_class: "failure".to_string(),
1156            failure_class: Some(failure_class.to_string()),
1157        });
1158    code
1159}
1160
1161fn contains_host_private_data(value: &Value) -> bool {
1162    match value {
1163        Value::Object(map) => map.iter().any(|(key, value)| {
1164            let key = key.to_ascii_lowercase();
1165            [
1166                "config",
1167                "credential",
1168                "secret",
1169                "password",
1170                "path",
1171                "device",
1172                "endpoint",
1173                "bucket",
1174            ]
1175            .iter()
1176            .any(|needle| key.contains(needle))
1177                || contains_host_private_data(value)
1178        }),
1179        Value::Array(values) => values.iter().any(contains_host_private_data),
1180        Value::String(value) => value.starts_with('/') || value.contains("://"),
1181        _ => false,
1182    }
1183}
1184
1185fn classify_wasm_execution_error(error: &wasmtime::Error) -> ExecutorError {
1186    let display = error.to_string();
1187    let debug = format!("{error:?}");
1188    if display.contains("all fuel consumed by WebAssembly")
1189        || debug.contains("all fuel consumed by WebAssembly")
1190    {
1191        return ExecutorError::Timeout(debug);
1192    }
1193    if display.contains("forcing trap when growing") || debug.contains("forcing trap when growing")
1194    {
1195        return ExecutorError::ResourceExhausted(debug);
1196    }
1197    ExecutorError::ExecutionFailed(display)
1198}
1199
1200fn sha256_hex(data: &[u8]) -> String {
1201    let mut hasher = Sha256::new();
1202    hasher.update(data);
1203    hasher
1204        .finalize()
1205        .iter()
1206        .fold(String::new(), |mut acc, byte| {
1207            let _ = write!(acc, "{byte:02x}");
1208            acc
1209        })
1210}
1211
1212fn validate_module_imports(
1213    module: &Module,
1214    abi_version: &str,
1215) -> Result<HostAbiValidation, ExecutorError> {
1216    let whitelist = host_abi_whitelist(abi_version)?;
1217    let mut imports = module
1218        .imports()
1219        .map(|import| HostAbiImport {
1220            module: import.module().to_string(),
1221            name: import.name().to_string(),
1222        })
1223        .collect::<Vec<_>>();
1224    imports.sort_by(|a, b| a.module.cmp(&b.module).then_with(|| a.name.cmp(&b.name)));
1225
1226    for import in &imports {
1227        if !whitelist
1228            .imports
1229            .iter()
1230            .any(|allowed| allowed.module == import.module && allowed.name == import.name)
1231        {
1232            return Err(ExecutorError::UnauthorizedHostImport {
1233                error_code: "unauthorized_host_import".to_string(),
1234                abi_version: abi_version.to_string(),
1235                module: import.module.clone(),
1236                name: import.name.clone(),
1237            });
1238        }
1239    }
1240
1241    Ok(HostAbiValidation {
1242        abi_version: whitelist.abi_version,
1243        imports,
1244    })
1245}
1246
1247fn host_abi_whitelist(abi_version: &str) -> Result<HostAbiWhitelist, ExecutorError> {
1248    if abi_version != SUPPORTED_HOST_ABI_VERSION {
1249        return Err(ExecutorError::UnsupportedAbiVersion {
1250            error_code: "unsupported_abi_version".to_string(),
1251            requested: abi_version.to_string(),
1252            supported: supported_host_abi_versions().join(", "),
1253        });
1254    }
1255
1256    HOST_ABI_V1_WHITELIST_CACHE
1257        .as_ref()
1258        .cloned()
1259        .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("invalid ABI whitelist: {e}")))
1260}
1261
1262#[cfg(test)]
1263mod binary_cache_tests {
1264    use super::*;
1265
1266    #[test]
1267    fn binary_file_identity_preserves_modified_time_failures() {
1268        let result = binary_file_identity(
1269            "unreadable-metadata.wasm",
1270            0,
1271            Err(std::io::Error::other("modified time unavailable")),
1272        );
1273
1274        assert!(matches!(result, Err(ExecutorError::BinaryLoadFailed(_))));
1275    }
1276}