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 }
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}