1use 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
31pub struct ResponseRewritePlugin {
38 status_code: Option<u16>,
39 body: Option<ResponseBody>,
44 filters: Vec<BodyFilter>,
45 add_headers: Vec<(String, Template)>,
49 set_headers: Vec<(String, Template)>,
51 remove_headers: Vec<String>,
52 vars: Option<vars::Expr>,
53}
54
55enum ResponseBody {
59 Plain(Template),
60 Base64(Bytes),
61}
62
63struct BodyFilter {
65 regex: Regex,
66 replace: Template,
72 global: bool,
73}
74
75const 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
92fn 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
102fn 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#[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 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
182fn 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 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 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
295 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 Some(ResponseBody::Base64(Bytes::from(decoded)))
347 } else {
348 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 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 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 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 !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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert_eq!(
726 headers.get("x-trace"),
727 Some(&vec!["existing".to_string(), "abc".to_string()])
728 );
729 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 assert!(!headers.contains_key("x-powered-by"));
737 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 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 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 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 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 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 #[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 #[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 #[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}