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. `Trap` indicates a guest trap or
150/// epoch timeout. `Exhausted` means all pooled instances are checked out.
151#[derive(Debug)]
152pub enum PluginError {
153	Plugin { code: String, message: String, on_error_hint: Option<String> },
154	Trap(String),
155	Exhausted,
156}
157
158/// Runtime contract between the executor and the WASM plugin layer.
159///
160/// Declared in `vane-core`; the concrete implementation lives in
161/// `vane-wasm` (`WasmtimeRuntime`). `vaned` constructs an
162/// `Arc<dyn WasmRuntime>` at startup and injects it into the engine
163/// before the first `FlowGraph` compilation that references WASM plugins.
164#[async_trait]
165pub trait WasmRuntime: Send + Sync {
166	/// Load a WASM component from disk, call `registry.get-metadata()`,
167	/// validate the result, and return the cached metadata.
168	///
169	/// The runtime may consult a `.cwasm` content-hash cache to skip
170	/// recompilation. Cache write failures are non-fatal (WARN log).
171	async fn load_component(&self, path: &Path) -> Result<Arc<PluginMetadata>, Error>;
172
173	/// Invoke the `l4-peek` handler exported by the named component.
174	///
175	/// `module_id` must previously have been loaded via `load_component`.
176	/// `export_name` selects which middleware export to call. `args_json`
177	/// is the per-call-site configuration string delivered to the plugin
178	/// via `host.get-args`. `input` carries the peek buffer and context.
179	///
180	/// Returns `PluginError::Trap` if the component has not been loaded.
181	async fn invoke_l4_peek(
182		&self,
183		module_id: &ModuleId,
184		export_name: &str,
185		args_json: &str,
186		input: L4PeekInput,
187	) -> Result<L4PeekDecision, PluginError>;
188
189	/// Invoke the `l4-bytes` handler exported by the named component.
190	async fn invoke_l4_bytes(
191		&self,
192		module_id: &ModuleId,
193		export_name: &str,
194		args_json: &str,
195		input: L4BytesInput,
196	) -> Result<L4BytesDecision, PluginError>;
197
198	/// Invoke the `l7-request` handler exported by the named component.
199	async fn invoke_l7_request(
200		&self,
201		module_id: &ModuleId,
202		export_name: &str,
203		args_json: &str,
204		input: L7RequestInput,
205	) -> Result<L7RequestDecision, PluginError>;
206
207	/// Invoke the `l7-response` handler exported by the named component.
208	async fn invoke_l7_response(
209		&self,
210		module_id: &ModuleId,
211		export_name: &str,
212		args_json: &str,
213		input: L7ResponseInput,
214	) -> Result<L7ResponseDecision, PluginError>;
215}
216
217/// One pool entry surfaced by [`WasmPoolStats::snapshot`]. Mirrors the
218/// shape `vane-wasm` produces internally; lives in `vane-core` so the
219/// daemon can consume the data via a trait object without depending on
220/// `vane-wasm` (which sits behind the optional `wasm` feature).
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct WasmPoolSummary {
223	/// `"stateful"` or `"stateless"`. Static-string in `vane-wasm`,
224	/// owned here so the trait object can return any backend's labels.
225	pub kind: String,
226	/// Module identity — typically the canonical absolute path of the
227	/// `.wasm` file (matches [`ModuleId`]).
228	pub key: String,
229	/// Export name within the component (e.g. `"l4-peek"`).
230	pub export: String,
231	/// Pre-warmed instance count for the pool. `0` when the pool has
232	/// no warm cache (e.g. on-demand stateless instantiation).
233	pub capacity: usize,
234	/// Currently checked-in instances. `capacity - available` is the
235	/// number in flight; the daemon translates that to `in_use` on the
236	/// wire.
237	pub available: usize,
238	/// Cumulative successful allocations (stateful checkouts +
239	/// stateless rentals).
240	pub total_allocations: u64,
241	/// Cumulative allocation failures (stateful exhaustion + stateless
242	/// instantiation errors).
243	pub failures: u64,
244}
245
246/// Read-only introspection of WASM pool runtime state. Implemented by
247/// `vane-wasm::WasmtimeRuntime`; held by the daemon as
248/// `Option<Arc<dyn WasmPoolStats>>` so builds without the optional
249/// `wasm` feature can still consume the trait surface and serve the
250/// `get_pools` mgmt verb (returning an empty list).
251pub trait WasmPoolStats: Send + Sync {
252	/// Snapshot every live pool. Read-only: must not instantiate
253	/// modules, build instances, or mutate runtime state. Returning
254	/// stale entries is acceptable — implementations may prune dead
255	/// weak refs as part of the snapshot.
256	fn snapshot(&self) -> Vec<WasmPoolSummary>;
257}
258
259/// Operator-owned per-plugin policy gating outbound `http-fetch`
260/// calls and bounding their body / timeout / redirect behaviour.
261///
262/// Plugin authors do not declare these fields — the WIT metadata
263/// only describes the plugin's exports. The daemon reads
264/// `<config_dir>/wasm/policy.json` (top-level keys = `.wasm` file
265/// stem) and constructs one [`PluginHttpPolicy`] per loaded module.
266/// Plugins missing from the config file get [`PluginHttpPolicy::default`]
267/// — an explicit deny-all posture (`allowed_hosts` empty) so an
268/// operator who hasn't reviewed a plugin's network surface can't
269/// be surprised by it reaching out.
270#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
271pub struct PluginHttpPolicy {
272	/// When `false` (default), `http-fetch` requests with
273	/// `verify_tls: false` short-circuit to `InsecureRejected` regardless
274	/// of the per-call value.
275	#[serde(default)]
276	pub allow_insecure: bool,
277	/// Allowed-host pattern list. Each entry is either a literal
278	/// hostname (`"api.internal"`), a wildcard prefix
279	/// (`"*.example.com"` matches `a.example.com` / `b.c.example.com`
280	/// but not `example.com`), or the universal wildcard `"*"`. An
281	/// empty list (the default) is deny-all.
282	#[serde(default)]
283	pub allowed_hosts: Vec<String>,
284	/// Per-request body cap (bytes) for the response body and the
285	/// outbound request body. Default 1 MiB matches
286	/// [`crate::fetch::HttpFetchLimits`]'s default `max_body_bytes`.
287	#[serde(default = "default_max_body_size")]
288	pub max_body_size: u32,
289	/// Default timeout when the per-call `timeout_ms` is `None`.
290	/// Default 30 s matches the spec's daemon default.
291	#[serde(default = "default_timeout_ms")]
292	pub default_timeout_ms: u32,
293	/// Default redirect follow cap when the per-call
294	/// `follow_redirects` is `None`. `0` disables redirects. Default 5.
295	#[serde(default = "default_follow_redirects")]
296	pub default_follow_redirects: u32,
297}
298
299const fn default_max_body_size() -> u32 {
300	1024 * 1024
301}
302
303const fn default_timeout_ms() -> u32 {
304	30_000
305}
306
307const fn default_follow_redirects() -> u32 {
308	5
309}
310
311impl Default for PluginHttpPolicy {
312	fn default() -> Self {
313		Self {
314			allow_insecure: false,
315			allowed_hosts: Vec::new(),
316			max_body_size: default_max_body_size(),
317			default_timeout_ms: default_timeout_ms(),
318			default_follow_redirects: default_follow_redirects(),
319		}
320	}
321}
322
323/// Operator-owned policy table keyed by `.wasm` file stem. Built at
324/// boot from `<config_dir>/wasm/policy.json` and looked up by the
325/// daemon's wasm loader when constructing per-plugin host state.
326#[derive(Debug, Clone, Default)]
327pub struct PluginPolicyTable {
328	pub policies: std::collections::HashMap<String, PluginHttpPolicy>,
329}
330
331impl PluginPolicyTable {
332	#[must_use]
333	pub fn new() -> Self {
334		Self { policies: std::collections::HashMap::new() }
335	}
336
337	/// Parse a `policy.json` whose top-level shape is
338	/// `{ "<stem>": { ...PluginHttpPolicy fields... } }`. Missing
339	/// fields per entry resolve to [`PluginHttpPolicy::default`]
340	/// values via serde defaults.
341	///
342	/// # Errors
343	/// Returns [`Error::compile`] when the JSON is malformed or any
344	/// entry fails to deserialize as [`PluginHttpPolicy`].
345	pub fn from_json(s: &str) -> Result<Self, Error> {
346		let policies: std::collections::HashMap<String, PluginHttpPolicy> =
347			serde_json::from_str(s).map_err(|e| Error::compile(format!("wasm/policy.json: {e}")))?;
348		Ok(Self { policies })
349	}
350
351	/// Load `<wasm_dir>/policy.json` into a [`PluginPolicyTable`].
352	/// Returns [`PluginPolicyTable::default`] (empty table) when the
353	/// file is absent. Surfaces parse errors verbatim.
354	///
355	/// # Errors
356	/// Returns [`Error::compile`] when the file exists but cannot be
357	/// read or parsed.
358	pub fn load_from_dir(wasm_dir: &std::path::Path) -> Result<Self, Error> {
359		let path = wasm_dir.join("policy.json");
360		match std::fs::read_to_string(&path) {
361			Ok(s) => Self::from_json(&s),
362			Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
363			Err(e) => Err(Error::compile(format!("wasm/policy.json: read {}: {e}", path.display()))),
364		}
365	}
366
367	/// Get the policy for a plugin by file stem, or
368	/// [`PluginHttpPolicy::default`] when absent.
369	#[must_use]
370	pub fn get_or_default(&self, stem: &str) -> PluginHttpPolicy {
371		self.policies.get(stem).cloned().unwrap_or_default()
372	}
373}
374
375#[cfg(test)]
376mod policy_tests {
377	use super::*;
378
379	#[test]
380	fn default_policy_is_deny_all() {
381		let p = PluginHttpPolicy::default();
382		assert!(!p.allow_insecure);
383		assert!(p.allowed_hosts.is_empty(), "deny-all by default");
384		assert_eq!(p.max_body_size, 1024 * 1024);
385		assert_eq!(p.default_timeout_ms, 30_000);
386		assert_eq!(p.default_follow_redirects, 5);
387	}
388
389	#[test]
390	fn policy_table_round_trips_explicit_fields() {
391		let json = r#"{
392			"edge": {
393				"allow_insecure": true,
394				"allowed_hosts": ["api.internal", "*.example.com"],
395				"max_body_size": 65536,
396				"default_timeout_ms": 5000,
397				"default_follow_redirects": 0
398			}
399		}"#;
400		let t = PluginPolicyTable::from_json(json).expect("parse");
401		let p = t.get_or_default("edge");
402		assert!(p.allow_insecure);
403		assert_eq!(p.allowed_hosts, vec!["api.internal".to_string(), "*.example.com".to_string()]);
404		assert_eq!(p.max_body_size, 65_536);
405		assert_eq!(p.default_timeout_ms, 5000);
406		assert_eq!(p.default_follow_redirects, 0);
407	}
408
409	#[test]
410	fn policy_table_partial_entry_fills_defaults() {
411		let json = r#"{ "edge": { "allowed_hosts": ["x.y"] } }"#;
412		let t = PluginPolicyTable::from_json(json).expect("parse");
413		let p = t.get_or_default("edge");
414		assert_eq!(p.allowed_hosts, vec!["x.y".to_string()]);
415		assert_eq!(p.max_body_size, 1024 * 1024, "default fills");
416		assert_eq!(p.default_timeout_ms, 30_000);
417	}
418
419	#[test]
420	fn policy_table_missing_plugin_returns_deny_all_default() {
421		let t = PluginPolicyTable::from_json(r#"{ "other": {} }"#).expect("parse");
422		let p = t.get_or_default("missing");
423		assert_eq!(p, PluginHttpPolicy::default());
424	}
425
426	#[test]
427	fn policy_table_load_from_dir_handles_absent_file() {
428		let tmp = tempfile::tempdir().expect("tempdir");
429		let t = PluginPolicyTable::load_from_dir(tmp.path()).expect("absent ok");
430		assert!(t.policies.is_empty());
431	}
432
433	#[test]
434	fn policy_table_load_from_dir_parses_json() {
435		let tmp = tempfile::tempdir().expect("tempdir");
436		std::fs::write(tmp.path().join("policy.json"), r#"{ "x": { "allowed_hosts": ["*"] } }"#)
437			.expect("write");
438		let t = PluginPolicyTable::load_from_dir(tmp.path()).expect("parse");
439		assert_eq!(t.get_or_default("x").allowed_hosts, vec!["*".to_string()]);
440	}
441
442	#[test]
443	fn policy_table_load_from_dir_propagates_parse_errors() {
444		let tmp = tempfile::tempdir().expect("tempdir");
445		std::fs::write(tmp.path().join("policy.json"), "{ this is not json").expect("write");
446		let err = PluginPolicyTable::load_from_dir(tmp.path()).expect_err("must fail");
447		assert!(err.to_string().contains("policy.json"));
448	}
449}