Skip to main content

featherbit/plugins/native/
response_rewrite.rs

1//! The `response-rewrite` node — rewrites the status code, body, and headers
2//! of the response before it reaches the client. Port of APISIX's
3//! `response-rewrite` plugin (response-phase: place after `upstream`, before
4//! `client`).
5//!
6//! Since featherbit responses are fully buffered in `Context.response`,
7//! APISIX's `header_filter` + `body_filter` phases collapse into a single
8//! `execute()`.
9//!
10//! Deviations from APISIX:
11//! - filter `options` only supports `"i"` (case-insensitive); APISIX's PCRE
12//!   flags `j`/`o` are JIT/compile-cache hints with no meaning here and are
13//!   rejected at config load.
14//! - when filters skip because of an unsupported/undecodable
15//!   `content-encoding`, the response headers are left untouched (APISIX has
16//!   already cleared `content-length`/`content-encoding` at that point, which
17//!   garbles the response — we deliberately do not mirror that quirk).
18
19use async_trait::async_trait;
20use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
21use bytes::Bytes;
22use regex::Regex;
23use std::collections::HashMap;
24
25use crate::context::Context;
26use crate::plugins::util::content_codec::{self, ContentEncoding};
27use crate::plugins::{Plugin, PluginOutput, PluginResult};
28use crate::vars;
29use crate::vars::template::Template;
30
31/// Rewrites `Context.response`: optionally forces a status code, replaces the
32/// body (with optional base64-decoded config content), applies regex filters
33/// to the body, and adds/sets/removes headers with `{{namespace.path}}`
34/// references plus legacy `$var` interpolation in header values. An optional
35/// `vars` expression gates the whole node — when present and false the node
36/// is a pure passthrough. This plugin never fails at execution time.
37pub struct ResponseRewritePlugin {
38    status_code: Option<u16>,
39    /// Replacement body. The plain-text form is a `{{namespace.path}}`
40    /// template rendered per request; a `body_base64` body is decoded once
41    /// at config load and used verbatim — it is opaque binary content
42    /// (images, protobufs, ...), not text, so it is never templated.
43    body: Option<ResponseBody>,
44    filters: Vec<BodyFilter>,
45    /// Header name → value template; supports `{{namespace.path}}`
46    /// references and legacy `$var` interpolation (see
47    /// [`Template::render_with_legacy`]).
48    add_headers: Vec<(String, Template)>,
49    /// Same rendering as `add_headers`.
50    set_headers: Vec<(String, Template)>,
51    remove_headers: Vec<String>,
52    vars: Option<vars::Expr>,
53}
54
55/// The configured replacement body: either a plain-text template rendered
56/// fresh per request, or fixed bytes decoded once from `body_base64` (opaque
57/// binary content, never templated — see [`ResponseRewritePlugin::body`]).
58enum ResponseBody {
59    Plain(Template),
60    Base64(Bytes),
61}
62
63/// One compiled body filter: a regex substitution applied once or globally.
64struct BodyFilter {
65    regex: Regex,
66    /// `replace` template: `{{namespace.path}}` references render first (via
67    /// plain [`Template::render`]); the rendered string is then handed to
68    /// the regex engine's own replacement syntax, so `$1`/`$2` capture-group
69    /// references are untouched by templating and still resolved by `regex`
70    /// at substitution time.
71    replace: Template,
72    global: bool,
73}
74
75/// Headers the server layer derives from the final body; stale copies from
76/// the upstream must be dropped whenever the body is replaced. Mirrors
77/// APISIX's `core.response.clear_header_as_body_modified` (which also drops
78/// the cache validators `last-modified` and `etag`).
79const BODY_DERIVED_HEADERS: [&str; 4] = [
80    "content-length",
81    "content-encoding",
82    "last-modified",
83    "etag",
84];
85
86fn clear_body_derived_headers(ctx: &mut Context) {
87    for name in BODY_DERIVED_HEADERS {
88        crate::plugins::util::headers::remove_ci(&mut ctx.response.headers, name);
89    }
90}
91
92/// Stringifies a scalar header value (string or number, matching the APISIX
93/// schema); returns `None` for other shapes.
94fn header_value_to_string(v: &serde_json::Value) -> Option<String> {
95    match v {
96        serde_json::Value::String(s) => Some(s.clone()),
97        serde_json::Value::Number(n) => Some(n.to_string()),
98        _ => None,
99    }
100}
101
102/// Splits an `add` entry of the form `"Name: value"` into `(name, value)`.
103///
104/// Mirrors APISIX's `^([^:\s]+)\s*:\s*([^:]+)$`: the name has no colon or
105/// whitespace, and the value part must be non-empty and colon-free.
106fn parse_add_entry(entry: &str) -> Result<(String, String), String> {
107    let (name, value) = entry
108        .split_once(':')
109        .ok_or_else(|| format!("headers.add entry '{}' must be 'Name: value'", entry))?;
110    let name = name.trim();
111    let value = value.trim();
112    if name.is_empty() || name.contains(char::is_whitespace) {
113        return Err(format!(
114            "headers.add entry '{}' has an invalid header name",
115            entry
116        ));
117    }
118    if value.is_empty() || value.contains(':') {
119        return Err(format!(
120            "headers.add entry '{}' has an invalid header value (must be non-empty, no ':')",
121            entry
122        ));
123    }
124    Ok((name.to_lowercase(), value.to_string()))
125}
126
127/// Parses the `headers` config key. Two accepted shapes:
128/// - structured: `{add: ["Name: value", ...], set: {name: value}, remove: [name]}`
129/// - deprecated flat map: `{name: value}` — treated as `set` (as APISIX does).
130#[allow(clippy::type_complexity)]
131fn parse_headers(
132    raw: &serde_json::Value,
133) -> Result<(Vec<(String, String)>, Vec<(String, String)>, Vec<String>), String> {
134    let obj = raw
135        .as_object()
136        .ok_or("headers must be an object".to_string())?;
137
138    let is_structured = obj.get("add").is_some_and(|v| v.is_array())
139        || obj.get("set").is_some_and(|v| v.is_object())
140        || obj.get("remove").is_some_and(|v| v.is_array());
141
142    let mut add = Vec::new();
143    let mut set = Vec::new();
144    let mut remove = Vec::new();
145
146    if is_structured {
147        if let Some(entries) = obj.get("add") {
148            for entry in entries.as_array().ok_or("headers.add must be an array")? {
149                let s = entry
150                    .as_str()
151                    .ok_or("headers.add entries must be strings ('Name: value')")?;
152                add.push(parse_add_entry(s)?);
153            }
154        }
155        if let Some(map) = obj.get("set") {
156            for (name, value) in map.as_object().ok_or("headers.set must be a map")? {
157                let v = header_value_to_string(value)
158                    .ok_or_else(|| format!("headers.set['{}'] must be a string or number", name))?;
159                set.push((name.to_lowercase(), v));
160            }
161        }
162        if let Some(names) = obj.get("remove") {
163            for name in names.as_array().ok_or("headers.remove must be an array")? {
164                let s = name
165                    .as_str()
166                    .ok_or("headers.remove entries must be strings")?;
167                remove.push(s.to_lowercase());
168            }
169        }
170    } else {
171        // Deprecated flat map: every key is a `set`.
172        for (name, value) in obj {
173            let v = header_value_to_string(value)
174                .ok_or_else(|| format!("headers['{}'] must be a string or number", name))?;
175            set.push((name.to_lowercase(), v));
176        }
177    }
178
179    Ok((add, set, remove))
180}
181
182/// Parses one `filters` entry into a compiled [`BodyFilter`].
183fn parse_filter(v: &serde_json::Value) -> Result<BodyFilter, String> {
184    let obj = v
185        .as_object()
186        .ok_or("filters entries must be objects".to_string())?;
187
188    let pattern = obj
189        .get("regex")
190        .and_then(|v| v.as_str())
191        .filter(|s| !s.is_empty())
192        .ok_or("filters entries require a non-empty 'regex' string")?;
193
194    let replace = obj
195        .get("replace")
196        .and_then(|v| v.as_str())
197        .ok_or("filters entries require a 'replace' string")?;
198    // Discard warnings here — the compile-time walk (a later task) reports
199    // well-formed-but-unknown references; execution must not.
200    let replace = Template::parse(replace).0;
201
202    let global = match obj.get("scope").and_then(|v| v.as_str()) {
203        None | Some("once") => false,
204        Some("global") => true,
205        Some(other) => {
206            return Err(format!(
207                "filters scope must be 'once' or 'global', got '{}'",
208                other
209            ))
210        }
211    };
212
213    let options = obj.get("options").and_then(|v| v.as_str()).unwrap_or("");
214    let case_insensitive = match options {
215        "" => false,
216        "i" => true,
217        other => {
218            return Err(format!(
219                "filters options only supports 'i' (case-insensitive), got '{}'",
220                other
221            ))
222        }
223    };
224
225    let full_pattern = if case_insensitive {
226        format!("(?i){}", pattern)
227    } else {
228        pattern.to_string()
229    };
230    let regex = Regex::new(&full_pattern)
231        .map_err(|e| format!("filters regex \"{}\" validation failed: {}", pattern, e))?;
232
233    Ok(BodyFilter {
234        regex,
235        replace,
236        global,
237    })
238}
239
240impl ResponseRewritePlugin {
241    /// Builds the plugin from node config.
242    ///
243    /// Accepted keys (all optional):
244    /// - `status_code` (integer, 200-598): new response status code.
245    /// - `body` (string): new response body. Wins over `filters` — configuring
246    ///   both is rejected (as in APISIX). Supports `{{namespace.path}}`
247    ///   references, rendered per request — unless `body_base64` is set (see
248    ///   below), in which case it is opaque binary content and is never
249    ///   templated.
250    /// - `body_base64` (bool, default `false`): when true, `body` is decoded
251    ///   from base64 at config load and used verbatim as fixed bytes on every
252    ///   request (never templated — a base64 body is binary content such as
253    ///   an image, not text); invalid or empty base64 content fails here.
254    /// - `headers` — either the structured shape or the deprecated flat map:
255    ///   - `add` (array of `"Name: value"` strings): appended alongside
256    ///     existing values. The value must be non-empty and colon-free.
257    ///   - `set` (map `{name: value}`): replaces existing values. Values may
258    ///     be strings or numbers.
259    ///   - `remove` (array of names): deleted.
260    ///   - flat map `{name: value}` (deprecated): treated as `set`.
261    ///
262    ///   `add`/`set` values support `{{namespace.path}}` references plus
263    ///   legacy `$var` / `${var}` interpolation at execution time (e.g.
264    ///   `$remote_addr`, `$status`, `$http_x_id`).
265    /// - `filters` (array): regex substitutions applied to the response body.
266    ///   Each entry: `regex` (required, non-empty), `replace` (required;
267    ///   supports `{{namespace.path}}` references, rendered before the regex
268    ///   engine applies its own `$1`/`$2` capture-group substitution, so
269    ///   those stay untouched by templating), `scope` (`once` | `global`,
270    ///   default `once`), `options` (only `"i"` for case-insensitive;
271    ///   anything else is rejected). Regexes are compiled here, so invalid
272    ///   patterns fail at config load.
273    /// - `vars` (array, APISIX triple-array expression): gate — when present
274    ///   and it evaluates to false, the node passes the context through
275    ///   unchanged.
276    ///
277    /// ```yaml
278    /// type: response-rewrite
279    /// config:
280    ///   status_code: 200
281    ///   headers:
282    ///     set:
283    ///       x-server-id: "3"
284    ///     add:
285    ///       - "x-trace: $http_x_request_id"
286    ///     remove: [x-powered-by]
287    ///   filters:
288    ///     - regex: "X-Amzn-"
289    ///       scope: global
290    ///       replace: ""
291    ///   vars:
292    ///     - ["status", "==", "200"]
293    /// ```
294    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
295        // A common mix-up: `proxy-rewrite` takes `add_headers`/`remove_headers`,
296        // but `response-rewrite` takes a single `headers` object. Silently
297        // ignoring the misplaced key produces a node that appears to do nothing,
298        // so reject it with the correct shape instead.
299        for wrong in ["add_headers", "set_headers", "remove_headers"] {
300            if config.contains_key(wrong) {
301                return Err(format!(
302                    "response-rewrite has no '{wrong}' key (that is proxy-rewrite's schema); \
303                     use headers with add/set/remove, e.g. \
304                     headers: {{ set: {{ x-foo: bar }}, remove: [x-powered-by] }}"
305                ));
306            }
307        }
308
309        let status_code = match config.get("status_code") {
310            None => None,
311            Some(v) => {
312                let code = v
313                    .as_u64()
314                    .ok_or("status_code must be an integer".to_string())?;
315                if !(200..=598).contains(&code) {
316                    return Err(format!("status_code must be within 200-598, got {}", code));
317                }
318                Some(code as u16)
319            }
320        };
321
322        let body_base64 = config
323            .get("body_base64")
324            .and_then(|v| v.as_bool())
325            .unwrap_or(false);
326
327        let body = match config.get("body") {
328            None => {
329                if body_base64 {
330                    return Err("body_base64 requires 'body' to be set".to_string());
331                }
332                None
333            }
334            Some(v) => {
335                let s = v.as_str().ok_or("body must be a string".to_string())?;
336                if body_base64 {
337                    if s.is_empty() {
338                        return Err("invalid base64 content".to_string());
339                    }
340                    let decoded = BASE64
341                        .decode(s.trim())
342                        .map_err(|_| "invalid base64 content".to_string())?;
343                    // Base64 content is opaque binary data, decoded once here
344                    // and used verbatim — never templated (documented on
345                    // `ResponseBody`).
346                    Some(ResponseBody::Base64(Bytes::from(decoded)))
347                } else {
348                    // Discard warnings here — the compile-time walk (a later
349                    // task) reports well-formed-but-unknown references;
350                    // execution must not.
351                    Some(ResponseBody::Plain(Template::parse(s).0))
352                }
353            }
354        };
355
356        let filters = match config.get("filters") {
357            None => Vec::new(),
358            Some(v) => {
359                let arr = v.as_array().ok_or("filters must be an array".to_string())?;
360                if arr.is_empty() {
361                    return Err("filters must contain at least one entry".to_string());
362                }
363                arr.iter()
364                    .map(parse_filter)
365                    .collect::<Result<Vec<_>, _>>()?
366            }
367        };
368
369        if body.is_some() && !filters.is_empty() {
370            return Err("'body' and 'filters' are mutually exclusive".to_string());
371        }
372
373        let (add_headers, set_headers, remove_headers) = match config.get("headers") {
374            None => (Vec::new(), Vec::new(), Vec::new()),
375            Some(raw) => parse_headers(raw)?,
376        };
377        // Discard warnings here — the compile-time walk (a later task)
378        // reports well-formed-but-unknown references; execution must not.
379        let add_headers: Vec<(String, Template)> = add_headers
380            .into_iter()
381            .map(|(name, value)| (name, Template::parse(&value).0))
382            .collect();
383        let set_headers: Vec<(String, Template)> = set_headers
384            .into_iter()
385            .map(|(name, value)| (name, Template::parse(&value).0))
386            .collect();
387
388        let vars = match config.get("vars") {
389            None => None,
390            Some(v) => Some(
391                vars::Expr::parse(v)
392                    .map_err(|e| format!("failed to validate the 'vars' expression: {}", e))?,
393            ),
394        };
395
396        Ok(Self {
397            status_code,
398            body,
399            filters,
400            add_headers,
401            set_headers,
402            remove_headers,
403            vars,
404        })
405    }
406
407    /// Runs the configured regex filters against the response body, decoding
408    /// a content-encoded body first. On any obstacle (unsupported encoding,
409    /// corrupt compressed data, non-UTF-8 body) it logs a warning and leaves
410    /// the response untouched, matching APISIX's "filters may not work as
411    /// expected" behavior.
412    fn apply_filters(&self, ctx: &mut Context) {
413        let encoding_header = ctx
414            .response
415            .headers
416            .get("content-encoding")
417            .and_then(|v| v.first())
418            .cloned()
419            .unwrap_or_default();
420
421        let decoded = match ContentEncoding::parse(&encoding_header) {
422            Err(e) => {
423                tracing::warn!(
424                    "response-rewrite: filters skipped due to unsupported \
425                     compression encoding: {}",
426                    e
427                );
428                return;
429            }
430            Ok(None) => ctx.response.body.clone(),
431            Ok(Some(encoding)) => match content_codec::decode(&encoding, &ctx.response.body) {
432                Ok(decoded) => decoded,
433                Err(e) => {
434                    tracing::warn!("response-rewrite: filters skipped: {}", e);
435                    return;
436                }
437            },
438        };
439
440        let mut text = match std::str::from_utf8(&decoded) {
441            Ok(s) => s.to_string(),
442            Err(_) => {
443                tracing::warn!("response-rewrite: filters skipped: body is not valid UTF-8");
444                return;
445            }
446        };
447
448        for filter in &self.filters {
449            let replace = filter.replace.render(ctx);
450            text = if filter.global {
451                filter.regex.replace_all(&text, replace.as_ref())
452            } else {
453                filter.regex.replace(&text, replace.as_ref())
454            }
455            .into_owned();
456        }
457
458        // The body is left decoded (as in APISIX), so all body-derived
459        // headers — including the stale content-encoding — must go.
460        ctx.response.body = Bytes::from(text);
461        clear_body_derived_headers(ctx);
462    }
463}
464
465#[async_trait]
466impl Plugin for ResponseRewritePlugin {
467    fn plugin_type(&self) -> &str {
468        "response-rewrite"
469    }
470
471    fn reads_response_body(&self) -> bool {
472        // `filters` rewrites the body; `body`/`body_base64` replaces it. Either
473        // needs the buffered body. Headers and status alone do not -- unless
474        // the `vars` gate deciding whether to apply them reads the body, which
475        // makes even a headers-only rewrite a body reader.
476        !self.filters.is_empty()
477            || self.body.is_some()
478            || self
479                .vars
480                .as_ref()
481                .is_some_and(|e| e.references_response_body())
482    }
483
484    async fn execute(&self, mut ctx: Context) -> PluginResult {
485        // `vars` gate: when configured and false, the node is a no-op.
486        if let Some(expr) = &self.vars {
487            if !expr.eval(&ctx) {
488                return Ok(PluginOutput::success(ctx));
489            }
490        }
491
492        if let Some(code) = self.status_code {
493            ctx.response.status_code = code;
494        }
495
496        if let Some(body) = &self.body {
497            ctx.response.body = match body {
498                ResponseBody::Plain(tpl) => Bytes::from(tpl.render(&ctx).into_owned()),
499                ResponseBody::Base64(bytes) => bytes.clone(),
500            };
501            clear_body_derived_headers(&mut ctx);
502        } else if !self.filters.is_empty() {
503            self.apply_filters(&mut ctx);
504        }
505
506        // Header ops in APISIX order: add, set, remove. Values are
507        // interpolated against the context ($status, $http_<name>, ...).
508        for (name, value) in &self.add_headers {
509            let value = value.render_with_legacy(&ctx);
510            ctx.response
511                .headers
512                .entry(name.clone())
513                .or_default()
514                .push(value);
515        }
516        for (name, value) in &self.set_headers {
517            let value = value.render_with_legacy(&ctx);
518            ctx.response.headers.insert(name.clone(), vec![value]);
519        }
520        for name in &self.remove_headers {
521            crate::plugins::util::headers::remove_ci(&mut ctx.response.headers, name);
522        }
523
524        Ok(PluginOutput::success(ctx))
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
532
533    fn test_context(status: u16, body: &[u8]) -> Context {
534        let mut response_headers = HashMap::new();
535        response_headers.insert("content-length".to_string(), vec![body.len().to_string()]);
536        Context {
537            request: GatewayRequest {
538                method: "GET".to_string(),
539                path: "/test".to_string(),
540                host: "localhost".to_string(),
541                scheme: "http".to_string(),
542                headers: HashMap::new(),
543                query_params: HashMap::new(),
544                body: Bytes::new(),
545                remote_addr: "127.0.0.1:12345".to_string(),
546                protocol: Protocol::Http1,
547            },
548            response: GatewayResponse {
549                status_code: status,
550                headers: response_headers,
551                body: Bytes::copy_from_slice(body),
552                stream: None,
553            },
554            message: HashMap::new(),
555            errors: Vec::new(),
556        }
557    }
558
559    fn plugin(config: serde_json::Value) -> ResponseRewritePlugin {
560        let map: HashMap<String, serde_json::Value> =
561            serde_json::from_value(config).expect("test config must be an object");
562        ResponseRewritePlugin::from_config(&map).expect("config should be valid")
563    }
564
565    #[tokio::test]
566    async fn test_response_rewrite_status_and_body() {
567        let p = plugin(serde_json::json!({
568            "status_code": 404,
569            "body": "not found\n"
570        }));
571        let ctx = test_context(200, b"original");
572        let out = p.execute(ctx).await.unwrap();
573        assert_eq!(out.context.response.status_code, 404);
574        assert_eq!(out.context.response.body.as_ref(), b"not found\n");
575        // Body-mutation convention: stale content-length is gone.
576        assert!(!out.context.response.headers.contains_key("content-length"));
577    }
578
579    #[tokio::test]
580    async fn test_response_rewrite_body_base64() {
581        let p = plugin(serde_json::json!({
582            "body": "aGVsbG8gd29ybGQ=",
583            "body_base64": true
584        }));
585        let ctx = test_context(200, b"x");
586        let out = p.execute(ctx).await.unwrap();
587        assert_eq!(out.context.response.body.as_ref(), b"hello world");
588    }
589
590    #[tokio::test]
591    async fn test_response_rewrite_body_renders_template() {
592        let p = plugin(serde_json::json!({
593            "body": "path={{request.path}} price=$19.99"
594        }));
595        let mut ctx = test_context(200, b"original");
596        ctx.request.path = "/orders".to_string();
597        let out = p.execute(ctx).await.unwrap();
598        assert_eq!(
599            out.context.response.body.as_ref(),
600            b"path=/orders price=$19.99"
601        );
602    }
603
604    #[tokio::test]
605    async fn test_response_rewrite_body_base64_never_templated() {
606        // A base64 body is decoded FIRST at config load, then never
607        // templated — the literal bytes `{{request.path}}` inside the
608        // decoded payload must survive undecoded/unrendered.
609        let literal = BASE64.encode(b"{{request.path}}");
610        let p = plugin(serde_json::json!({
611            "body": literal,
612            "body_base64": true
613        }));
614        let mut ctx = test_context(200, b"x");
615        ctx.request.path = "/should-not-appear".to_string();
616        let out = p.execute(ctx).await.unwrap();
617        assert_eq!(out.context.response.body.as_ref(), b"{{request.path}}");
618    }
619
620    #[test]
621    fn test_response_rewrite_invalid_base64_rejected() {
622        let mut config = HashMap::new();
623        config.insert("body".to_string(), serde_json::json!("not!!valid@@base64"));
624        config.insert("body_base64".to_string(), serde_json::json!(true));
625        assert!(ResponseRewritePlugin::from_config(&config).is_err());
626
627        // body_base64 without a body is also invalid.
628        let mut config = HashMap::new();
629        config.insert("body_base64".to_string(), serde_json::json!(true));
630        assert!(ResponseRewritePlugin::from_config(&config).is_err());
631    }
632
633    #[test]
634    fn test_response_rewrite_config_validation() {
635        // status_code out of range
636        let mut config = HashMap::new();
637        config.insert("status_code".to_string(), serde_json::json!(199));
638        assert!(ResponseRewritePlugin::from_config(&config).is_err());
639        let mut config = HashMap::new();
640        config.insert("status_code".to_string(), serde_json::json!(599));
641        assert!(ResponseRewritePlugin::from_config(&config).is_err());
642
643        // body and filters are mutually exclusive
644        let mut config = HashMap::new();
645        config.insert("body".to_string(), serde_json::json!("x"));
646        config.insert(
647            "filters".to_string(),
648            serde_json::json!([{ "regex": "a", "replace": "b" }]),
649        );
650        assert!(ResponseRewritePlugin::from_config(&config).is_err());
651
652        // invalid regex fails at load
653        let mut config = HashMap::new();
654        config.insert(
655            "filters".to_string(),
656            serde_json::json!([{ "regex": "(", "replace": "" }]),
657        );
658        assert!(ResponseRewritePlugin::from_config(&config).is_err());
659
660        // unsupported regex options rejected (only "i" is supported)
661        let mut config = HashMap::new();
662        config.insert(
663            "filters".to_string(),
664            serde_json::json!([{ "regex": "a", "replace": "b", "options": "jo" }]),
665        );
666        assert!(ResponseRewritePlugin::from_config(&config).is_err());
667
668        // malformed add entry (colon in value) rejected
669        let mut config = HashMap::new();
670        config.insert(
671            "headers".to_string(),
672            serde_json::json!({ "add": ["x-key: a:b"] }),
673        );
674        assert!(ResponseRewritePlugin::from_config(&config).is_err());
675    }
676
677    /// proxy-rewrite's header keys must not be silently ignored here — that
678    /// produces a node that looks like it does nothing. Fail with guidance.
679    #[test]
680    fn test_proxy_rewrite_header_keys_rejected_with_guidance() {
681        for wrong in ["add_headers", "set_headers", "remove_headers"] {
682            let mut config = HashMap::new();
683            config.insert(wrong.to_string(), serde_json::json!({ "x-foo": "bar" }));
684            let err = ResponseRewritePlugin::from_config(&config)
685                .err()
686                .unwrap_or_else(|| panic!("{wrong} should be rejected"));
687            assert!(err.contains(wrong), "error should name the bad key: {err}");
688            assert!(
689                err.contains("headers"),
690                "error should point to the right shape: {err}"
691            );
692        }
693        // The correct shape still builds.
694        let mut ok = HashMap::new();
695        ok.insert(
696            "headers".to_string(),
697            serde_json::json!({ "set": { "x-foo": "bar" } }),
698        );
699        assert!(ResponseRewritePlugin::from_config(&ok).is_ok());
700    }
701
702    #[tokio::test]
703    async fn test_response_rewrite_headers_add_set_remove() {
704        let p = plugin(serde_json::json!({
705            "headers": {
706                "add": ["X-Trace: abc"],
707                "set": { "X-Server": "featherbit", "x-version": 3 },
708                "remove": ["X-Powered-By"]
709            }
710        }));
711        let mut ctx = test_context(200, b"body");
712        ctx.response
713            .headers
714            .insert("x-powered-by".to_string(), vec!["php".to_string()]);
715        ctx.response
716            .headers
717            .insert("x-trace".to_string(), vec!["existing".to_string()]);
718        ctx.response
719            .headers
720            .insert("x-server".to_string(), vec!["nginx".to_string()]);
721
722        let out = p.execute(ctx).await.unwrap();
723        let headers = &out.context.response.headers;
724        // add appends alongside the existing value
725        assert_eq!(
726            headers.get("x-trace"),
727            Some(&vec!["existing".to_string(), "abc".to_string()])
728        );
729        // set replaces
730        assert_eq!(
731            headers.get("x-server"),
732            Some(&vec!["featherbit".to_string()])
733        );
734        assert_eq!(headers.get("x-version"), Some(&vec!["3".to_string()]));
735        // remove deletes
736        assert!(!headers.contains_key("x-powered-by"));
737        // body untouched → content-length kept
738        assert!(headers.contains_key("content-length"));
739    }
740
741    #[tokio::test]
742    async fn test_response_rewrite_deprecated_flat_headers_map() {
743        let p = plugin(serde_json::json!({
744            "headers": { "X-Flat": "yes" }
745        }));
746        let ctx = test_context(200, b"body");
747        let out = p.execute(ctx).await.unwrap();
748        assert_eq!(
749            out.context.response.headers.get("x-flat"),
750            Some(&vec!["yes".to_string()])
751        );
752    }
753
754    #[tokio::test]
755    async fn test_response_rewrite_header_var_interpolation() {
756        let p = plugin(serde_json::json!({
757            "headers": {
758                "set": { "x-origin": "$remote_addr", "x-status": "$status" }
759            }
760        }));
761        let ctx = test_context(201, b"body");
762        let out = p.execute(ctx).await.unwrap();
763        assert_eq!(
764            out.context.response.headers.get("x-origin"),
765            Some(&vec!["127.0.0.1".to_string()])
766        );
767        assert_eq!(
768            out.context.response.headers.get("x-status"),
769            Some(&vec!["201".to_string()])
770        );
771    }
772
773    #[tokio::test]
774    async fn test_response_rewrite_filters_once_and_global() {
775        let p = plugin(serde_json::json!({
776            "filters": [{ "regex": "foo", "replace": "bar" }]
777        }));
778        let ctx = test_context(200, b"foo foo foo");
779        let out = p.execute(ctx).await.unwrap();
780        assert_eq!(out.context.response.body.as_ref(), b"bar foo foo");
781        assert!(!out.context.response.headers.contains_key("content-length"));
782
783        let p = plugin(serde_json::json!({
784            "filters": [{ "regex": "FOO", "replace": "bar", "scope": "global", "options": "i" }]
785        }));
786        let ctx = test_context(200, b"foo Foo fOO");
787        let out = p.execute(ctx).await.unwrap();
788        assert_eq!(out.context.response.body.as_ref(), b"bar bar bar");
789    }
790
791    #[tokio::test]
792    async fn test_response_rewrite_filters_replace_template_and_capture_group() {
793        // `replace` must render `{{...}}` references while leaving `$1`
794        // (a regex capture-group backreference) for the regex engine's own
795        // substitution — the template pass runs first and never touches `$`.
796        let p = plugin(serde_json::json!({
797            "filters": [{ "regex": r"user=(\w+)", "replace": "user=$1 path={{request.path}}" }]
798        }));
799        let mut ctx = test_context(200, b"user=jack");
800        ctx.request.path = "/api/users".to_string();
801        let out = p.execute(ctx).await.unwrap();
802        assert_eq!(
803            out.context.response.body.as_ref(),
804            b"user=jack path=/api/users"
805        );
806    }
807
808    #[tokio::test]
809    async fn test_response_rewrite_filters_decode_gzip_body() {
810        let plain = Bytes::from_static(b"hello encoded world");
811        let compressed = content_codec::encode(&ContentEncoding::Gzip, &plain, 6).unwrap();
812
813        let p = plugin(serde_json::json!({
814            "filters": [{ "regex": "encoded", "replace": "decoded" }]
815        }));
816        let mut ctx = test_context(200, &compressed);
817        ctx.response
818            .headers
819            .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
820        ctx.response
821            .headers
822            .insert("etag".to_string(), vec!["\"abc\"".to_string()]);
823
824        let out = p.execute(ctx).await.unwrap();
825        // Body is decoded, filtered, and left decoded (as in APISIX).
826        assert_eq!(out.context.response.body.as_ref(), b"hello decoded world");
827        let headers = &out.context.response.headers;
828        assert!(!headers.contains_key("content-encoding"));
829        assert!(!headers.contains_key("content-length"));
830        assert!(!headers.contains_key("etag"));
831    }
832
833    #[tokio::test]
834    async fn test_response_rewrite_filters_skip_unsupported_encoding() {
835        let p = plugin(serde_json::json!({
836            "filters": [{ "regex": "foo", "replace": "bar" }]
837        }));
838        let mut ctx = test_context(200, b"foo body");
839        ctx.response
840            .headers
841            .insert("content-encoding".to_string(), vec!["zstd".to_string()]);
842
843        let out = p.execute(ctx).await.unwrap();
844        // Unsupported encoding: body and headers untouched.
845        assert_eq!(out.context.response.body.as_ref(), b"foo body");
846        assert_eq!(
847            out.context.response.headers.get("content-encoding"),
848            Some(&vec!["zstd".to_string()])
849        );
850        assert!(out.context.response.headers.contains_key("content-length"));
851    }
852
853    #[tokio::test]
854    async fn test_response_rewrite_filters_skip_corrupt_encoded_body() {
855        let p = plugin(serde_json::json!({
856            "filters": [{ "regex": "foo", "replace": "bar" }]
857        }));
858        let mut ctx = test_context(200, b"\x00not gzip\xff");
859        ctx.response
860            .headers
861            .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
862
863        let out = p.execute(ctx).await.unwrap();
864        assert_eq!(out.context.response.body.as_ref(), b"\x00not gzip\xff");
865        assert!(out
866            .context
867            .response
868            .headers
869            .contains_key("content-encoding"));
870    }
871
872    #[tokio::test]
873    async fn test_response_rewrite_vars_gate() {
874        let config = serde_json::json!({
875            "status_code": 500,
876            "body": "rewritten",
877            "headers": { "set": { "x-hit": "1" } },
878            "vars": [["status", "==", "200"]]
879        });
880
881        // Gate matches → rewrite applies.
882        let p = plugin(config.clone());
883        let out = p.execute(test_context(200, b"orig")).await.unwrap();
884        assert_eq!(out.context.response.status_code, 500);
885        assert_eq!(out.context.response.body.as_ref(), b"rewritten");
886
887        // Gate does not match → complete passthrough.
888        let p = plugin(config);
889        let out = p.execute(test_context(404, b"orig")).await.unwrap();
890        assert_eq!(out.context.response.status_code, 404);
891        assert_eq!(out.context.response.body.as_ref(), b"orig");
892        assert!(!out.context.response.headers.contains_key("x-hit"));
893        assert!(out.context.response.headers.contains_key("content-length"));
894    }
895
896    /// `response-rewrite` only reads the body when it rewrites the body:
897    /// `filters` (regex substitution) or a replacement `body`. A headers-only
898    /// or status-only instance leaves the body untouched, so it must not force
899    /// a streaming upstream to buffer.
900    #[test]
901    fn test_reads_response_body_only_when_body_is_rewritten() {
902        let headers_only = plugin(serde_json::json!({
903            "headers": { "set": { "x-frame-options": "deny" } }
904        }));
905        assert!(
906            !headers_only.reads_response_body(),
907            "headers-only rewrite must not force buffering"
908        );
909
910        let status_only = plugin(serde_json::json!({ "status_code": 204 }));
911        assert!(!status_only.reads_response_body());
912
913        let with_filters = plugin(serde_json::json!({
914            "filters": [{ "regex": "secret", "replace": "***" }]
915        }));
916        assert!(
917            with_filters.reads_response_body(),
918            "filters rewrite the body and must force buffering"
919        );
920
921        let with_body = plugin(serde_json::json!({ "body": "replaced" }));
922        assert!(with_body.reads_response_body());
923    }
924
925    /// A headers-only rewrite gated on the response body still reads it: the
926    /// `vars` gate is evaluated against the body even when `filters` and
927    /// `body` are both absent.
928    #[test]
929    fn test_response_rewrite_vars_gate_on_the_body_forces_buffering() {
930        let p = plugin(serde_json::json!({
931            "headers": { "add": ["x-checked: 1"] },
932            "vars": [["response_body:$.error", "==", true]]
933        }));
934        assert!(p.reads_response_body());
935    }
936
937    /// The same headers-only rewrite gated on the status must stay stream-safe.
938    #[test]
939    fn test_response_rewrite_vars_gate_on_the_status_stays_stream_safe() {
940        let p = plugin(serde_json::json!({
941            "headers": { "add": ["x-checked: 1"] },
942            "vars": [["status", "==", "200"]]
943        }));
944        assert!(!p.reads_response_body());
945    }
946}