1use std::{
5 collections::BTreeMap,
6 io::Read,
7 net::{IpAddr, SocketAddr},
8 path::PathBuf,
9 sync::{Arc, Mutex, OnceLock},
10 time::{Duration, Instant},
11};
12
13use async_trait::async_trait;
14use serde_json::Value;
15use soma_provider_core::{
16 Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
17};
18use tokio::time::timeout;
19use wasmtime::{
20 Cache, CacheConfig, Config, Engine, Instance, Module, Store, StoreLimits, StoreLimitsBuilder,
21 component::{Component, Linker},
22};
23
24#[cfg(test)]
25use crate::wasm_memory::public_ip as component_public_ip;
26use crate::{
27 broker_state::BrokerStateStore,
28 sidecar::execution_payload,
29 wasm_limits::WasmRuntimeLimits,
30 wasm_memory::{read_memory, typed, write_memory},
31};
32
33mod artifact;
34mod direct;
35mod host;
36mod runtime_support;
37use artifact::{artifact_digest, compile_artifact, read_artifact};
38pub use direct::{
39 PreparedComponentArtifact, invoke_authorized_component_artifact,
40 invoke_component_artifact_async, invoke_component_artifact_before_async,
41 invoke_prepared_component_artifact_before_async, prepare_component_artifact_before,
42 verify_component_artifact, verify_component_artifact_before,
43};
44use host::{component_diagnostic, component_linker};
45#[cfg(test)]
46use host::{component_state_get, component_state_put};
47use runtime_support::{
48 EpochTicker, WasmArtifactCache, acquire_execution, component_broker,
49 component_forbidden_header, component_metric, component_progress, component_remaining,
50 component_require_scope, resolve_component_hosts,
51};
52
53const MAX_WASM_ARTIFACT_BYTES: usize = 64 * 1024 * 1024;
54const DEFAULT_ARTIFACT_COMPILE_TIMEOUT_SECS: u64 = 30;
55const COMPONENTIZE_ARTIFACT_COMPILE_TIMEOUT_SECS: u64 = 600;
56const COMPONENTIZE_MARKER_NAME: &[u8] = b"soma.componentize-py.v1";
57const VERIFY_MAX_MEMORY_BYTES: usize = 64 * 1024 * 1024;
58const VERIFY_MAX_TABLE_ELEMENTS: usize = 10_000;
59const VERIFY_MAX_INSTANCES: usize = 16;
60const WASMTIME_CACHE_FILE_COUNT_SOFT_LIMIT: u64 = 256;
61const WASMTIME_CACHE_BYTES_SOFT_LIMIT: u64 = 2_147_483_648;
62
63pub fn mark_componentize_artifact(bytes: &mut Vec<u8>) -> Result<(), String> {
65 if bytes.len() < 8 || &bytes[..4] != b"\0asm" {
66 return Err("componentize artifact is not a WebAssembly binary".to_owned());
67 }
68 let marker = componentize_marker_section()?;
69 if bytes.ends_with(&marker) {
70 return Ok(());
71 }
72 if bytes.len().saturating_add(marker.len()) > MAX_WASM_ARTIFACT_BYTES {
73 return Err(format!(
74 "marked componentize artifact exceeds {MAX_WASM_ARTIFACT_BYTES} bytes"
75 ));
76 }
77 bytes.extend_from_slice(&marker);
78 Ok(())
79}
80
81#[must_use]
83pub fn is_componentize_artifact(bytes: &[u8]) -> bool {
84 componentize_marker_section()
85 .map(|marker| bytes.ends_with(&marker))
86 .unwrap_or(false)
87}
88
89fn artifact_compile_timeout(bytes: &[u8]) -> Duration {
90 let seconds = if is_componentize_artifact(bytes) {
91 COMPONENTIZE_ARTIFACT_COMPILE_TIMEOUT_SECS
92 } else {
93 DEFAULT_ARTIFACT_COMPILE_TIMEOUT_SECS
94 };
95 Duration::from_secs(seconds)
96}
97
98fn componentize_marker_section() -> Result<Vec<u8>, String> {
99 let name_len = u32::try_from(COMPONENTIZE_MARKER_NAME.len())
100 .map_err(|_| "componentize marker name is too large".to_owned())?;
101 let mut body = Vec::with_capacity(COMPONENTIZE_MARKER_NAME.len() + 5);
102 push_u32_leb(&mut body, name_len);
103 body.extend_from_slice(COMPONENTIZE_MARKER_NAME);
104 let body_len = u32::try_from(body.len())
105 .map_err(|_| "componentize marker section is too large".to_owned())?;
106 let mut section = Vec::with_capacity(body.len() + 6);
107 section.push(0);
108 push_u32_leb(&mut section, body_len);
109 section.extend_from_slice(&body);
110 Ok(section)
111}
112
113fn push_u32_leb(output: &mut Vec<u8>, mut value: u32) {
114 loop {
115 let mut byte = (value & 0x7f) as u8;
116 value >>= 7;
117 if value != 0 {
118 byte |= 0x80;
119 }
120 output.push(byte);
121 if value == 0 {
122 break;
123 }
124 }
125}
126
127#[derive(Clone)]
128pub struct WasmProvider {
129 path: PathBuf,
130 catalog: ProviderCatalog,
131 runtime: Result<PreparedWasmProvider, String>,
132}
133
134#[derive(Clone)]
135struct PreparedWasmProvider {
136 runtime: Arc<WasmRuntime>,
137 artifact: Arc<WasmArtifact>,
138 digest: String,
139 componentize: bool,
140}
141
142impl WasmProvider {
143 pub fn new(path: PathBuf, catalog: ProviderCatalog) -> Self {
144 let runtime = shared_wasm_runtime().and_then(|runtime| {
145 let bytes = read_artifact(&path)?;
146 let digest = artifact_digest(&bytes);
147 let componentize = is_componentize_artifact(&bytes);
148 let artifact =
149 runtime.artifact(&bytes, Instant::now() + artifact_compile_timeout(&bytes))?;
150 Ok(PreparedWasmProvider {
151 runtime,
152 artifact,
153 digest,
154 componentize,
155 })
156 });
157 Self {
158 path,
159 catalog,
160 runtime,
161 }
162 }
163
164 pub fn arc(path: PathBuf, catalog: ProviderCatalog) -> Arc<Self> {
165 Arc::new(Self::new(path, catalog))
166 }
167}
168
169#[async_trait]
170impl Provider for WasmProvider {
171 fn catalog(&self) -> ProviderCatalog {
172 self.catalog.clone()
173 }
174
175 async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
176 let tool = self.tool(&call)?.clone();
177 let provider = self.catalog.provider.name.clone();
178 let action = call.action.clone();
179 let source = self.path.display().to_string();
180 let prepared = self.runtime.clone().map_err(|error| {
181 ProviderError::execution(&provider, action.clone(), error)
182 .with_provider_kind("wasm")
183 .with_source(source.clone())
184 .with_phase("runtime-initialization")
185 })?;
186 let current_digest = read_artifact(&self.path)
187 .map(|bytes| artifact_digest(&bytes))
188 .map_err(|error| {
189 ProviderError::execution(&provider, action.clone(), error)
190 .with_provider_kind("wasm")
191 .with_source(source.clone())
192 .with_phase("artifact-verification")
193 })?;
194 if current_digest != prepared.digest {
195 return Err(ProviderError::execution(
196 &provider,
197 action.clone(),
198 "WASM artifact changed after provider activation",
199 )
200 .with_provider_kind("wasm")
201 .with_source(source)
202 .with_phase("artifact-verification"));
203 }
204 let capabilities = self.catalog.capabilities.clone();
205 let context = call.context.clone();
206 let input = execution_payload(&call).map_err(|error| {
207 ProviderError::execution(&provider, call.action.clone(), error)
208 .with_provider_kind("wasm")
209 .with_source(source.clone())
210 .with_phase("input-serialization")
211 })?;
212 let limits =
213 WasmRuntimeLimits::from_tool(&tool).with_componentize_minimums(prepared.componentize);
214 if input.len() > limits.max_input_bytes {
215 return Err(ProviderError::validation(
216 provider,
217 call.action,
218 "wasm_input_too_large",
219 format!("WASM input exceeds {} bytes", limits.max_input_bytes),
220 )
221 .with_provider_kind("wasm")
222 .with_source(source)
223 .with_phase("input-validation"));
224 }
225
226 let timeout_ms = limits.timeout_ms;
227 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
228 let resolved_hosts = resolve_component_hosts(&capabilities, deadline)
229 .await
230 .map_err(|error| {
231 ProviderError::execution(&provider, action.clone(), error)
232 .with_provider_kind("wasm")
233 .with_source(source.clone())
234 .with_phase("network-resolution")
235 })?;
236 let task = tokio::task::spawn_blocking(move || {
237 prepared.runtime.run_artifact(
238 prepared.artifact,
239 WasmInvocation {
240 input: &input,
241 limits,
242 capabilities: &capabilities,
243 context: &context,
244 resolved_hosts,
245 deadline,
246 },
247 )
248 });
249 let remaining = deadline
250 .checked_duration_since(Instant::now())
251 .unwrap_or(Duration::ZERO);
252 let task_result = timeout(remaining, task)
253 .await
254 .map_err(|_| {
255 ProviderError::new(
256 "wasm_provider_timeout",
257 &provider,
258 Some(action.clone()),
259 format!("WASM provider exceeded {timeout_ms}ms timeout"),
260 "Increase tool.limits.timeout_ms or fix the WASM provider.",
261 )
262 .with_provider_kind("wasm")
263 .with_source(source.clone())
264 .with_phase("execution")
265 })?
266 .map_err(|error| {
267 ProviderError::execution(&provider, action.clone(), error)
268 .with_provider_kind("wasm")
269 .with_source(source.clone())
270 .with_phase("execution")
271 })?;
272 if Instant::now() >= deadline {
273 return Err(ProviderError::new(
274 "wasm_provider_timeout",
275 &provider,
276 Some(action.clone()),
277 format!("WASM provider exceeded {timeout_ms}ms timeout"),
278 "Increase tool.limits.timeout_ms or fix the WASM provider.",
279 )
280 .with_provider_kind("wasm")
281 .with_source(source.clone())
282 .with_phase("execution"));
283 }
284 let output = task_result.map_err(|error| {
285 ProviderError::execution(&provider, action.clone(), error)
286 .with_provider_kind("wasm")
287 .with_source(source.clone())
288 .with_phase("execution")
289 })?;
290
291 let value = serde_json::from_slice(&output).map_err(|error| {
292 ProviderError::validation(
293 &provider,
294 &action,
295 "wasm_invalid_json_output",
296 error.to_string(),
297 )
298 .with_provider_kind("wasm")
299 .with_source(source)
300 .with_phase("output-validation")
301 })?;
302 Ok(ProviderOutput::json(value))
303 }
304
305 fn runtime_status(&self) -> Option<Value> {
306 Some(match &self.runtime {
307 Ok(prepared) => serde_json::json!({
308 "kind": "wasm",
309 "ready": true,
310 "artifact": self.path,
311 "artifact_sha256": prepared.digest,
312 }),
313 Err(error) => serde_json::json!({
314 "kind": "wasm",
315 "ready": false,
316 "artifact": self.path,
317 "error": error,
318 }),
319 })
320 }
321}
322
323impl WasmProvider {
324 fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
325 self.catalog
326 .tools
327 .iter()
328 .find(|tool| tool.name == call.action)
329 .ok_or_else(|| {
330 ProviderError::validation(
331 &self.catalog.provider.name,
332 &call.action,
333 "unknown_wasm_action",
334 format!("WASM provider has no action `{}`", call.action),
335 )
336 })
337 }
338}
339
340enum WasmArtifact {
341 Core(Module),
342 Component(Component),
343}
344
345pub(super) struct WasmStoreState {
346 limits: StoreLimits,
347 capabilities: soma_provider_core::HostCapabilities,
348 state: Result<Arc<BrokerStateStore>, String>,
349 context: soma_provider_core::ProviderInvocationContext,
350 resolved_hosts: BTreeMap<String, Vec<IpAddr>>,
351 deadline: Instant,
352 wasi: wasmtime_wasi::WasiCtx,
353 table: wasmtime::component::ResourceTable,
354}
355
356impl wasmtime_wasi::WasiView for WasmStoreState {
357 fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
358 wasmtime_wasi::WasiCtxView {
359 ctx: &mut self.wasi,
360 table: &mut self.table,
361 }
362 }
363}
364
365struct WasmRuntime {
366 engine: Engine,
367 cache: Mutex<WasmArtifactCache>,
368 state: Result<Arc<BrokerStateStore>, String>,
369 _ticker: EpochTicker,
370}
371
372struct WasmInvocation<'a> {
373 input: &'a [u8],
374 limits: WasmRuntimeLimits,
375 capabilities: &'a soma_provider_core::HostCapabilities,
376 context: &'a soma_provider_core::ProviderInvocationContext,
377 resolved_hosts: BTreeMap<String, Vec<IpAddr>>,
378 deadline: Instant,
379}
380
381fn shared_wasm_runtime() -> Result<Arc<WasmRuntime>, String> {
382 static RUNTIME: OnceLock<Result<Arc<WasmRuntime>, String>> = OnceLock::new();
383 RUNTIME
384 .get_or_init(|| WasmRuntime::new().map(Arc::new))
385 .clone()
386}
387
388fn wasmtime_cache_config() -> CacheConfig {
389 let mut config = CacheConfig::new();
390 config
391 .with_file_count_soft_limit(WASMTIME_CACHE_FILE_COUNT_SOFT_LIMIT)
392 .with_files_total_size_soft_limit(WASMTIME_CACHE_BYTES_SOFT_LIMIT)
393 .with_file_count_limit_percent_if_deleting(75)
394 .with_files_total_size_limit_percent_if_deleting(75);
395 config
396}
397
398fn wasmtime_cache() -> Result<Cache, String> {
399 Cache::new(wasmtime_cache_config())
400 .map_err(|error| format!("failed to initialize Wasmtime cache: {error}"))
401}
402
403impl WasmRuntime {
404 fn new() -> Result<Self, String> {
405 let mut config = Config::new();
406 config.consume_fuel(true);
407 config.epoch_interruption(true);
408 config.wasm_component_model(true);
409 config.cache(Some(wasmtime_cache()?));
410 let engine = Engine::new(&config).map_err(|error| error.to_string())?;
411 let ticker = EpochTicker::start(engine.clone())?;
412 Ok(Self {
413 engine,
414 cache: Mutex::new(WasmArtifactCache::default()),
415 state: BrokerStateStore::configured(),
416 _ticker: ticker,
417 })
418 }
419
420 fn artifact(&self, bytes: &[u8], deadline: Instant) -> Result<Arc<WasmArtifact>, String> {
421 let digest = artifact_digest(bytes);
422 let cell = self
423 .cache
424 .lock()
425 .map_err(|_| "WASM cache lock is poisoned".to_owned())?
426 .cell(digest, bytes.len().saturating_mul(8));
427 let result = cell.get_or_compile(deadline, || compile_artifact(self, bytes, deadline));
428 self.cache
429 .lock()
430 .map_err(|_| "WASM cache lock is poisoned".to_owned())?
431 .prune();
432 result
433 }
434
435 fn verify_prepared_component(
436 &self,
437 artifact: &Arc<WasmArtifact>,
438 componentize: bool,
439 deadline: Instant,
440 ) -> Result<(), String> {
441 let limits = WasmRuntimeLimits {
442 timeout_ms: 1_000,
443 max_input_bytes: 0,
444 max_output_bytes: 0,
445 fuel: 100_000,
446 max_memory_bytes: VERIFY_MAX_MEMORY_BYTES,
447 max_table_elements: VERIFY_MAX_TABLE_ELEMENTS,
448 max_instances: VERIFY_MAX_INSTANCES,
449 }
450 .with_componentize_minimums(componentize);
451 let _execution = acquire_execution(deadline, limits.max_memory_bytes)?;
452 let WasmArtifact::Component(component) = artifact.as_ref() else {
453 return Err("artifact is core Wasm, not a component".to_owned());
454 };
455 let mut store = self.store(
456 limits,
457 &soma_provider_core::HostCapabilities::default(),
458 &soma_provider_core::ProviderInvocationContext {
459 request_id: "component-verification".to_owned(),
460 actor_id: Some("soma-verifier".to_owned()),
461 actor_scopes: vec!["soma:read".to_owned()],
462 ..Default::default()
463 },
464 BTreeMap::new(),
465 deadline,
466 )?;
467 let linker = component_linker(&self.engine)?;
468 let instance = linker
469 .instantiate(&mut store, component)
470 .map_err(|error| error.to_string())?;
471 instance
472 .get_typed_func::<(String,), (Result<String, String>,)>(&mut store, "invoke")
473 .map(|_| ())
474 .map_err(|error| format!("component does not implement soma:provider@1.0.0: {error}"))
475 }
476
477 #[cfg(test)]
478 fn verify_component(&self, bytes: &[u8], deadline: Instant) -> Result<(), String> {
479 let componentize = is_componentize_artifact(bytes);
480 let artifact = self.artifact(bytes, deadline)?;
481 self.verify_prepared_component(&artifact, componentize, deadline)
482 }
483
484 #[cfg(test)]
485 fn run(
486 &self,
487 path: &std::path::Path,
488 invocation: WasmInvocation<'_>,
489 ) -> Result<Vec<u8>, String> {
490 let bytes = read_artifact(path)?;
491 let artifact = self.artifact(&bytes, invocation.deadline)?;
492 self.run_artifact(artifact, invocation)
493 }
494
495 fn run_artifact(
496 &self,
497 artifact: Arc<WasmArtifact>,
498 invocation: WasmInvocation<'_>,
499 ) -> Result<Vec<u8>, String> {
500 let WasmInvocation {
501 input,
502 limits,
503 capabilities,
504 context,
505 resolved_hosts,
506 deadline,
507 } = invocation;
508 let _execution = acquire_execution(deadline, limits.max_memory_bytes)?;
509 let mut store = self.store(limits, capabilities, context, resolved_hosts, deadline)?;
510 match artifact.as_ref() {
511 WasmArtifact::Core(module) => run_core_wasm(&mut store, module, input, limits),
512 WasmArtifact::Component(component) => {
513 run_component_wasm(&self.engine, &mut store, component, input, limits)
514 }
515 }
516 }
517
518 fn store(
519 &self,
520 limits: WasmRuntimeLimits,
521 capabilities: &soma_provider_core::HostCapabilities,
522 context: &soma_provider_core::ProviderInvocationContext,
523 resolved_hosts: BTreeMap<String, Vec<IpAddr>>,
524 deadline: Instant,
525 ) -> Result<Store<WasmStoreState>, String> {
526 let store_limits = StoreLimitsBuilder::new()
527 .memory_size(limits.max_memory_bytes)
528 .table_elements(limits.max_table_elements)
529 .instances(limits.max_instances)
530 .memories(4)
531 .tables(4)
532 .build();
533 let mut store = Store::new(
534 &self.engine,
535 WasmStoreState {
536 limits: store_limits,
537 capabilities: capabilities.clone(),
538 state: self.state.clone(),
539 context: context.clone(),
540 resolved_hosts,
541 deadline,
542 wasi: {
543 let mut builder = wasmtime_wasi::WasiCtxBuilder::new();
544 builder.allow_blocking_current_thread(true);
545 builder.build()
546 },
547 table: wasmtime::component::ResourceTable::new(),
548 },
549 );
550 store.limiter(|state| &mut state.limits);
551 store
552 .set_fuel(limits.fuel)
553 .map_err(|error| error.to_string())?;
554 let remaining_ms = deadline
555 .checked_duration_since(Instant::now())
556 .unwrap_or(Duration::ZERO)
557 .as_millis()
558 .min(u128::from(u64::MAX)) as u64;
559 store.set_epoch_deadline(remaining_ms.div_ceil(10).max(1));
560 store.epoch_deadline_trap();
561 Ok(store)
562 }
563}
564
565fn run_core_wasm(
566 store: &mut Store<WasmStoreState>,
567 module: &Module,
568 input: &[u8],
569 limits: WasmRuntimeLimits,
570) -> Result<Vec<u8>, String> {
571 let instance = Instance::new(&mut *store, module, &[]).map_err(|error| error.to_string())?;
572 let memory = instance
573 .get_memory(&mut *store, "memory")
574 .ok_or_else(|| "WASM provider must export memory".to_owned())?;
575 let input_alloc = typed::<i32, i32>(&instance, &mut *store, "soma_input_alloc")?;
576 let input_ptr_fn = typed::<(), i32>(&instance, &mut *store, "soma_input_ptr")?;
577 let call_fn = typed::<(), i32>(&instance, &mut *store, "soma_call")?;
578 let output_ptr_fn = typed::<(), i32>(&instance, &mut *store, "soma_output_ptr")?;
579 let output_len_fn = typed::<(), i32>(&instance, &mut *store, "soma_output_len")?;
580
581 let ptr = input_alloc
582 .call(&mut *store, input.len() as i32)
583 .map_err(|error| error.to_string())? as usize;
584 let input_ptr = input_ptr_fn
585 .call(&mut *store, ())
586 .map_err(|error| error.to_string())? as usize;
587 if ptr != input_ptr {
588 return Err("WASM provider input pointer mismatch".to_owned());
589 }
590 write_memory(&memory, &mut *store, ptr, input)?;
591 let status = call_fn
592 .call(&mut *store, ())
593 .map_err(|error| error.to_string())?;
594 if status != 0 {
595 return Err(format!("WASM provider returned non-zero status {status}"));
596 }
597 let output_ptr = output_ptr_fn
598 .call(&mut *store, ())
599 .map_err(|error| error.to_string())? as usize;
600 let output_len = output_len_fn
601 .call(&mut *store, ())
602 .map_err(|error| error.to_string())? as usize;
603 if output_len > limits.max_output_bytes {
604 return Err(format!(
605 "WASM provider output exceeds {} bytes",
606 limits.max_output_bytes
607 ));
608 }
609 read_memory(&memory, &mut *store, output_ptr, output_len)
610}
611
612fn run_component_wasm(
613 engine: &Engine,
614 store: &mut Store<WasmStoreState>,
615 component: &Component,
616 input: &[u8],
617 limits: WasmRuntimeLimits,
618) -> Result<Vec<u8>, String> {
619 let linker = component_linker(engine)?;
620 let instance = linker
621 .instantiate(&mut *store, component)
622 .map_err(|error| error.to_string())?;
623 let invoke = instance
624 .get_typed_func::<(String,), (Result<String, String>,)>(&mut *store, "invoke")
625 .map_err(|error| error.to_string())?;
626 let input = String::from_utf8(input.to_vec())
627 .map_err(|_| "component input must be UTF-8 JSON".to_owned())?;
628 let (result,) = invoke
629 .call(&mut *store, (input,))
630 .map_err(|error| format!("{error:#}"))?;
631 let output = result.map_err(|error| format!("component provider failed: {error}"))?;
632 if output.len() > limits.max_output_bytes {
633 return Err(format!(
634 "WASM provider output exceeds {} bytes",
635 limits.max_output_bytes
636 ));
637 }
638 Ok(output.into_bytes())
639}
640
641#[cfg(test)]
642#[path = "wasm_tests.rs"]
643mod tests;