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#[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#[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#[derive(Clone, Debug, Eq, PartialEq, Hash)]
35pub struct ModuleId(pub Arc<str>);
36
37#[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#[derive(Debug, Clone)]
50pub struct ContextEntry {
51 pub path: String,
52 pub value: ContextValue,
53}
54
55#[derive(Debug, Clone)]
59pub struct Header {
60 pub name: String,
61 pub value: String,
62}
63
64#[derive(Debug, Clone)]
66pub struct BytesView {
67 pub data: Vec<u8>,
68 pub truncated: bool,
69}
70
71pub struct L4PeekInput {
73 pub peek: Vec<u8>,
74 pub context: Vec<ContextEntry>,
75}
76
77#[derive(Debug)]
79pub enum L4PeekDecision {
80 Continue,
81 Close,
82}
83
84pub struct L4BytesInput {
86 pub bytes: BytesView,
87 pub context: Vec<ContextEntry>,
88}
89
90#[derive(Debug)]
92pub enum L4BytesDecision {
93 Continue,
94 Tunnel,
95 Close,
96}
97
98pub 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#[derive(Debug, Clone)]
109pub struct SynthResponse {
110 pub status: u16,
111 pub headers: Vec<Header>,
112 pub body: Vec<u8>,
113}
114
115#[derive(Debug)]
117pub enum L7RequestDecision {
118 Continue,
119 Short(SynthResponse),
120 Close,
121}
122
123pub struct L7ResponseInput {
125 pub status: u16,
126 pub headers: Vec<Header>,
127 pub body: Option<BytesView>,
128 pub context: Vec<ContextEntry>,
129}
130
131#[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#[derive(Debug)]
141pub enum L7ResponseDecision {
142 Continue,
143 Modify(ModifiedResponse),
144 Abort,
145}
146
147#[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#[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 #[must_use]
191 pub fn trap(message: impl Into<String>) -> Self {
192 Self::Trap(PluginTrap::new(message))
193 }
194}
195
196#[async_trait]
203pub trait WasmRuntime: Send + Sync {
204 async fn load_component(&self, path: &Path) -> Result<Arc<PluginMetadata>, Error>;
210
211 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct WasmPoolSummary {
261 pub kind: String,
264 pub key: String,
267 pub export: String,
269 pub capacity: usize,
272 pub available: usize,
276 pub total_allocations: u64,
279 pub failures: u64,
282}
283
284pub trait WasmPoolStats: Send + Sync {
290 fn snapshot(&self) -> Vec<WasmPoolSummary>;
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
309pub struct PluginHttpPolicy {
310 #[serde(default)]
314 pub allow_insecure: bool,
315 #[serde(default)]
321 pub allowed_hosts: Vec<String>,
322 #[serde(default = "default_max_body_size")]
326 pub max_body_size: u32,
327 #[serde(default = "default_timeout_ms")]
330 pub default_timeout_ms: u32,
331 #[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#[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 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 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 #[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}