cardinal_wasm_plugins/
lib.rs

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