Skip to main content

vane_core/
wasm_runtime.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5
6use crate::error::Error;
7use crate::middleware::MiddlewareKind;
8
9/// Metadata for a single exported middleware within a WASM component.
10///
11/// Populated from `registry.get-metadata()` at component load time.
12#[derive(Debug, Clone)]
13pub struct PluginExport {
14	pub name: String,
15	pub kind: MiddlewareKind,
16	pub stateless: bool,
17	pub needs_body: bool,
18	pub inspects: Vec<String>,
19}
20
21/// Cached result of `registry.get-metadata()` for one WASM component.
22#[derive(Debug)]
23pub struct PluginMetadata {
24	pub name: String,
25	pub version: String,
26	pub abi_version: String,
27	pub exports: Vec<PluginExport>,
28}
29
30/// Stable identity for a loaded WASM component.
31///
32/// Per `spec/wasm-abi.md` § _Module identity and reload_: the canonical absolute
33/// filesystem path of the `.wasm` file.
34#[derive(Clone, Debug, Eq, PartialEq, Hash)]
35pub struct ModuleId(pub Arc<str>);
36
37/// Mirrors the WIT `context-value` variant from `vane:plugin/types@0.1.0`.
38#[derive(Debug, Clone)]
39pub enum ContextValue {
40	Text(String),
41	Bytes(Vec<u8>),
42	Int64(i64),
43	Uint64(u64),
44	Boolean(bool),
45	ListText(Vec<String>),
46}
47
48/// Mirrors the WIT `context-entry` record from `vane:plugin/types@0.1.0`.
49#[derive(Debug, Clone)]
50pub struct ContextEntry {
51	pub path: String,
52	pub value: ContextValue,
53}
54
55/// Mirrors the WIT `header` record from `vane:plugin/types@0.1.0`.
56///
57/// Names are guaranteed ASCII-lowercase by the host before being passed to plugins.
58#[derive(Debug, Clone)]
59pub struct Header {
60	pub name: String,
61	pub value: String,
62}
63
64/// Mirrors the WIT `bytes-view` record from `vane:plugin/types@0.1.0`.
65#[derive(Debug, Clone)]
66pub struct BytesView {
67	pub data: Vec<u8>,
68	pub truncated: bool,
69}
70
71/// Mirrors the WIT `l4-peek-input` record from `vane:plugin/handler-l4-peek@0.1.0`.
72pub struct L4PeekInput {
73	pub peek: Vec<u8>,
74	pub context: Vec<ContextEntry>,
75}
76
77/// Mirrors the WIT `l4-peek-decision` variant from `vane:plugin/handler-l4-peek@0.1.0`.
78#[derive(Debug)]
79pub enum L4PeekDecision {
80	Continue,
81	Close,
82}
83
84/// Mirrors the WIT `l4-bytes-input` record from `vane:plugin/handler-l4-bytes@0.1.0`.
85pub struct L4BytesInput {
86	pub bytes: BytesView,
87	pub context: Vec<ContextEntry>,
88}
89
90/// Mirrors the WIT `l4-bytes-decision` variant from `vane:plugin/handler-l4-bytes@0.1.0`.
91#[derive(Debug)]
92pub enum L4BytesDecision {
93	Continue,
94	Tunnel,
95	Close,
96}
97
98/// Mirrors the WIT `l7-request-input` record from `vane:plugin/handler-l7-request@0.1.0`.
99pub struct L7RequestInput {
100	pub method: String,
101	pub uri: String,
102	pub headers: Vec<Header>,
103	pub body: Option<BytesView>,
104	pub context: Vec<ContextEntry>,
105}
106
107/// Mirrors the WIT `synth-response` record from `vane:plugin/handler-l7-request@0.1.0`.
108#[derive(Debug, Clone)]
109pub struct SynthResponse {
110	pub status: u16,
111	pub headers: Vec<Header>,
112	pub body: Vec<u8>,
113}
114
115/// Mirrors the WIT `l7-request-decision` variant from `vane:plugin/handler-l7-request@0.1.0`.
116#[derive(Debug)]
117pub enum L7RequestDecision {
118	Continue,
119	Short(SynthResponse),
120	Close,
121}
122
123/// Mirrors the WIT `l7-response-input` record from `vane:plugin/handler-l7-response@0.1.0`.
124pub struct L7ResponseInput {
125	pub status: u16,
126	pub headers: Vec<Header>,
127	pub body: Option<BytesView>,
128	pub context: Vec<ContextEntry>,
129}
130
131/// Mirrors the WIT `modified-response` record from `vane:plugin/handler-l7-response@0.1.0`.
132#[derive(Debug, Clone)]
133pub struct ModifiedResponse {
134	pub status: Option<u16>,
135	pub headers: Option<Vec<Header>>,
136	pub body: Option<Vec<u8>>,
137}
138
139/// Mirrors the WIT `l7-response-decision` variant from `vane:plugin/handler-l7-response@0.1.0`.
140#[derive(Debug)]
141pub enum L7ResponseDecision {
142	Continue,
143	Modify(ModifiedResponse),
144	Abort,
145}
146
147/// Structured error from a plugin invocation.
148///
149/// `Plugin` wraps an in-band WIT error returned by the guest.
150/// `Trap` indicates a guest trap or epoch timeout — its inner string
151/// carries the wasmtime trap message; the variant is `#[source]`-
152/// shaped via a thin wrapper below so `vane_core::Error::with_source`
153/// can attach the cause to the surrounding `Error::middleware`.
154/// `Exhausted` means all pooled instances are checked out.
155///
156/// `#[non_exhaustive]` so future plugin-runtime error classes (cpu
157/// budget, memory cap, deadline) can be added without breaking
158/// downstream match sites.
159#[derive(Debug, thiserror::Error)]
160#[non_exhaustive]
161pub enum PluginError {
162	#[error("plugin {code}: {message}")]
163	Plugin { code: String, message: String, on_error_hint: Option<String> },
164	#[error("plugin trap: {0}")]
165	Trap(#[source] PluginTrap),
166	#[error("plugin pool exhausted: no instance available")]
167	Exhausted,
168}
169
170/// Wasmtime trap details from a guest crash or epoch timeout. Wraps
171/// a `String` because `wasmtime::Trap` is not `Clone`; the engine
172/// stringifies the trap once at the call boundary so downstream
173/// observers see a stable, owned value.
174#[derive(Debug, thiserror::Error)]
175#[error("{0}")]
176pub struct PluginTrap(pub String);
177
178impl PluginTrap {
179	#[must_use]
180	pub fn new(message: impl Into<String>) -> Self {
181		Self(message.into())
182	}
183}
184
185impl PluginError {
186	/// Convenience constructor for the `Trap` variant. Matches the
187	/// prior `PluginError::Trap(String)` ergonomics so callers in
188	/// the engine / wasm crates don't have to know about the new
189	/// `PluginTrap` wrapper.
190	#[must_use]
191	pub fn trap(message: impl Into<String>) -> Self {
192		Self::Trap(PluginTrap::new(message))
193	}
194}
195
196/// Runtime contract between the executor and the WASM plugin layer.
197///
198/// Declared in `vane-core`; the concrete implementation lives in
199/// `vane-wasm` (`WasmtimeRuntime`). `vaned` constructs an
200/// `Arc<dyn WasmRuntime>` at startup and injects it into the engine
201/// before the first `FlowGraph` compilation that references WASM plugins.
202#[async_trait]
203pub trait WasmRuntime: Send + Sync {
204	/// Load a WASM component from disk, call `registry.get-metadata()`,
205	/// validate the result, and return the cached metadata.
206	///
207	/// The runtime may consult a `.cwasm` content-hash cache to skip
208	/// recompilation. Cache write failures are non-fatal (WARN log).
209	async fn load_component(&self, path: &Path) -> Result<Arc<PluginMetadata>, Error>;
210
211	/// Invoke the `l4-peek` handler exported by the named component.
212	///
213	/// `module_id` must previously have been loaded via `load_component`.
214	/// `export_name` selects which middleware export to call. `args_json`
215	/// is the per-call-site configuration string delivered to the plugin
216	/// via `host.get-args`. `input` carries the peek buffer and context.
217	///
218	/// Returns `PluginError::Trap` if the component has not been loaded.
219	async fn invoke_l4_peek(
220		&self,
221		module_id: &ModuleId,
222		export_name: &str,
223		args_json: &str,
224		input: L4PeekInput,
225	) -> Result<L4PeekDecision, PluginError>;
226
227	/// Invoke the `l4-bytes` handler exported by the named component.
228	async fn invoke_l4_bytes(
229		&self,
230		module_id: &ModuleId,
231		export_name: &str,
232		args_json: &str,
233		input: L4BytesInput,
234	) -> Result<L4BytesDecision, PluginError>;
235
236	/// Invoke the `l7-request` handler exported by the named component.
237	async fn invoke_l7_request(
238		&self,
239		module_id: &ModuleId,
240		export_name: &str,
241		args_json: &str,
242		input: L7RequestInput,
243	) -> Result<L7RequestDecision, PluginError>;
244
245	/// Invoke the `l7-response` handler exported by the named component.
246	async fn invoke_l7_response(
247		&self,
248		module_id: &ModuleId,
249		export_name: &str,
250		args_json: &str,
251		input: L7ResponseInput,
252	) -> Result<L7ResponseDecision, PluginError>;
253}
254
255/// One pool entry surfaced by [`WasmPoolStats::snapshot`]. Mirrors the
256/// shape `vane-wasm` produces internally; lives in `vane-core` so the
257/// daemon can consume the data via a trait object without depending on
258/// `vane-wasm` (which sits behind the optional `wasm` feature).
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct WasmPoolSummary {
261	/// `"stateful"` or `"stateless"`. Static-string in `vane-wasm`,
262	/// owned here so the trait object can return any backend's labels.
263	pub kind: String,
264	/// Module identity — typically the canonical absolute path of the
265	/// `.wasm` file (matches [`ModuleId`]).
266	pub key: String,
267	/// Export name within the component (e.g. `"l4-peek"`).
268	pub export: String,
269	/// Pre-warmed instance count for the pool. `0` when the pool has
270	/// no warm cache (e.g. on-demand stateless instantiation).
271	pub capacity: usize,
272	/// Currently checked-in instances. `capacity - available` is the
273	/// number in flight; the daemon translates that to `in_use` on the
274	/// wire.
275	pub available: usize,
276	/// Cumulative successful allocations (stateful checkouts +
277	/// stateless rentals).
278	pub total_allocations: u64,
279	/// Cumulative allocation failures (stateful exhaustion + stateless
280	/// instantiation errors).
281	pub failures: u64,
282}
283
284/// Read-only introspection of WASM pool runtime state. Implemented by
285/// `vane-wasm::WasmtimeRuntime`; held by the daemon as
286/// `Option<Arc<dyn WasmPoolStats>>` so builds without the optional
287/// `wasm` feature can still consume the trait surface and serve the
288/// `get_pools` mgmt verb (returning an empty list).
289pub trait WasmPoolStats: Send + Sync {
290	/// Snapshot every live pool. Read-only: must not instantiate
291	/// modules, build instances, or mutate runtime state. Returning
292	/// stale entries is acceptable — implementations may prune dead
293	/// weak refs as part of the snapshot.
294	fn snapshot(&self) -> Vec<WasmPoolSummary>;
295}
296
297/// Operator-owned per-plugin policy gating outbound `http-fetch`
298/// calls and bounding their body / timeout / redirect behaviour.
299///
300/// Plugin authors do not declare these fields — the WIT metadata
301/// only describes the plugin's exports. The daemon reads
302/// `<config_dir>/wasm/policy.json` (top-level keys = `.wasm` file
303/// stem) and constructs one [`PluginHttpPolicy`] per loaded module.
304/// Plugins missing from the config file get [`PluginHttpPolicy::default`]
305/// — an explicit deny-all posture (`allowed_hosts` empty) so an
306/// operator who hasn't reviewed a plugin's network surface can't
307/// be surprised by it reaching out.
308#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
309pub struct PluginHttpPolicy {
310	/// When `false` (default), `http-fetch` requests with
311	/// `verify_tls: false` short-circuit to `InsecureRejected` regardless
312	/// of the per-call value.
313	#[serde(default)]
314	pub allow_insecure: bool,
315	/// Allowed-host pattern list. Each entry is either a literal
316	/// hostname (`"api.internal"`), a wildcard prefix
317	/// (`"*.example.com"` matches `a.example.com` / `b.c.example.com`
318	/// but not `example.com`), or the universal wildcard `"*"`. An
319	/// empty list (the default) is deny-all.
320	#[serde(default)]
321	pub allowed_hosts: Vec<String>,
322	/// Per-request body cap (bytes) for the response body and the
323	/// outbound request body. Default 1 MiB matches
324	/// [`crate::fetch::HttpFetchLimits`]'s default `max_body_bytes`.
325	#[serde(default = "default_max_body_size")]
326	pub max_body_size: u32,
327	/// Default timeout when the per-call `timeout_ms` is `None`.
328	/// Default 30 s matches the spec's daemon default.
329	#[serde(default = "default_timeout_ms")]
330	pub default_timeout_ms: u32,
331	/// Default redirect follow cap when the per-call
332	/// `follow_redirects` is `None`. `0` disables redirects. Default 5.
333	#[serde(default = "default_follow_redirects")]
334	pub default_follow_redirects: u32,
335}
336
337const fn default_max_body_size() -> u32 {
338	1024 * 1024
339}
340
341const fn default_timeout_ms() -> u32 {
342	30_000
343}
344
345const fn default_follow_redirects() -> u32 {
346	5
347}
348
349impl Default for PluginHttpPolicy {
350	fn default() -> Self {
351		Self {
352			allow_insecure: false,
353			allowed_hosts: Vec::new(),
354			max_body_size: default_max_body_size(),
355			default_timeout_ms: default_timeout_ms(),
356			default_follow_redirects: default_follow_redirects(),
357		}
358	}
359}
360
361/// Operator-owned policy table keyed by `.wasm` file stem. Built at
362/// boot from `<config_dir>/wasm/policy.json` and looked up by the
363/// daemon's wasm loader when constructing per-plugin host state.
364#[derive(Debug, Clone, Default)]
365pub struct PluginPolicyTable {
366	pub policies: std::collections::HashMap<String, PluginHttpPolicy>,
367}
368
369impl PluginPolicyTable {
370	#[must_use]
371	pub fn new() -> Self {
372		Self { policies: std::collections::HashMap::new() }
373	}
374
375	/// Parse a `policy.json` whose top-level shape is
376	/// `{ "<stem>": { ...PluginHttpPolicy fields... } }`. Missing
377	/// fields per entry resolve to [`PluginHttpPolicy::default`]
378	/// values via serde defaults.
379	///
380	/// # Errors
381	/// Returns [`Error::compile`] when the JSON is malformed or any
382	/// entry fails to deserialize as [`PluginHttpPolicy`].
383	pub fn from_json(s: &str) -> Result<Self, Error> {
384		let policies: std::collections::HashMap<String, PluginHttpPolicy> =
385			serde_json::from_str(s).map_err(|e| Error::compile(format!("wasm/policy.json: {e}")))?;
386		Ok(Self { policies })
387	}
388
389	/// Load `<wasm_dir>/policy.json` into a [`PluginPolicyTable`].
390	/// Returns [`PluginPolicyTable::default`] (empty table) when the
391	/// file is absent. Surfaces parse errors verbatim.
392	///
393	/// # Errors
394	/// Returns [`Error::compile`] when the file exists but cannot be
395	/// read or parsed.
396	pub fn load_from_dir(wasm_dir: &std::path::Path) -> Result<Self, Error> {
397		let path = wasm_dir.join("policy.json");
398		match std::fs::read_to_string(&path) {
399			Ok(s) => Self::from_json(&s),
400			Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
401			Err(e) => Err(Error::compile(format!("wasm/policy.json: read {}: {e}", path.display()))),
402		}
403	}
404
405	/// Get the policy for a plugin by file stem, or
406	/// [`PluginHttpPolicy::default`] when absent.
407	#[must_use]
408	pub fn get_or_default(&self, stem: &str) -> PluginHttpPolicy {
409		self.policies.get(stem).cloned().unwrap_or_default()
410	}
411}
412
413#[cfg(test)]
414mod policy_tests {
415	use super::*;
416
417	#[test]
418	fn default_policy_is_deny_all() {
419		let p = PluginHttpPolicy::default();
420		assert!(!p.allow_insecure);
421		assert!(p.allowed_hosts.is_empty(), "deny-all by default");
422		assert_eq!(p.max_body_size, 1024 * 1024);
423		assert_eq!(p.default_timeout_ms, 30_000);
424		assert_eq!(p.default_follow_redirects, 5);
425	}
426
427	#[test]
428	fn policy_table_round_trips_explicit_fields() {
429		let json = r#"{
430			"edge": {
431				"allow_insecure": true,
432				"allowed_hosts": ["api.internal", "*.example.com"],
433				"max_body_size": 65536,
434				"default_timeout_ms": 5000,
435				"default_follow_redirects": 0
436			}
437		}"#;
438		let t = PluginPolicyTable::from_json(json).expect("parse");
439		let p = t.get_or_default("edge");
440		assert!(p.allow_insecure);
441		assert_eq!(p.allowed_hosts, vec!["api.internal".to_string(), "*.example.com".to_string()]);
442		assert_eq!(p.max_body_size, 65_536);
443		assert_eq!(p.default_timeout_ms, 5000);
444		assert_eq!(p.default_follow_redirects, 0);
445	}
446
447	#[test]
448	fn policy_table_partial_entry_fills_defaults() {
449		let json = r#"{ "edge": { "allowed_hosts": ["x.y"] } }"#;
450		let t = PluginPolicyTable::from_json(json).expect("parse");
451		let p = t.get_or_default("edge");
452		assert_eq!(p.allowed_hosts, vec!["x.y".to_string()]);
453		assert_eq!(p.max_body_size, 1024 * 1024, "default fills");
454		assert_eq!(p.default_timeout_ms, 30_000);
455	}
456
457	#[test]
458	fn policy_table_missing_plugin_returns_deny_all_default() {
459		let t = PluginPolicyTable::from_json(r#"{ "other": {} }"#).expect("parse");
460		let p = t.get_or_default("missing");
461		assert_eq!(p, PluginHttpPolicy::default());
462	}
463
464	#[test]
465	fn policy_table_load_from_dir_handles_absent_file() {
466		let tmp = tempfile::tempdir().expect("tempdir");
467		let t = PluginPolicyTable::load_from_dir(tmp.path()).expect("absent ok");
468		assert!(t.policies.is_empty());
469	}
470
471	#[test]
472	fn policy_table_load_from_dir_parses_json() {
473		let tmp = tempfile::tempdir().expect("tempdir");
474		std::fs::write(tmp.path().join("policy.json"), r#"{ "x": { "allowed_hosts": ["*"] } }"#)
475			.expect("write");
476		let t = PluginPolicyTable::load_from_dir(tmp.path()).expect("parse");
477		assert_eq!(t.get_or_default("x").allowed_hosts, vec!["*".to_string()]);
478	}
479
480	#[test]
481	fn policy_table_load_from_dir_propagates_parse_errors() {
482		let tmp = tempfile::tempdir().expect("tempdir");
483		std::fs::write(tmp.path().join("policy.json"), "{ this is not json").expect("write");
484		let err = PluginPolicyTable::load_from_dir(tmp.path()).expect_err("must fail");
485		assert!(err.to_string().contains("policy.json"));
486	}
487}