cardinal_wasm_plugins/
lib.rs

1use ::wasmer::Memory;
2use bytes::Bytes;
3use derive_builder::Builder;
4use enum_as_inner::EnumAsInner;
5use std::collections::HashMap;
6
7mod host;
8pub mod instance;
9pub mod plugin;
10pub mod runner;
11pub mod utils;
12
13pub mod wasmer {
14    pub use wasmer::*;
15}
16
17#[derive(Clone, Builder)]
18pub struct ExecutionRequest {
19    pub memory: Option<Memory>,
20    pub req_headers: HashMap<String, String>,
21    pub query: HashMap<String, Vec<String>>,
22    pub body: Option<Bytes>,
23}
24
25#[derive(Clone, Builder)]
26pub struct ExecutionResponse {
27    pub memory: Option<Memory>,
28    pub req_headers: HashMap<String, String>,
29    pub query: HashMap<String, Vec<String>>,
30    pub body: Option<Bytes>,
31
32    // Response fields
33    pub resp_headers: HashMap<String, String>,
34    pub status: i32,
35}
36
37/// Per-instance host context; we’ll extend this in the next step
38/// (headers map, status, etc.). For now it just carries `memory`.
39#[derive(Clone, EnumAsInner)]
40pub enum ExecutionContext {
41    Inbound(ExecutionRequest),
42    Outbound(ExecutionResponse),
43}
44
45impl ExecutionContext {
46    pub fn replace_memory(&mut self, memory: Memory) {
47        match self {
48            ExecutionContext::Inbound(ctx) => {
49                ctx.memory.replace(memory);
50            }
51            ExecutionContext::Outbound(ctx) => {
52                ctx.memory.replace(memory);
53            }
54        }
55    }
56
57    pub fn body(&self) -> &Option<Bytes> {
58        match self {
59            ExecutionContext::Inbound(inbound) => &inbound.body,
60            ExecutionContext::Outbound(outbound) => &outbound.body,
61        }
62    }
63
64    pub fn req_headers(&self) -> &HashMap<String, String> {
65        match self {
66            ExecutionContext::Inbound(inbound) => &inbound.req_headers,
67            ExecutionContext::Outbound(outbound) => &outbound.req_headers,
68        }
69    }
70
71    pub fn query(&self) -> &HashMap<String, Vec<String>> {
72        match self {
73            ExecutionContext::Inbound(inbound) => &inbound.query,
74            ExecutionContext::Outbound(outbound) => &outbound.query,
75        }
76    }
77
78    pub fn memory(&self) -> &Option<Memory> {
79        match self {
80            ExecutionContext::Inbound(inbound) => &inbound.memory,
81            ExecutionContext::Outbound(outbound) => &outbound.memory,
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::plugin::WasmPlugin;
90    use crate::runner::{ExecutionType, WasmRunner};
91    use bytes::Bytes;
92    use serde_json::Value;
93    use std::collections::HashMap;
94    use std::fs;
95    use std::path::{Path, PathBuf};
96
97    const CASE_ROOT: &str = "../../../tests/wasm-plugins";
98
99    #[test]
100    fn wasm_plugin_allow_sets_headers() {
101        run_wasm_case("allow", ExecutionType::Outbound);
102    }
103
104    #[test]
105    fn wasm_plugin_blocks_flagged_requests() {
106        run_wasm_case("block", ExecutionType::Outbound);
107    }
108
109    #[test]
110    fn wasm_plugin_requires_tenant() {
111        run_wasm_case("require-tenant", ExecutionType::Outbound);
112    }
113
114    #[test]
115    fn wasm_inbound_plugin_allows_when_header_present() {
116        run_wasm_case("inbound-allow", ExecutionType::Inbound);
117    }
118
119    #[test]
120    fn wasm_inbound_plugin_blocks_without_header() {
121        run_wasm_case("inbound-block", ExecutionType::Inbound);
122    }
123
124    fn run_wasm_case(name: &str, expected_type: ExecutionType) {
125        let case_dir = case_path(name);
126        let wasm_path = case_dir.join("plugin.wasm");
127        let incoming_path = case_dir.join("incoming_request.json");
128        let expected_path = case_dir.join("expected_response.json");
129
130        let wasm_plugin = WasmPlugin::from_path(&wasm_path)
131            .unwrap_or_else(|e| panic!("failed to load plugin {:?}: {}", wasm_path, e));
132
133        let incoming = load_json(&incoming_path);
134        let expected = load_json(&expected_path);
135
136        let expected = expected_response_from_value(&expected, name);
137        if !matches!(
138            (expected.execution_type, expected_type),
139            (ExecutionType::Inbound, ExecutionType::Inbound)
140                | (ExecutionType::Outbound, ExecutionType::Outbound)
141        ) {
142            panic!(
143                "fixture {} declares execution_type {:?} but test expected {:?}",
144                name, expected.execution_type, expected_type
145            );
146        }
147
148        let exec_ctx = execution_context_from_value(&incoming, expected.execution_type, name);
149
150        let runner = WasmRunner::new(&wasm_plugin, expected.execution_type, None);
151        let result = runner
152            .run(exec_ctx)
153            .unwrap_or_else(|e| panic!("plugin execution failed for {:?}: {}", wasm_path, e));
154
155        assert_eq!(
156            result.should_continue, expected.should_continue,
157            "decision mismatch for {}",
158            name
159        );
160        match expected.execution_type {
161            ExecutionType::Outbound => {
162                let outbound = result
163                    .execution_context
164                    .into_outbound()
165                    .unwrap_or_else(|_| panic!("expected outbound context for {}", name));
166
167                let expected_status = expected.status.unwrap_or_else(|| {
168                    panic!(
169                        "outbound fixture {} must define a status field in expected_response.json",
170                        name
171                    )
172                });
173                assert_eq!(
174                    outbound.status, expected_status,
175                    "status mismatch for {}",
176                    name
177                );
178
179                let actual_headers = lowercase_string_map(outbound.resp_headers.clone());
180                for (key, value) in expected.resp_headers.iter() {
181                    let actual = actual_headers
182                        .get(key)
183                        .unwrap_or_else(|| panic!("missing header `{}` for {}", key, name));
184                    assert_eq!(actual, value, "header `{}` mismatch for {}", key, name);
185                }
186            }
187            ExecutionType::Inbound => {
188                let inbound = result
189                    .execution_context
190                    .into_inbound()
191                    .unwrap_or_else(|_| panic!("expected inbound context for {}", name));
192
193                assert!(
194                    expected.status.is_none(),
195                    "inbound fixture {} should not define a status field",
196                    name
197                );
198
199                assert!(
200                    expected.resp_headers.is_empty(),
201                    "inbound fixture {} should not define resp_headers",
202                    name
203                );
204
205                // No header mutation assertions yet; inbound plugins under test only read state.
206                let _inbound = inbound;
207            }
208        }
209    }
210
211    fn case_path(name: &str) -> PathBuf {
212        Path::new(env!("CARGO_MANIFEST_DIR"))
213            .join(CASE_ROOT)
214            .join(name)
215    }
216
217    fn load_json(path: &Path) -> Value {
218        let data =
219            fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {:?}: {}", path, e));
220        serde_json::from_str(&data).unwrap_or_else(|e| panic!("failed to parse {:?}: {}", path, e))
221    }
222
223    fn execution_context_from_value(
224        value: &Value,
225        exec_type: ExecutionType,
226        scenario: &str,
227    ) -> ExecutionContext {
228        let req_headers = lowercase_string_map(json_string_map(value.get("req_headers")));
229        let query = lowercase_string_vec_map(json_string_vec_map(value.get("query")));
230        let body = value.get("body").and_then(body_from_value);
231
232        match exec_type {
233            ExecutionType::Inbound => ExecutionContext::Inbound(ExecutionRequest {
234                memory: None,
235                req_headers,
236                query,
237                body,
238            }),
239            ExecutionType::Outbound => {
240                let resp_headers = lowercase_string_map(json_string_map(value.get("resp_headers")));
241                let status = value
242                    .get("status")
243                    .and_then(Value::as_i64)
244                    .unwrap_or_else(|| {
245                        panic!(
246                            "outbound fixture {} must define a numeric status field",
247                            scenario
248                        )
249                    }) as i32;
250
251                ExecutionContext::Outbound(ExecutionResponse {
252                    memory: None,
253                    req_headers,
254                    query,
255                    body,
256                    resp_headers,
257                    status,
258                })
259            }
260        }
261    }
262
263    fn expected_response_from_value(value: &Value, scenario: &str) -> ExpectedResponse {
264        let should_continue = value
265            .get("should_continue")
266            .and_then(Value::as_bool)
267            .unwrap_or(false);
268        let execution_type = execution_type_from_value(value.get("execution_type"), scenario);
269        let status = value
270            .get("status")
271            .and_then(Value::as_i64)
272            .map(|s| s as i32);
273        let resp_headers = lowercase_string_map(json_string_map(value.get("resp_headers")));
274
275        ExpectedResponse {
276            should_continue,
277            status,
278            resp_headers,
279            execution_type,
280        }
281    }
282
283    fn json_string_map(value: Option<&Value>) -> HashMap<String, String> {
284        match value {
285            Some(Value::Object(map)) => map
286                .iter()
287                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
288                .collect(),
289            _ => HashMap::new(),
290        }
291    }
292
293    fn json_string_vec_map(value: Option<&Value>) -> HashMap<String, Vec<String>> {
294        match value {
295            Some(Value::Object(map)) => map
296                .iter()
297                .map(|(k, v)| (k.clone(), value_to_string_vec(v)))
298                .collect(),
299            _ => HashMap::new(),
300        }
301    }
302
303    fn value_to_string_vec(value: &Value) -> Vec<String> {
304        match value {
305            Value::String(s) => vec![s.to_string()],
306            Value::Array(arr) => arr
307                .iter()
308                .filter_map(|v| v.as_str().map(|s| s.to_string()))
309                .collect(),
310            _ => Vec::new(),
311        }
312    }
313
314    fn lowercase_string_map(map: HashMap<String, String>) -> HashMap<String, String> {
315        map.into_iter()
316            .map(|(k, v)| (k.to_ascii_lowercase(), v))
317            .collect()
318    }
319
320    fn lowercase_string_vec_map(map: HashMap<String, Vec<String>>) -> HashMap<String, Vec<String>> {
321        map.into_iter()
322            .map(|(k, v)| (k.to_ascii_lowercase(), v))
323            .collect()
324    }
325
326    fn body_from_value(value: &Value) -> Option<Bytes> {
327        match value {
328            Value::Null => None,
329            Value::String(s) => Some(Bytes::from(s.clone())),
330            _ => None,
331        }
332    }
333
334    struct ExpectedResponse {
335        should_continue: bool,
336        status: Option<i32>,
337        resp_headers: HashMap<String, String>,
338        execution_type: ExecutionType,
339    }
340
341    fn execution_type_from_value(value: Option<&Value>, scenario: &str) -> ExecutionType {
342        let raw = value
343            .and_then(Value::as_str)
344            .unwrap_or_else(|| panic!("fixture {} must define execution_type", scenario));
345
346        match raw.to_ascii_lowercase().as_str() {
347            "inbound" => ExecutionType::Inbound,
348            "outbound" => ExecutionType::Outbound,
349            other => panic!(
350                "fixture {} has invalid execution_type '{}'; expected 'inbound' or 'outbound'",
351                scenario, other
352            ),
353        }
354    }
355}