1use 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
32pub 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
44const MAX_EVENT_EMIT_PAYLOAD_BYTES: usize = 64 * 1024;
48
49const EMIT_EVENT_OK: i32 = 0;
52const EMIT_EVENT_ERR_INVALID_PAYLOAD: i32 = -1;
57const EMIT_EVENT_ERR_UNDECLARED_EVENT: i32 = -2;
60const EMIT_EVENT_ERR_NOT_SUBSCRIBABLE: i32 = -3;
63
64const 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#[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#[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
94pub trait MediatedConnector: Send + Sync {
96 fn invoke(&self, request: &ConnectorInvokeRequest) -> Result<ConnectorInvokeResponse, String>;
101}
102
103#[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#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct HostAbiImport {
125 pub module: String,
127 pub name: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct HostAbiValidation {
134 pub abi_version: String,
136 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#[must_use]
154pub fn supported_host_abi_versions() -> &'static [&'static str] {
155 &[SUPPORTED_HOST_ABI_VERSION]
156}
157
158pub 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#[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 pub fn new() -> Result<Self, ExecutorError> {
196 Self::with_limits(WasmExecutionLimits::default())
197 }
198
199 pub fn with_limits(limits: WasmExecutionLimits) -> Result<Self, ExecutorError> {
205 Self::with_limits_and_cache_config(limits, WasmModuleCacheConfig::default())
206 }
207
208 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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct WasmExecutionLimits {
253 pub fuel_budget: u64,
255 pub memory_bytes: usize,
257 pub table_elements: usize,
259 pub instances: usize,
261 pub tables: usize,
263 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct WasmModuleCacheConfig {
283 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct WasmModuleCacheStats {
298 pub entries: usize,
300 pub hits: u64,
302 pub misses: u64,
304 pub evictions: u64,
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct WasmBinaryCacheStats {
311 pub entries: usize,
313 pub hits: u64,
315 pub loads: u64,
317 pub hashes: u64,
319 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 capability_id: String,
472 emits: Vec<EventReference>,
473 service_type: ServiceType,
474 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 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 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 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 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 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 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 let stdout_pipe = MemoryOutputPipe::new(65536);
658 let stdout_ref = stdout_pipe.clone();
659
660 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 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 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
817fn handle_emit_event(mut caller: Caller<'_, WasmStoreState>, ptr: i32, len: i32) -> i32 {
825 if caller.data().service_type != ServiceType::Subscribable {
828 return EMIT_EVENT_ERR_NOT_SUBSCRIBABLE;
829 }
830
831 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 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 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
906fn 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}