Skip to main content

featherbit/debug/
sandbox.rs

1//! The plugin sandbox: run plugins against a synthetic [`Context`].
2//!
3//! Two modes, both producing the same [`Trace`](super::Trace) shape a live
4//! request produces — so the UI viewer, the diff, and the redaction tests are
5//! written once and serve both:
6//!
7//! - **ad-hoc nodes** — a list of plugin nodes run in order, for testing one
8//!   plugin (or a handful) in isolation;
9//! - **named policy** — a configured policy replayed against a synthetic
10//!   request, for testing a whole pipeline.
11//!
12//! Ad-hoc mode does **not** get its own executor. It synthesises a
13//! [`PolicyConfig`] — prepending a `listener` and appending a `client`, which
14//! [`validate_policy`](crate::graph::validate_policy) requires — and reuses
15//! [`compile_policy`](crate::graph::compile_policy). One executor means a
16//! sandbox run can never diverge from what the gateway really does.
17
18use 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/// A header/query value given as either a bare string or a list.
29///
30/// Ergonomics: `{"headers": {"apikey": "abc"}}` should work without forcing the
31/// caller to write `["abc"]` to satisfy the multi-valued representation.
32#[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/// Optional seed for `Context.response`, so response-phase plugins
49/// (`response-rewrite`, the loggers) have something to act on.
50#[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/// A synthetic context. Every field is optional: posting `{}` yields a valid
59/// `GET /` run.
60///
61/// `deny_unknown_fields` is deliberate here even though it is unusual — a
62/// typo'd `"paths"` must fail loudly rather than silently default to `/` and
63/// produce a baffling trace.
64#[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    /// Plain UTF-8 body. Mutually exclusive with `body_base64`.
74    pub body: Option<String>,
75    /// Base64 body, for binary payloads. Mutually exclusive with `body`.
76    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    /// Materialises a full [`Context`], filling in defaults.
85    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/// What to do when an ad-hoc node fails.
133#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq)]
134#[serde(rename_all = "snake_case")]
135pub enum OnError {
136    /// Leave error ports unwired, so a failing node hits the engine's
137    /// no-error-handling branch and the trace records `edge: "unhandled"`.
138    /// This makes the consequence of an unwired error port *visible* — the
139    /// single thing policy authors most often get wrong.
140    #[default]
141    Stop,
142    /// Wire every node's error port to `client`, preserving the plugin's own
143    /// status code (the wiring a real auth/redirect node needs).
144    Client,
145}
146
147/// A sandbox request: exactly one of `nodes` or `policy`.
148///
149/// `context` is taken as a raw value and normalised by [`materialize_context`],
150/// so it accepts **both** the flat hand-authored shape *and* a context copied
151/// straight out of a trace (the nested `{request, response, message, errors}`
152/// snapshot shape). Typos in the flat shape are still rejected — validation
153/// runs after normalisation.
154#[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
163/// Turns a sandbox `context` value — flat or the nested trace-snapshot shape —
164/// into a [`Context`].
165///
166/// A context copied from `GET /api/debug/traces/{id}` (a step's `after`, or the
167/// trace's `initial`) is nested (`request`/`response` objects, bodies as
168/// `{len, text, …}` objects) and carries display-only fields (`errors`, body
169/// `len`/`truncated`/`binary`). This flattens that into the flat input shape
170/// before deserialising, so pasting a trace context "just works".
171///
172/// Note: snapshots are **redacted** — a header shown as `<redacted>` replays
173/// literally as that string, and a truncated or binary body cannot be
174/// reconstructed. The sandbox replays what the trace could show, not the
175/// original secret bytes.
176pub 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
186/// Flattens the nested trace-snapshot shape into the flat input shape. A value
187/// that is already flat passes through essentially unchanged.
188fn 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    // Nested `request` object (snapshot shape) -> lift its fields to the top,
193    // converting the body object to a plain string.
194    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                // A binary or uncaptured body has no faithful text to replay.
201            } else {
202                obj.insert(k, val);
203            }
204        }
205    }
206    // Response body object -> string (or drop it when there is no text).
207    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    // Display-only fields the sandbox does not model.
220    obj.remove("errors");
221    Value::Object(obj)
222}
223
224/// Extracts replayable body text: a plain string, or a snapshot body object's
225/// `text` field. `None` for a binary or uncaptured body.
226fn 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
234/// Synthesises a runnable policy from an ad-hoc node list.
235///
236/// Prepends `listener`, appends `client`, and chains the user's nodes through
237/// their success ports.
238pub 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    /// Posting `{}` must produce a runnable context — the sandbox should not
328    /// demand a fully-specified request just to try one plugin.
329    #[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    /// A typo must fail loudly rather than silently defaulting — now at
387    /// materialisation, since `context` is taken raw and validated after
388    /// normalisation.
389    #[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    /// A context copied straight from a trace (nested request/response, body as
396    /// an object, plus `errors`) must replay without a shape error.
397    #[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        // `errors` is display-only and must not leak into the replayed context.
427        assert!(ctx.errors.is_empty());
428    }
429
430    /// A snapshot whose body was binary/uncaptured (no `text`) replays with an
431    /// empty body rather than failing.
432    #[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        // The policy must satisfy the same validation the editor applies.
466        assert!(crate::graph::validate_policy(&p).is_ok());
467    }
468
469    /// `on_error: "client"` wires every error port so a rejecting plugin's own
470    /// status survives instead of becoming the engine's generic 500.
471    #[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}