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        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            .map_err(|error| classify_wasm_execution_error(&error))?;
707
708        // Extract captured stdout — contents() reads the buffer without consuming it
709        let raw_output = stdout_ref.contents();
710
711        let value = serde_json::from_slice::<Value>(&raw_output).map_err(|e| {
712            ExecutorError::OutputDeserializationFailed(format!(
713                "stdout is not valid JSON: {e} — raw: {}",
714                String::from_utf8_lossy(&raw_output)
715            ))
716        })?;
717
718        let data = store.into_data();
719        Ok(ExecutorOutput {
720            value,
721            emitted_events: data.emitted_events,
722            connector_invocation_evidence: data.connector_invocation_evidence,
723        })
724    }
725
726    fn store_limits(&self) -> StoreLimits {
727        StoreLimitsBuilder::new()
728            .memory_size(self.limits.memory_bytes)
729            .table_elements(self.limits.table_elements)
730            .instances(self.limits.instances)
731            .tables(self.limits.tables)
732            .memories(self.limits.memories)
733            .trap_on_grow_failure(true)
734            .build()
735    }
736
737    fn compiled_module(
738        &self,
739        wasm_bytes: &[u8],
740        checksum: &str,
741        abi_version: &str,
742    ) -> Result<CachedModule, ExecutorError> {
743        {
744            let mut cache = self
745                .module_cache
746                .lock()
747                .unwrap_or_else(std::sync::PoisonError::into_inner);
748            if let Some(cached) = cache.get(checksum, abi_version) {
749                return Ok(cached);
750            }
751        }
752
753        let module = Module::from_binary(&self.engine, wasm_bytes).map_err(|e| {
754            ExecutorError::MalformedWasmArtifact {
755                error_code: "malformed_wasm_artifact".to_string(),
756                detail: format!("module compile: {e}"),
757            }
758        })?;
759        let validation = validate_module_imports(&module, abi_version)?;
760        let cached = CachedModule { module, validation };
761
762        let mut cache = self
763            .module_cache
764            .lock()
765            .unwrap_or_else(std::sync::PoisonError::into_inner);
766        cache.insert(checksum.to_string(), cached.clone());
767        Ok(cached)
768    }
769
770    fn load_binary(&self, wasm_path: &str) -> Result<CachedBinary, ExecutorError> {
771        let metadata = fs::metadata(wasm_path).map_err(|e| {
772            ExecutorError::BinaryLoadFailed(format!("cannot read {wasm_path}: {e}"))
773        })?;
774        let identity = binary_file_identity(wasm_path, metadata.len(), metadata.modified())?;
775
776        let mut cache = self
777            .binary_cache
778            .lock()
779            .unwrap_or_else(std::sync::PoisonError::into_inner);
780        if let Some(cached) = cache.get(wasm_path, &identity) {
781            return Ok(cached);
782        }
783
784        let bytes: Arc<[u8]> = fs::read(wasm_path)
785            .map_err(|e| ExecutorError::BinaryLoadFailed(format!("cannot read {wasm_path}: {e}")))?
786            .into();
787        let cached = CachedBinary {
788            identity,
789            checksum: sha256_hex(&bytes),
790            bytes,
791        };
792        cache.record_load();
793        cache.insert(wasm_path.to_string(), cached.clone());
794        Ok(cached)
795    }
796}
797
798fn binary_file_identity(
799    wasm_path: &str,
800    len: u64,
801    modified: Result<SystemTime, std::io::Error>,
802) -> Result<BinaryFileIdentity, ExecutorError> {
803    let modified = modified
804        .map_err(|e| ExecutorError::BinaryLoadFailed(format!("cannot read {wasm_path}: {e}")))?;
805    Ok(BinaryFileIdentity { len, modified })
806}
807
808/// Host implementation of `traverse_host::emit_event` (spec
809/// 098-capability-event-host-abi FR-001). The guest passes a pointer/length
810/// into its own linear memory holding a JSON payload shaped
811/// `{"event_id": "...", "version": "...", "payload": {...}}`; this function
812/// validates it synchronously, at call time, and never panics or traps on a
813/// malformed or out-of-bounds guest pointer (FR-008) — every failure path
814/// returns a negative status code to the guest instead.
815fn handle_emit_event(mut caller: Caller<'_, WasmStoreState>, ptr: i32, len: i32) -> i32 {
816    // FR-003: checked before any guest memory is touched — rejected
817    // regardless of payload.
818    if caller.data().service_type != ServiceType::Subscribable {
819        return EMIT_EVENT_ERR_NOT_SUBSCRIBABLE;
820    }
821
822    // FR-008: bounds/size checked before any read or deserialization.
823    if ptr < 0 || len < 0 {
824        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
825    }
826    #[allow(clippy::cast_sign_loss)]
827    let (ptr, len) = (ptr as usize, len as usize);
828    if len > MAX_EVENT_EMIT_PAYLOAD_BYTES {
829        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
830    }
831
832    let Some(Extern::Memory(memory)) = caller.get_export("memory") else {
833        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
834    };
835
836    let mut buffer = vec![0u8; len];
837    // `Memory::read` bounds-checks `ptr + len` against actual guest memory
838    // size and returns `Err` rather than panicking or reading out of bounds.
839    if memory.read(&caller, ptr, &mut buffer).is_err() {
840        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
841    }
842
843    let Ok(payload) = serde_json::from_slice::<Value>(&buffer) else {
844        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
845    };
846    let Some(event_type) = payload
847        .get("event_id")
848        .and_then(Value::as_str)
849        .map(str::to_string)
850    else {
851        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
852    };
853    let Some(version) = payload
854        .get("version")
855        .and_then(Value::as_str)
856        .map(str::to_string)
857    else {
858        return EMIT_EVENT_ERR_INVALID_PAYLOAD;
859    };
860    let data = payload
861        .get("payload")
862        .cloned()
863        .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
864
865    // FR-002: declared-emission check, synchronous, at call time.
866    let declared = caller
867        .data()
868        .emits
869        .iter()
870        .any(|decl| decl.event_id == event_type && decl.version == version);
871    if !declared {
872        return EMIT_EVENT_ERR_UNDECLARED_EVENT;
873    }
874
875    let capability_id = caller.data().capability_id.clone();
876    let event = TraverseEvent {
877        id: Uuid::new_v4().to_string(),
878        source: format!("traverse-runtime/{capability_id}"),
879        event_type: event_type.clone(),
880        datacontenttype: "application/json".to_string(),
881        time: Utc::now().to_rfc3339(),
882        data,
883        owner: capability_id.clone(),
884        version: version.clone(),
885        lifecycle_status: LifecycleStatus::Active,
886        deduplication_id: Some(format!("{capability_id}:{event_type}:{version}")),
887        ordering_scope: Some(capability_id),
888        correlation_id: None,
889        causation_id: None,
890        subject_id: None,
891        actor_id: None,
892    };
893    caller.data_mut().emitted_events.push(event);
894    EMIT_EVENT_OK
895}
896
897/// Fail closed until an embedding host supplies an active application binding.
898///
899/// The four integers are the versioned ABI's request pointer/length and
900/// response pointer/capacity. This default handler intentionally neither reads
901/// nor writes guest memory: no request data, host configuration, credentials,
902/// paths, or endpoint can cross the boundary before authorization exists.
903fn handle_connector_invoke(
904    mut caller: Caller<'_, WasmStoreState>,
905    request_ptr: i32,
906    request_len: i32,
907    response_ptr: i32,
908    response_capacity: i32,
909) -> i32 {
910    let Ok((memory, request, request_ptr, response_ptr, response_capacity)) =
911        parse_connector_request(
912            &mut caller,
913            request_ptr,
914            request_len,
915            response_ptr,
916            response_capacity,
917        )
918    else {
919        return CONNECTOR_INVOKE_ERR_INVALID_REQUEST;
920    };
921    let Some(context) = caller.data().connector_context.clone() else {
922        return connector_failure(
923            &mut caller,
924            &request.connector_id,
925            None,
926            "unbound",
927            CONNECTOR_INVOKE_ERR_UNBOUND,
928        );
929    };
930    let connector = match resolve_activated_connector(&context, &request) {
931        Ok(connector) => connector,
932        Err(failure) => {
933            return connector_failure(
934                &mut caller,
935                &request.connector_id,
936                failure.resolved_version.as_deref(),
937                failure.failure_class,
938                failure.code,
939            );
940        }
941    };
942    invoke_activated_connector(
943        &mut caller,
944        memory,
945        request,
946        request_ptr,
947        response_ptr,
948        response_capacity,
949        &connector,
950    )
951}
952
953fn parse_connector_request(
954    caller: &mut Caller<'_, WasmStoreState>,
955    request_ptr: i32,
956    request_len: i32,
957    response_ptr: i32,
958    response_capacity: i32,
959) -> Result<
960    (
961        wasmtime::Memory,
962        ConnectorInvokeRequest,
963        usize,
964        usize,
965        usize,
966    ),
967    (),
968> {
969    if request_ptr < 0 || request_len < 0 || response_ptr < 0 || response_capacity < 0 {
970        return Err(());
971    }
972    #[allow(clippy::cast_sign_loss)]
973    let (request_ptr, request_len, response_ptr, response_capacity) = (
974        request_ptr as usize,
975        request_len as usize,
976        response_ptr as usize,
977        response_capacity as usize,
978    );
979    if request_len > MAX_CONNECTOR_INVOKE_REQUEST_BYTES
980        || response_capacity > MAX_CONNECTOR_INVOKE_RESPONSE_BYTES
981    {
982        return Err(());
983    }
984    let Some(Extern::Memory(memory)) = caller.get_export("memory") else {
985        return Err(());
986    };
987    let mut bytes = vec![0_u8; request_len];
988    if memory.read(&caller, request_ptr, &mut bytes).is_err() {
989        return Err(());
990    }
991    let Ok(request) = serde_json::from_slice::<ConnectorInvokeRequest>(&bytes) else {
992        return Err(());
993    };
994    if request.abi_version != SUPPORTED_HOST_ABI_VERSION
995        || request.connector_id.is_empty()
996        || request.operation.is_empty()
997        || contains_host_private_data(&request.payload)
998    {
999        return Err(());
1000    }
1001    Ok((
1002        memory,
1003        request,
1004        request_ptr,
1005        response_ptr,
1006        response_capacity,
1007    ))
1008}
1009
1010struct ConnectorInvokeFailure {
1011    failure_class: &'static str,
1012    resolved_version: Option<String>,
1013    code: i32,
1014}
1015
1016fn resolve_activated_connector(
1017    context: &MediatedConnectorContext,
1018    request: &ConnectorInvokeRequest,
1019) -> Result<ActivatedConnector, ConnectorInvokeFailure> {
1020    if !context
1021        .declared_requirements
1022        .iter()
1023        .any(|requirement| requirement.connector_id == request.connector_id)
1024    {
1025        return Err(ConnectorInvokeFailure {
1026            failure_class: "undeclared",
1027            resolved_version: None,
1028            code: CONNECTOR_INVOKE_ERR_UNDECLARED,
1029        });
1030    }
1031    let Some(connector) = context
1032        .activated_connectors
1033        .iter()
1034        .find(|connector| connector.connector_id == request.connector_id)
1035    else {
1036        return Err(ConnectorInvokeFailure {
1037            failure_class: "unbound",
1038            resolved_version: None,
1039            code: CONNECTOR_INVOKE_ERR_UNBOUND,
1040        });
1041    };
1042    let compatible = context
1043        .declared_requirements
1044        .iter()
1045        .filter(|requirement| requirement.connector_id == request.connector_id)
1046        .any(|requirement| {
1047            VersionReq::parse(&requirement.version)
1048                .ok()
1049                .zip(Version::parse(&connector.version).ok())
1050                .is_some_and(|(range, version)| range.matches(&version))
1051        });
1052    if !compatible {
1053        return Err(ConnectorInvokeFailure {
1054            failure_class: "incompatible",
1055            resolved_version: Some(connector.version.clone()),
1056            code: CONNECTOR_INVOKE_ERR_UNAUTHORIZED,
1057        });
1058    }
1059    Ok(connector.clone())
1060}
1061
1062fn invoke_activated_connector(
1063    caller: &mut Caller<'_, WasmStoreState>,
1064    memory: wasmtime::Memory,
1065    request: ConnectorInvokeRequest,
1066    _request_ptr: usize,
1067    response_ptr: usize,
1068    response_capacity: usize,
1069    connector: &ActivatedConnector,
1070) -> i32 {
1071    let Ok(response) = connector.implementation.invoke(&request) else {
1072        return connector_failure(
1073            caller,
1074            &request.connector_id,
1075            Some(&connector.version),
1076            "execution_failed",
1077            CONNECTOR_INVOKE_ERR_EXECUTION_FAILED,
1078        );
1079    };
1080    if response.abi_version != SUPPORTED_HOST_ABI_VERSION
1081        || response.result_class.is_empty()
1082        || contains_host_private_data(&response.payload)
1083    {
1084        return connector_failure(
1085            caller,
1086            &request.connector_id,
1087            Some(&connector.version),
1088            "unauthorized_output",
1089            CONNECTOR_INVOKE_ERR_UNAUTHORIZED,
1090        );
1091    }
1092    #[allow(clippy::expect_used)]
1093    let response_bytes =
1094        serde_json::to_vec(&response).expect("connector response uses serializable JSON value");
1095    if response_bytes.len() > response_capacity
1096        || response_bytes.len() > MAX_CONNECTOR_INVOKE_RESPONSE_BYTES
1097    {
1098        return connector_failure(
1099            caller,
1100            &request.connector_id,
1101            Some(&connector.version),
1102            "bounded_io",
1103            CONNECTOR_INVOKE_ERR_PAYLOAD_TOO_LARGE,
1104        );
1105    }
1106    if memory
1107        .write(&mut *caller, response_ptr, &response_bytes)
1108        .is_err()
1109    {
1110        return connector_failure(
1111            caller,
1112            &request.connector_id,
1113            Some(&connector.version),
1114            "invalid_response_memory",
1115            CONNECTOR_INVOKE_ERR_INVALID_REQUEST,
1116        );
1117    }
1118    caller
1119        .data_mut()
1120        .connector_invocation_evidence
1121        .push(ConnectorInvocationEvidence {
1122            connector_id: request.connector_id,
1123            resolved_version: Some(connector.version.clone()),
1124            result_class: response.result_class,
1125            failure_class: None,
1126        });
1127    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1128    {
1129        response_bytes.len() as i32
1130    }
1131}
1132
1133fn connector_failure(
1134    caller: &mut Caller<'_, WasmStoreState>,
1135    connector_id: &str,
1136    resolved_version: Option<&str>,
1137    failure_class: &str,
1138    code: i32,
1139) -> i32 {
1140    caller
1141        .data_mut()
1142        .connector_invocation_evidence
1143        .push(ConnectorInvocationEvidence {
1144            connector_id: connector_id.to_string(),
1145            resolved_version: resolved_version.map(str::to_string),
1146            result_class: "failure".to_string(),
1147            failure_class: Some(failure_class.to_string()),
1148        });
1149    code
1150}
1151
1152fn contains_host_private_data(value: &Value) -> bool {
1153    match value {
1154        Value::Object(map) => map.iter().any(|(key, value)| {
1155            let key = key.to_ascii_lowercase();
1156            [
1157                "config",
1158                "credential",
1159                "secret",
1160                "password",
1161                "path",
1162                "device",
1163                "endpoint",
1164                "bucket",
1165            ]
1166            .iter()
1167            .any(|needle| key.contains(needle))
1168                || contains_host_private_data(value)
1169        }),
1170        Value::Array(values) => values.iter().any(contains_host_private_data),
1171        Value::String(value) => value.starts_with('/') || value.contains("://"),
1172        _ => false,
1173    }
1174}
1175
1176fn classify_wasm_execution_error(error: &wasmtime::Error) -> ExecutorError {
1177    let display = error.to_string();
1178    let debug = format!("{error:?}");
1179    if display.contains("all fuel consumed by WebAssembly")
1180        || debug.contains("all fuel consumed by WebAssembly")
1181    {
1182        return ExecutorError::Timeout(debug);
1183    }
1184    if display.contains("forcing trap when growing") || debug.contains("forcing trap when growing")
1185    {
1186        return ExecutorError::ResourceExhausted(debug);
1187    }
1188    ExecutorError::ExecutionFailed(display)
1189}
1190
1191fn sha256_hex(data: &[u8]) -> String {
1192    let mut hasher = Sha256::new();
1193    hasher.update(data);
1194    hasher
1195        .finalize()
1196        .iter()
1197        .fold(String::new(), |mut acc, byte| {
1198            let _ = write!(acc, "{byte:02x}");
1199            acc
1200        })
1201}
1202
1203fn validate_module_imports(
1204    module: &Module,
1205    abi_version: &str,
1206) -> Result<HostAbiValidation, ExecutorError> {
1207    let whitelist = host_abi_whitelist(abi_version)?;
1208    let mut imports = module
1209        .imports()
1210        .map(|import| HostAbiImport {
1211            module: import.module().to_string(),
1212            name: import.name().to_string(),
1213        })
1214        .collect::<Vec<_>>();
1215    imports.sort_by(|a, b| a.module.cmp(&b.module).then_with(|| a.name.cmp(&b.name)));
1216
1217    for import in &imports {
1218        if !whitelist
1219            .imports
1220            .iter()
1221            .any(|allowed| allowed.module == import.module && allowed.name == import.name)
1222        {
1223            return Err(ExecutorError::UnauthorizedHostImport {
1224                error_code: "unauthorized_host_import".to_string(),
1225                abi_version: abi_version.to_string(),
1226                module: import.module.clone(),
1227                name: import.name.clone(),
1228            });
1229        }
1230    }
1231
1232    Ok(HostAbiValidation {
1233        abi_version: whitelist.abi_version,
1234        imports,
1235    })
1236}
1237
1238fn host_abi_whitelist(abi_version: &str) -> Result<HostAbiWhitelist, ExecutorError> {
1239    if abi_version != SUPPORTED_HOST_ABI_VERSION {
1240        return Err(ExecutorError::UnsupportedAbiVersion {
1241            error_code: "unsupported_abi_version".to_string(),
1242            requested: abi_version.to_string(),
1243            supported: supported_host_abi_versions().join(", "),
1244        });
1245    }
1246
1247    HOST_ABI_V1_WHITELIST_CACHE
1248        .as_ref()
1249        .cloned()
1250        .map_err(|e| ExecutorError::RuntimeSetupFailed(format!("invalid ABI whitelist: {e}")))
1251}
1252
1253#[cfg(test)]
1254mod binary_cache_tests {
1255    use super::*;
1256
1257    #[test]
1258    fn binary_file_identity_preserves_modified_time_failures() {
1259        let result = binary_file_identity(
1260            "unreadable-metadata.wasm",
1261            0,
1262            Err(std::io::Error::other("modified time unavailable")),
1263        );
1264
1265        assert!(matches!(result, Err(ExecutorError::BinaryLoadFailed(_))));
1266    }
1267}