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 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 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
808fn handle_emit_event(mut caller: Caller<'_, WasmStoreState>, ptr: i32, len: i32) -> i32 {
816 if caller.data().service_type != ServiceType::Subscribable {
819 return EMIT_EVENT_ERR_NOT_SUBSCRIBABLE;
820 }
821
822 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 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 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
897fn 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}