1use std::collections::HashMap;
19
20use base64::engine::general_purpose::STANDARD as BASE64;
21use base64::Engine;
22use bytes::Bytes;
23use serde::Deserialize;
24
25use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
26use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
27
28#[derive(Debug, Clone, Deserialize)]
33#[serde(untagged)]
34pub enum StringOrList {
35 One(String),
36 Many(Vec<String>),
37}
38
39impl StringOrList {
40 fn into_vec(self) -> Vec<String> {
41 match self {
42 Self::One(s) => vec![s],
43 Self::Many(v) => v,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Default, Deserialize)]
51#[serde(default, deny_unknown_fields)]
52pub struct ResponseInput {
53 pub status_code: Option<u16>,
54 pub headers: HashMap<String, StringOrList>,
55 pub body: Option<String>,
56}
57
58#[derive(Debug, Clone, Default, Deserialize)]
65#[serde(default, deny_unknown_fields)]
66pub struct SandboxContextInput {
67 pub method: Option<String>,
68 pub path: Option<String>,
69 pub host: Option<String>,
70 pub scheme: Option<String>,
71 pub headers: HashMap<String, StringOrList>,
72 pub query_params: HashMap<String, StringOrList>,
73 pub body: Option<String>,
75 pub body_base64: Option<String>,
77 pub remote_addr: Option<String>,
78 pub protocol: Option<Protocol>,
79 pub message: HashMap<String, serde_json::Value>,
80 pub response: Option<ResponseInput>,
81}
82
83impl SandboxContextInput {
84 pub fn into_context(self) -> Result<Context, String> {
86 if self.body.is_some() && self.body_base64.is_some() {
87 return Err("provide only one of 'body' or 'body_base64'".to_string());
88 }
89 let body = match (self.body, self.body_base64) {
90 (Some(text), _) => Bytes::from(text.into_bytes()),
91 (None, Some(b64)) => Bytes::from(
92 BASE64
93 .decode(b64.as_bytes())
94 .map_err(|e| format!("body_base64 is not valid base64: {e}"))?,
95 ),
96 (None, None) => Bytes::new(),
97 };
98
99 let to_map = |m: HashMap<String, StringOrList>| -> HashMap<String, Vec<String>> {
100 m.into_iter().map(|(k, v)| (k, v.into_vec())).collect()
101 };
102
103 let response = self.response.unwrap_or_default();
104 Ok(Context {
105 request: GatewayRequest {
106 method: self.method.unwrap_or_else(|| "GET".to_string()),
107 path: self.path.unwrap_or_else(|| "/".to_string()),
108 host: self.host.unwrap_or_else(|| "sandbox.local".to_string()),
109 scheme: self.scheme.unwrap_or_else(|| "http".to_string()),
110 headers: to_map(self.headers),
111 query_params: to_map(self.query_params),
112 body,
113 remote_addr: self
114 .remote_addr
115 .unwrap_or_else(|| "127.0.0.1:0".to_string()),
116 protocol: self.protocol.unwrap_or(Protocol::Http1),
117 },
118 response: GatewayResponse {
119 status_code: response.status_code.unwrap_or(0),
120 headers: to_map(response.headers),
121 body: response
122 .body
123 .map(|b| Bytes::from(b.into_bytes()))
124 .unwrap_or_default(),
125 },
126 message: self.message,
127 errors: Vec::new(),
128 })
129 }
130}
131
132#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq)]
134#[serde(rename_all = "snake_case")]
135pub enum OnError {
136 #[default]
141 Stop,
142 Client,
145}
146
147#[derive(Debug, Clone, Default, Deserialize)]
155#[serde(default, deny_unknown_fields)]
156pub struct SandboxRequest {
157 pub nodes: Option<Vec<NodeConfig>>,
158 pub policy: Option<String>,
159 pub on_error: OnError,
160 pub context: serde_json::Value,
161}
162
163pub fn materialize_context(raw: serde_json::Value) -> Result<Context, String> {
177 let normalized = normalize_context(raw);
178 if normalized.is_null() {
179 return SandboxContextInput::default().into_context();
180 }
181 let input: SandboxContextInput =
182 serde_json::from_value(normalized).map_err(|e| format!("invalid sandbox context: {e}"))?;
183 input.into_context()
184}
185
186fn normalize_context(v: serde_json::Value) -> serde_json::Value {
189 use serde_json::Value;
190 let Value::Object(mut obj) = v else { return v };
191
192 if let Some(Value::Object(req)) = obj.remove("request") {
195 for (k, val) in req {
196 if k == "body" {
197 if let Some(text) = body_text(&val) {
198 obj.insert("body".to_string(), Value::String(text));
199 }
200 } else {
202 obj.insert(k, val);
203 }
204 }
205 }
206 if let Some(resp) = obj.get_mut("response").and_then(Value::as_object_mut) {
208 if let Some(body) = resp.get("body").cloned() {
209 match body_text(&body) {
210 Some(text) => {
211 resp.insert("body".to_string(), Value::String(text));
212 }
213 None => {
214 resp.remove("body");
215 }
216 }
217 }
218 }
219 obj.remove("errors");
221 Value::Object(obj)
222}
223
224fn body_text(v: &serde_json::Value) -> Option<String> {
227 match v {
228 serde_json::Value::String(s) => Some(s.clone()),
229 serde_json::Value::Object(o) => o.get("text").and_then(|t| t.as_str()).map(String::from),
230 _ => None,
231 }
232}
233
234pub fn synthesize_policy(
239 nodes: Vec<NodeConfig>,
240 on_error: OnError,
241) -> Result<PolicyConfig, String> {
242 if nodes.is_empty() {
243 return Err("'nodes' must contain at least one node".to_string());
244 }
245
246 let mut seen = std::collections::HashSet::new();
247 let mut user_nodes = Vec::with_capacity(nodes.len());
248 for (i, mut n) in nodes.into_iter().enumerate() {
249 if n.node_type == "listener" || n.node_type == "client" {
250 return Err(format!(
251 "node '{}': '{}' nodes are added automatically and cannot be supplied",
252 n.id, n.node_type
253 ));
254 }
255 if n.id.trim().is_empty() {
256 n.id = format!("{}-{}", n.node_type, i);
257 }
258 if !seen.insert(n.id.clone()) {
259 return Err(format!("duplicate node id '{}'", n.id));
260 }
261 user_nodes.push(n);
262 }
263
264 let mut edges = vec![EdgeConfig {
265 from: "listener.out".to_string(),
266 to: format!("{}.in", user_nodes[0].id),
267 }];
268 for pair in user_nodes.windows(2) {
269 edges.push(EdgeConfig {
270 from: format!("{}.success", pair[0].id),
271 to: format!("{}.in", pair[1].id),
272 });
273 }
274 edges.push(EdgeConfig {
275 from: format!("{}.success", user_nodes[user_nodes.len() - 1].id),
276 to: "client.in".to_string(),
277 });
278 if on_error == OnError::Client {
279 for n in &user_nodes {
280 edges.push(EdgeConfig {
281 from: format!("{}.error", n.id),
282 to: "client.in".to_string(),
283 });
284 }
285 }
286
287 let mut all = Vec::with_capacity(user_nodes.len() + 2);
288 all.push(NodeConfig {
289 id: "listener".to_string(),
290 node_type: "listener".to_string(),
291 config: HashMap::new(),
292 position: None,
293 });
294 all.extend(user_nodes);
295 all.push(NodeConfig {
296 id: "client".to_string(),
297 node_type: "client".to_string(),
298 config: HashMap::new(),
299 position: None,
300 });
301
302 Ok(PolicyConfig {
303 name: "__sandbox".to_string(),
304 error_handler: None,
305 nodes: all,
306 edges,
307 })
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn node(id: &str, node_type: &str) -> NodeConfig {
315 NodeConfig {
316 id: id.to_string(),
317 node_type: node_type.to_string(),
318 config: HashMap::new(),
319 position: None,
320 }
321 }
322
323 fn parse(json: serde_json::Value) -> Result<SandboxRequest, serde_json::Error> {
324 serde_json::from_value(json)
325 }
326
327 #[test]
330 fn test_empty_input_yields_sensible_defaults() {
331 let ctx = SandboxContextInput::default().into_context().unwrap();
332 assert_eq!(ctx.request.method, "GET");
333 assert_eq!(ctx.request.path, "/");
334 assert_eq!(ctx.request.host, "sandbox.local");
335 assert_eq!(ctx.request.scheme, "http");
336 assert_eq!(ctx.request.remote_addr, "127.0.0.1:0");
337 assert_eq!(ctx.request.protocol, Protocol::Http1);
338 assert!(ctx.request.body.is_empty());
339 assert_eq!(ctx.response.status_code, 0);
340 assert!(ctx.errors.is_empty());
341 }
342
343 #[test]
344 fn test_single_string_header_coerces_to_list() {
345 let req = parse(serde_json::json!({
346 "context": { "headers": { "apikey": "abc" }, "query_params": { "q": ["a", "b"] } }
347 }))
348 .unwrap();
349 let ctx = materialize_context(req.context).unwrap();
350 assert_eq!(ctx.request.headers["apikey"], vec!["abc"]);
351 assert_eq!(ctx.request.query_params["q"], vec!["a", "b"]);
352 }
353
354 #[test]
355 fn test_body_and_body_base64_are_mutually_exclusive() {
356 let input = SandboxContextInput {
357 body: Some("a".to_string()),
358 body_base64: Some("YQ==".to_string()),
359 ..Default::default()
360 };
361 assert!(input.into_context().is_err());
362 }
363
364 #[test]
365 fn test_body_base64_decoded() {
366 let input = SandboxContextInput {
367 body_base64: Some(BASE64.encode("binary")),
368 ..Default::default()
369 };
370 let ctx = input.into_context().unwrap();
371 assert_eq!(ctx.request.body, Bytes::from_static(b"binary"));
372 }
373
374 #[test]
375 fn test_response_seed_supports_response_phase_plugins() {
376 let req = parse(serde_json::json!({
377 "context": { "response": { "status_code": 200, "body": "hi", "headers": { "x": "1" } } }
378 }))
379 .unwrap();
380 let ctx = materialize_context(req.context).unwrap();
381 assert_eq!(ctx.response.status_code, 200);
382 assert_eq!(ctx.response.body, Bytes::from_static(b"hi"));
383 assert_eq!(ctx.response.headers["x"], vec!["1"]);
384 }
385
386 #[test]
390 fn test_unknown_context_field_is_rejected() {
391 let req = parse(serde_json::json!({ "context": { "paths": "/x" } })).unwrap();
392 assert!(materialize_context(req.context).is_err());
393 }
394
395 #[test]
398 fn test_accepts_trace_snapshot_shape() {
399 let snapshot = serde_json::json!({
400 "request": {
401 "method": "POST",
402 "path": "/api/items",
403 "host": "h",
404 "scheme": "http",
405 "headers": { "x-consumer": ["alice"] },
406 "query_params": { "page": ["2"] },
407 "body": { "len": 7, "text": "payload" }
408 },
409 "response": {
410 "status_code": 200,
411 "headers": { "x-powered-by": ["php"] },
412 "body": { "len": 2, "text": "ok" }
413 },
414 "message": { "user_id": "alice" },
415 "errors": [ { "node_id": "auth", "code": "X", "message": "y" } ]
416 });
417 let ctx = materialize_context(snapshot).unwrap();
418 assert_eq!(ctx.request.method, "POST");
419 assert_eq!(ctx.request.path, "/api/items");
420 assert_eq!(ctx.request.headers["x-consumer"], vec!["alice"]);
421 assert_eq!(ctx.request.query_params["page"], vec!["2"]);
422 assert_eq!(ctx.request.body, Bytes::from_static(b"payload"));
423 assert_eq!(ctx.response.status_code, 200);
424 assert_eq!(ctx.response.body, Bytes::from_static(b"ok"));
425 assert_eq!(ctx.message["user_id"], serde_json::json!("alice"));
426 assert!(ctx.errors.is_empty());
428 }
429
430 #[test]
433 fn test_snapshot_binary_or_uncaptured_body_becomes_empty() {
434 let snapshot = serde_json::json!({
435 "request": { "path": "/x", "body": { "len": 1024, "binary": true } }
436 });
437 let ctx = materialize_context(snapshot).unwrap();
438 assert_eq!(ctx.request.path, "/x");
439 assert!(ctx.request.body.is_empty());
440 }
441
442 #[test]
443 fn test_synthesized_policy_chains_nodes() {
444 let p = synthesize_policy(
445 vec![node("a", "cors"), node("b", "proxy-rewrite")],
446 OnError::Stop,
447 )
448 .unwrap();
449 let ids: Vec<&str> = p.nodes.iter().map(|n| n.id.as_str()).collect();
450 assert_eq!(ids, vec!["listener", "a", "b", "client"]);
451
452 let edges: Vec<String> = p
453 .edges
454 .iter()
455 .map(|e| format!("{}->{}", e.from, e.to))
456 .collect();
457 assert_eq!(
458 edges,
459 vec![
460 "listener.out->a.in",
461 "a.success->b.in",
462 "b.success->client.in"
463 ]
464 );
465 assert!(crate::graph::validate_policy(&p).is_ok());
467 }
468
469 #[test]
472 fn test_on_error_client_wires_error_edges() {
473 let p = synthesize_policy(vec![node("a", "key-auth")], OnError::Client).unwrap();
474 assert!(p
475 .edges
476 .iter()
477 .any(|e| e.from == "a.error" && e.to == "client.in"));
478
479 let stop = synthesize_policy(vec![node("a", "key-auth")], OnError::Stop).unwrap();
480 assert!(!stop.edges.iter().any(|e| e.from == "a.error"));
481 }
482
483 #[test]
484 fn test_missing_id_is_defaulted_from_type() {
485 let p = synthesize_policy(vec![node("", "cors")], OnError::Stop).unwrap();
486 assert_eq!(p.nodes[1].id, "cors-0");
487 }
488
489 #[test]
490 fn test_duplicate_ids_rejected() {
491 let err = synthesize_policy(vec![node("a", "cors"), node("a", "csrf")], OnError::Stop)
492 .unwrap_err();
493 assert!(err.contains("duplicate node id"), "got: {err}");
494 }
495
496 #[test]
497 fn test_reserved_node_types_rejected() {
498 for t in ["listener", "client"] {
499 let err = synthesize_policy(vec![node("x", t)], OnError::Stop).unwrap_err();
500 assert!(err.contains("added automatically"), "got: {err}");
501 }
502 }
503
504 #[test]
505 fn test_empty_node_list_rejected() {
506 assert!(synthesize_policy(Vec::new(), OnError::Stop).is_err());
507 }
508
509 #[test]
510 fn test_on_error_defaults_to_stop() {
511 let req = parse(serde_json::json!({ "policy": "p" })).unwrap();
512 assert_eq!(req.on_error, OnError::Stop);
513 }
514}