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}