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 pub resp_headers: HashMap<String, String>,
34 pub status: i32,
35}
36
37#[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 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}