cardinal_wasm_plugins/
lib.rs

1mod context;
2pub mod host;
3pub mod instance;
4pub mod plugin;
5pub mod runner;
6pub mod utils;
7
8pub use context::{ExecutionContext, RequestState, ResponseState, SharedExecutionContext};
9
10pub mod wasmer {
11    pub use wasmer::*;
12}
13
14#[cfg(test)]
15mod tests {
16    use super::*;
17    use crate::plugin::WasmPlugin;
18    use crate::runner::{ExecutionPhase, WasmRunner};
19    use bytes::Bytes;
20    use http::HeaderMap;
21    use parking_lot::RwLock;
22    use serde_json::Value;
23    use std::collections::HashMap;
24    use std::fs;
25    use std::path::{Path, PathBuf};
26    use std::sync::Arc;
27
28    const CASE_ROOT: &str = "../../../tests/wasm-plugins";
29
30    #[test]
31    fn wasm_plugin_allow_sets_headers() {
32        run_wasm_case("allow", ScenarioKind::Response);
33    }
34
35    #[test]
36    fn wasm_plugin_blocks_flagged_requests() {
37        run_wasm_case("block", ScenarioKind::Response);
38    }
39
40    #[test]
41    fn wasm_plugin_requires_tenant() {
42        run_wasm_case("require-tenant", ScenarioKind::Response);
43    }
44
45    #[test]
46    fn wasm_inbound_plugin_allows_when_header_present() {
47        run_wasm_case("inbound-allow", ScenarioKind::Request);
48    }
49
50    #[test]
51    fn wasm_inbound_plugin_blocks_without_header() {
52        run_wasm_case("inbound-block", ScenarioKind::Request);
53    }
54
55    fn run_wasm_case(name: &str, expected_type: ScenarioKind) {
56        let case_dir = case_path(name);
57        let wasm_path = case_dir.join("plugin.wasm");
58        let incoming_path = case_dir.join("incoming_request.json");
59        let expected_path = case_dir.join("expected_response.json");
60
61        let wasm_plugin = Arc::new(
62            WasmPlugin::from_path(&wasm_path)
63                .unwrap_or_else(|e| panic!("failed to load plugin {:?}: {}", wasm_path, e)),
64        );
65
66        let incoming = load_json(&incoming_path);
67        let expected = load_json(&expected_path);
68
69        let expected = expected_response_from_value(&expected, name);
70        if expected.execution_type != expected_type {
71            panic!(
72                "fixture {} declares execution_type {:?} but test expected {:?}",
73                name, expected.execution_type, expected_type
74            );
75        }
76
77        let exec_ctx = execution_context_from_value(&incoming, expected.execution_type, name);
78
79        let runner = WasmRunner::new(
80            &wasm_plugin,
81            ExecutionPhase::from(expected.execution_type),
82            None,
83        );
84        let shared_ctx = Arc::new(RwLock::new(exec_ctx));
85        let result = runner
86            .run(shared_ctx.clone())
87            .unwrap_or_else(|e| panic!("plugin execution failed for {:?}: {}", wasm_path, e));
88
89        assert_eq!(
90            result.should_continue, expected.should_continue,
91            "decision mismatch for {}",
92            name
93        );
94        let context = result.execution_context.read();
95        match expected.execution_type {
96            ScenarioKind::Response => {
97                let response = context.response();
98                let expected_status = expected.status.unwrap_or_else(|| {
99                    panic!(
100                        "outbound fixture {} must define a status field in expected_response.json",
101                        name
102                    )
103                });
104                assert_eq!(
105                    i32::from(response.status()),
106                    expected_status,
107                    "status mismatch for {}",
108                    name
109                );
110
111                let actual_headers = lowercase_header_map(response.headers().clone());
112                for (key, value) in expected.resp_headers.iter() {
113                    let actual = actual_headers
114                        .get(key)
115                        .unwrap_or_else(|| panic!("missing header `{}` for {}", key, name));
116                    assert_eq!(actual, value, "header `{}` mismatch for {}", key, name);
117                }
118            }
119            ScenarioKind::Request => {
120                assert!(
121                    expected.status.is_none(),
122                    "inbound fixture {} should not define a status field",
123                    name
124                );
125
126                assert!(
127                    expected.resp_headers.is_empty(),
128                    "inbound fixture {} should not define resp_headers",
129                    name
130                );
131            }
132        }
133    }
134
135    fn case_path(name: &str) -> PathBuf {
136        Path::new(env!("CARGO_MANIFEST_DIR"))
137            .join(CASE_ROOT)
138            .join(name)
139    }
140
141    fn load_json(path: &Path) -> Value {
142        let data =
143            fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {:?}: {}", path, e));
144        serde_json::from_str(&data).unwrap_or_else(|e| panic!("failed to parse {:?}: {}", path, e))
145    }
146
147    fn execution_context_from_value(
148        value: &Value,
149        scenario_kind: ScenarioKind,
150        _scenario: &str,
151    ) -> ExecutionContext {
152        let req_headers = lowercase_string_map(json_string_map(value.get("req_headers")));
153        let query = lowercase_string_vec_map(json_string_vec_map(value.get("query")));
154        let body = value.get("body").and_then(body_from_value);
155
156        let response_state = match scenario_kind {
157            ScenarioKind::Request => ResponseState::from_hash_map(HashMap::new(), 403, false),
158            ScenarioKind::Response => ResponseState::from_hash_map(HashMap::new(), 0, false),
159        };
160
161        ExecutionContext::from_parts(
162            HeaderMap::try_from(&req_headers).unwrap(),
163            query,
164            body,
165            response_state,
166            Arc::new(RwLock::new(HashMap::new())),
167        )
168    }
169
170    fn expected_response_from_value(value: &Value, scenario: &str) -> ExpectedResponse {
171        let should_continue = value
172            .get("should_continue")
173            .and_then(Value::as_bool)
174            .unwrap_or(false);
175        let execution_type = execution_type_from_value(value.get("execution_type"), scenario);
176        let status = value
177            .get("status")
178            .and_then(Value::as_i64)
179            .map(|s| s as i32);
180        let resp_headers = lowercase_string_map(json_string_map(value.get("resp_headers")));
181
182        ExpectedResponse {
183            should_continue,
184            status,
185            resp_headers,
186            execution_type,
187        }
188    }
189
190    fn json_string_map(value: Option<&Value>) -> HashMap<String, String> {
191        match value {
192            Some(Value::Object(map)) => map
193                .iter()
194                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
195                .collect(),
196            _ => HashMap::new(),
197        }
198    }
199
200    fn json_string_vec_map(value: Option<&Value>) -> HashMap<String, Vec<String>> {
201        match value {
202            Some(Value::Object(map)) => map
203                .iter()
204                .map(|(k, v)| (k.clone(), value_to_string_vec(v)))
205                .collect(),
206            _ => HashMap::new(),
207        }
208    }
209
210    fn value_to_string_vec(value: &Value) -> Vec<String> {
211        match value {
212            Value::String(s) => vec![s.to_string()],
213            Value::Array(arr) => arr
214                .iter()
215                .filter_map(|v| v.as_str().map(|s| s.to_string()))
216                .collect(),
217            _ => Vec::new(),
218        }
219    }
220
221    fn lowercase_string_map(map: HashMap<String, String>) -> HashMap<String, String> {
222        map.into_iter()
223            .map(|(k, v)| (k.to_ascii_lowercase(), v))
224            .collect()
225    }
226
227    fn lowercase_header_map(map: HeaderMap) -> HashMap<String, String> {
228        let mut lowered = HashMap::new();
229        for (name, value) in map.iter() {
230            if let Ok(val) = value.to_str() {
231                lowered.insert(name.as_str().to_ascii_lowercase(), val.to_string());
232            }
233        }
234        lowered
235    }
236
237    fn response_state_from_value(value: &Value, default_status: u16) -> ResponseState {
238        let headers = lowercase_string_map(json_string_map(value.get("resp_headers")));
239        let override_status = value.get("status").and_then(Value::as_i64).and_then(|raw| {
240            if raw > 0 {
241                u16::try_from(raw).ok()
242            } else {
243                None
244            }
245        });
246
247        match override_status {
248            Some(status) => ResponseState::from_hash_map(headers, status, true),
249            None => ResponseState::from_hash_map(headers, default_status, false),
250        }
251    }
252
253    fn lowercase_string_vec_map(map: HashMap<String, Vec<String>>) -> HashMap<String, Vec<String>> {
254        map.into_iter()
255            .map(|(k, v)| (k.to_ascii_lowercase(), v))
256            .collect()
257    }
258
259    fn body_from_value(value: &Value) -> Option<Bytes> {
260        match value {
261            Value::Null => None,
262            Value::String(s) => Some(Bytes::from(s.clone())),
263            _ => None,
264        }
265    }
266
267    #[derive(Debug, Copy, Clone, PartialEq, Eq)]
268    enum ScenarioKind {
269        Request,
270        Response,
271    }
272
273    impl From<ScenarioKind> for ExecutionPhase {
274        fn from(value: ScenarioKind) -> Self {
275            match value {
276                ScenarioKind::Request => ExecutionPhase::Inbound,
277                ScenarioKind::Response => ExecutionPhase::Outbound,
278            }
279        }
280    }
281
282    struct ExpectedResponse {
283        should_continue: bool,
284        status: Option<i32>,
285        resp_headers: HashMap<String, String>,
286        execution_type: ScenarioKind,
287    }
288
289    fn execution_type_from_value(value: Option<&Value>, scenario: &str) -> ScenarioKind {
290        let raw = value
291            .and_then(Value::as_str)
292            .unwrap_or_else(|| panic!("fixture {} must define execution_type", scenario));
293
294        match raw.to_ascii_lowercase().as_str() {
295            "inbound" => ScenarioKind::Request,
296            "outbound" => ScenarioKind::Response,
297            other => panic!(
298                "fixture {} has invalid execution_type '{}'; expected 'inbound' or 'outbound'",
299                scenario, other
300            ),
301        }
302    }
303}