Skip to main content

featherbit/vars/
template.rs

1//! Universal `{{namespace.path}}` template engine.
2//!
3//! Complements `$var`/`${var}` interpolation ([`super::interpolate`]) with a
4//! syntax that is unambiguous by construction: any `{{...}}` that doesn't
5//! match a known namespace path passes through as a literal (no silent-empty,
6//! no escape syntax needed). This lets the engine be applied universally —
7//! including to fields holding secrets, regexes, or `$1`/`$ref` values that
8//! the legacy `$` syntax would corrupt.
9//!
10//! Grammar: `{{` `\s*` `<namespace path>` `\s*` `}}`, no nesting. Namespaces:
11//!
12//! - `request.method|path|host|scheme|body`
13//! - `request.headers.<name>` / `request.query.<name>` / `request.cookies.<name>`
14//! - `response.status|body`, `response.headers.<name>`
15//! - `message.<key>` (key may itself contain dots)
16//! - `client.ip|port`
17//! - `env.<NAME>` — resolved at *parse* time from the process environment
18//!   (or an injected lookup fn), never at render time.
19//!
20//! Anything else — an unrecognized first segment, a malformed remainder
21//! within a known namespace, or an unclosed `{{` — passes through literally.
22//! A malformed remainder within a *known* namespace (and an unset `env.*`)
23//! additionally produces a warning string from [`Template::parse`].
24//!
25//! [`Template::render_with_legacy`] applies the legacy `interpolate` pass to
26//! the template's literal segments only; rendered `{{...}}` output is never
27//! `$`-processed, so runtime data can never become a `$var` read primitive.
28
29use std::borrow::Cow;
30
31use crate::context::Context;
32use crate::vars::{cookie_value, message_str, split_remote_addr};
33
34/// A single resolvable reference inside a template.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum TemplateRef {
37    RequestMethod,
38    RequestPath,
39    RequestHost,
40    RequestScheme,
41    RequestBody,
42    RequestHeader(String),
43    RequestQuery(String),
44    RequestCookie(String),
45    ResponseStatus,
46    ResponseBody,
47    ResponseHeader(String),
48    Message(String),
49    ClientIp,
50    ClientPort,
51}
52
53/// One chunk of a parsed template: either raw text or a resolvable reference.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Segment {
56    Literal(String),
57    Ref(TemplateRef),
58}
59
60/// A parsed template, ready to render repeatedly against different contexts.
61///
62/// Adjacent literal segments are merged at parse time, so a template with no
63/// references at all collapses to zero-or-one `Literal` segments, enabling
64/// the [`Template::render`] fast path to borrow directly from it.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Template {
67    segments: Vec<Segment>,
68    has_refs: bool,
69}
70
71impl Template {
72    /// Parses `s`, resolving `env.*` references from the process environment.
73    ///
74    /// Returns the compiled template plus any warnings (well-formed-but-unknown
75    /// references, unset env vars) discovered along the way.
76    pub fn parse(s: &str) -> (Template, Vec<String>) {
77        Self::parse_with_env(s, &|name| std::env::var(name).ok())
78    }
79
80    /// Parses `s`, resolving `env.*` references via the injected `env` lookup
81    /// (used by tests to avoid depending on real process environment state).
82    pub fn parse_with_env(
83        s: &str,
84        env: &dyn Fn(&str) -> Option<String>,
85    ) -> (Template, Vec<String>) {
86        let mut segments: Vec<Segment> = Vec::new();
87        let mut warnings: Vec<String> = Vec::new();
88        let mut literal = String::new();
89        let mut rest = s;
90
91        loop {
92            let Some(idx) = rest.find("{{") else {
93                literal.push_str(rest);
94                break;
95            };
96            literal.push_str(&rest[..idx]);
97            let after_open = &rest[idx + 2..];
98            let Some(close_idx) = after_open.find("}}") else {
99                // Unclosed: literal to end of input, no warning, no hang.
100                literal.push_str(&rest[idx..]);
101                break;
102            };
103            let inner = &after_open[..close_idx];
104            let trimmed = inner.trim();
105            let full_match = &rest[idx..idx + 2 + close_idx + 2];
106
107            match classify(trimmed, full_match, env) {
108                Classified::Ref(r) => {
109                    if !literal.is_empty() {
110                        segments.push(Segment::Literal(std::mem::take(&mut literal)));
111                    }
112                    segments.push(Segment::Ref(r));
113                }
114                Classified::EnvLiteral(value) => literal.push_str(&value),
115                Classified::PassThrough => literal.push_str(full_match),
116                Classified::Warn(message) => {
117                    literal.push_str(full_match);
118                    warnings.push(message);
119                }
120            }
121
122            rest = &after_open[close_idx + 2..];
123        }
124
125        if !literal.is_empty() {
126            segments.push(Segment::Literal(literal));
127        }
128        let has_refs = segments.iter().any(|s| matches!(s, Segment::Ref(_)));
129        (Template { segments, has_refs }, warnings)
130    }
131
132    /// Renders the template against `ctx`. Never touches `$var` syntax.
133    ///
134    /// Literal-only templates return `Cow::Borrowed` without allocating.
135    pub fn render(&self, ctx: &Context) -> Cow<'_, str> {
136        if !self.has_refs {
137            return Cow::Borrowed(self.literal_str());
138        }
139        let mut out = String::new();
140        for seg in &self.segments {
141            match seg {
142                Segment::Literal(s) => out.push_str(s),
143                Segment::Ref(r) => render_ref(r, ctx, &mut out),
144            }
145        }
146        Cow::Owned(out)
147    }
148
149    /// Applies the legacy `$var`/`${var}` pass to the template's *literal*
150    /// segments only; rendered `{{...}}` output is appended inert and is
151    /// never `$`-processed. Used only by fields that historically supported
152    /// `$` interpolation.
153    ///
154    /// This composition is deliberate, not an optimization: `$`-processing
155    /// the fully rendered string would let runtime data (a header, cookie,
156    /// query param, ...) that happens to contain `$var`/`${var}` syntax act
157    /// as a read primitive against the context (e.g. a client-supplied
158    /// header value of `$http_authorization` would leak the Authorization
159    /// header) and would corrupt values like `pa$sword4` or `{"$ref":1}`
160    /// flowing through a ref — exactly the classes this engine exists to
161    /// prevent. For a template with no refs (a single literal segment, or
162    /// none), this is byte-identical to the old whole-string pass.
163    pub fn render_with_legacy(&self, ctx: &Context) -> String {
164        let mut out = String::new();
165        for seg in &self.segments {
166            match seg {
167                Segment::Literal(s) => out.push_str(&crate::vars::interpolate(ctx, s)),
168                Segment::Ref(r) => render_ref(r, ctx, &mut out),
169            }
170        }
171        out
172    }
173
174    /// Borrows the single literal chunk of a literal-only template (or ""
175    /// when the source was empty). Only meaningful when `!self.has_refs`.
176    fn literal_str(&self) -> &str {
177        match self.segments.first() {
178            Some(Segment::Literal(s)) => s.as_str(),
179            _ => "",
180        }
181    }
182
183    /// True when the template has no `{{...}}` references at all (render is
184    /// a pure borrow with no per-request work).
185    /// Whether rendering this template reads `context.response.body`.
186    ///
187    /// Covers both spellings a config can use: the parsed `{{response.body}}`
188    /// reference, and the legacy `$resp_body`, which `render_with_legacy`
189    /// resolves at render time and so never becomes a segment -- a
190    /// segment-only scan would miss it entirely.
191    ///
192    /// The legacy check is textual, so a literal containing `$resp_body` in a
193    /// field rendered by [`Template::render`] (which never interpolates
194    /// `$var`) reports `true` as well. That over-reports in a rare case and
195    /// buffers a response that could have streamed, which is the safe
196    /// direction: streaming when a node needed the body corrupts the
197    /// response, while buffering only forgoes an optimization.
198    pub fn references_response_body(&self) -> bool {
199        self.segments.iter().any(|seg| match seg {
200            Segment::Ref(TemplateRef::ResponseBody) => true,
201            Segment::Ref(_) => false,
202            Segment::Literal(text) => text.contains("$resp_body"),
203        })
204    }
205
206    pub fn is_literal(&self) -> bool {
207        !self.has_refs
208    }
209
210    /// Parses `s` and returns only the warnings, discarding the template.
211    /// Used by the compile-time walk that surfaces warnings without
212    /// duplicating render behavior.
213    pub fn source_warnings_only(s: &str) -> Vec<String> {
214        Self::parse(s).1
215    }
216}
217
218/// Outcome of classifying one `{{...}}` occurrence's trimmed inner text.
219enum Classified {
220    /// A recognized reference to resolve at render time.
221    Ref(TemplateRef),
222    /// A parse-time substitution (currently only `env.*`); becomes a plain
223    /// literal segment with no warning.
224    EnvLiteral(String),
225    /// Unrecognized first segment: passed through as a literal, silently.
226    PassThrough,
227    /// A known namespace with a malformed/unknown remainder, or an unset
228    /// `env.*` reference: passed through as a literal, with a warning.
229    Warn(String),
230}
231
232fn unknown_ref_warning(full_match: &str) -> String {
233    format!("unknown template reference '{full_match}' (passed through literally)")
234}
235
236fn classify(trimmed: &str, full_match: &str, env: &dyn Fn(&str) -> Option<String>) -> Classified {
237    let (first, remainder) = match trimmed.split_once('.') {
238        Some((first, remainder)) => (first, remainder),
239        None => (trimmed, ""),
240    };
241
242    match first {
243        "request" => classify_request(remainder, full_match),
244        "response" => classify_response(remainder, full_match),
245        "message" => classify_message(remainder, full_match),
246        "client" => classify_client(remainder, full_match),
247        "env" => classify_env(remainder, full_match, env),
248        _ => Classified::PassThrough,
249    }
250}
251
252fn classify_request(remainder: &str, full_match: &str) -> Classified {
253    match remainder {
254        "method" => Classified::Ref(TemplateRef::RequestMethod),
255        "path" => Classified::Ref(TemplateRef::RequestPath),
256        "host" => Classified::Ref(TemplateRef::RequestHost),
257        "scheme" => Classified::Ref(TemplateRef::RequestScheme),
258        "body" => Classified::Ref(TemplateRef::RequestBody),
259        _ => {
260            if let Some(name) = remainder.strip_prefix("headers.") {
261                named_ref(name, full_match, |n| {
262                    TemplateRef::RequestHeader(n.to_string())
263                })
264            } else if let Some(name) = remainder.strip_prefix("query.") {
265                named_ref(name, full_match, |n| {
266                    TemplateRef::RequestQuery(n.to_string())
267                })
268            } else if let Some(name) = remainder.strip_prefix("cookies.") {
269                named_ref(name, full_match, |n| {
270                    TemplateRef::RequestCookie(n.to_string())
271                })
272            } else {
273                Classified::Warn(unknown_ref_warning(full_match))
274            }
275        }
276    }
277}
278
279fn classify_response(remainder: &str, full_match: &str) -> Classified {
280    match remainder {
281        "status" => Classified::Ref(TemplateRef::ResponseStatus),
282        "body" => Classified::Ref(TemplateRef::ResponseBody),
283        _ => {
284            if let Some(name) = remainder.strip_prefix("headers.") {
285                named_ref(name, full_match, |n| {
286                    TemplateRef::ResponseHeader(n.to_string())
287                })
288            } else {
289                Classified::Warn(unknown_ref_warning(full_match))
290            }
291        }
292    }
293}
294
295fn classify_message(remainder: &str, full_match: &str) -> Classified {
296    if remainder.is_empty() {
297        Classified::Warn(unknown_ref_warning(full_match))
298    } else {
299        Classified::Ref(TemplateRef::Message(remainder.to_string()))
300    }
301}
302
303fn classify_client(remainder: &str, full_match: &str) -> Classified {
304    match remainder {
305        "ip" => Classified::Ref(TemplateRef::ClientIp),
306        "port" => Classified::Ref(TemplateRef::ClientPort),
307        _ => Classified::Warn(unknown_ref_warning(full_match)),
308    }
309}
310
311fn classify_env(
312    remainder: &str,
313    full_match: &str,
314    env: &dyn Fn(&str) -> Option<String>,
315) -> Classified {
316    if remainder.is_empty() {
317        return Classified::Warn(unknown_ref_warning(full_match));
318    }
319    match env(remainder) {
320        Some(value) => Classified::EnvLiteral(value),
321        None => Classified::Warn(format!(
322            "unset environment variable '{remainder}' referenced in template '{full_match}' (passed through literally)"
323        )),
324    }
325}
326
327/// Builds a `Ref` classification for a `<namespace>.<subkey>.<name>` leaf,
328/// warning instead when `name` is empty (e.g. `{{request.headers.}}`).
329fn named_ref(name: &str, full_match: &str, make: impl FnOnce(&str) -> TemplateRef) -> Classified {
330    if name.is_empty() {
331        Classified::Warn(unknown_ref_warning(full_match))
332    } else {
333        Classified::Ref(make(name))
334    }
335}
336
337fn render_ref(r: &TemplateRef, ctx: &Context, out: &mut String) {
338    match r {
339        TemplateRef::RequestMethod => out.push_str(&ctx.request.method),
340        TemplateRef::RequestPath => out.push_str(&ctx.request.path),
341        TemplateRef::RequestHost => out.push_str(&ctx.request.host),
342        TemplateRef::RequestScheme => out.push_str(&ctx.request.scheme),
343        TemplateRef::RequestBody => {
344            out.push_str(&String::from_utf8_lossy(&ctx.request.body));
345        }
346        TemplateRef::RequestHeader(name) => {
347            let key = name.to_lowercase();
348            if let Some(v) = ctx.request.headers.get(&key).and_then(|v| v.first()) {
349                out.push_str(v);
350            }
351        }
352        TemplateRef::RequestQuery(name) => {
353            if let Some(v) = ctx.request.query_params.get(name).and_then(|v| v.first()) {
354                out.push_str(v);
355            }
356        }
357        TemplateRef::RequestCookie(name) => {
358            if let Some(v) = cookie_value(ctx, name) {
359                out.push_str(&v);
360            }
361        }
362        TemplateRef::ResponseStatus => out.push_str(&ctx.response.status_code.to_string()),
363        TemplateRef::ResponseBody => {
364            out.push_str(&String::from_utf8_lossy(&ctx.response.body));
365        }
366        TemplateRef::ResponseHeader(name) => {
367            let key = name.to_lowercase();
368            if let Some(v) = ctx.response.headers.get(&key).and_then(|v| v.first()) {
369                out.push_str(v);
370            }
371        }
372        TemplateRef::Message(key) => {
373            if let Some(v) = message_str(ctx, key) {
374                out.push_str(&v);
375            }
376        }
377        TemplateRef::ClientIp => out.push_str(split_remote_addr(&ctx.request.remote_addr).0),
378        TemplateRef::ClientPort => {
379            if let Some(port) = split_remote_addr(&ctx.request.remote_addr).1 {
380                out.push_str(port);
381            }
382        }
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
390    use bytes::Bytes;
391    use std::collections::HashMap;
392
393    fn test_ctx() -> Context {
394        let mut headers = HashMap::new();
395        headers.insert("x-user-id".to_string(), vec!["u-42".to_string()]);
396        headers.insert(
397            "cookie".to_string(),
398            vec!["session=abc123; theme=dark".to_string()],
399        );
400        let mut query = HashMap::new();
401        query.insert(
402            "name".to_string(),
403            vec!["jack".to_string(), "jill".to_string()],
404        );
405        let mut message = HashMap::new();
406        message.insert("consumer.key_id".to_string(), serde_json::json!("key-123"));
407        message.insert("retries".to_string(), serde_json::json!(3));
408
409        Context {
410            request: GatewayRequest {
411                method: "GET".to_string(),
412                path: "/api/users".to_string(),
413                host: "example.com".to_string(),
414                scheme: "https".to_string(),
415                headers,
416                query_params: query,
417                body: Bytes::from_static(b"req-body"),
418                remote_addr: "10.1.2.3:44321".to_string(),
419                protocol: Protocol::Http1,
420            },
421            response: GatewayResponse {
422                status_code: 200,
423                headers: {
424                    let mut h = HashMap::new();
425                    h.insert("x-cache".to_string(), vec!["HIT".to_string()]);
426                    h
427                },
428                body: Bytes::from_static(b"resp-body"),
429                stream: None,
430            },
431            message,
432            errors: Vec::new(),
433        }
434    }
435
436    fn no_env(_: &str) -> Option<String> {
437        None
438    }
439
440    // ---- Parsing / structure ----------------------------------------------
441
442    #[test]
443    fn test_literal_only_fast_path() {
444        let (tpl, warnings) = Template::parse("just plain text, no braces here");
445        assert!(warnings.is_empty());
446        assert!(tpl.is_literal());
447        let ctx = test_ctx();
448        match tpl.render(&ctx) {
449            Cow::Borrowed(s) => assert_eq!(s, "just plain text, no braces here"),
450            Cow::Owned(_) => panic!("literal-only template must render Cow::Borrowed"),
451        }
452    }
453
454    #[test]
455    fn test_parse_known_refs() {
456        let (tpl, warnings) = Template::parse("{{request.method}} {{ response.status }}");
457        assert!(warnings.is_empty(), "warnings: {warnings:?}");
458        assert!(!tpl.is_literal());
459        let refs: Vec<&TemplateRef> = tpl
460            .segments
461            .iter()
462            .filter_map(|s| match s {
463                Segment::Ref(r) => Some(r),
464                _ => None,
465            })
466            .collect();
467        assert_eq!(refs.len(), 2);
468        assert_eq!(refs[0], &TemplateRef::RequestMethod);
469        assert_eq!(refs[1], &TemplateRef::ResponseStatus);
470    }
471
472    #[test]
473    fn test_unknown_namespace_passes_silently() {
474        for input in ["{{ $1 }}", "{{mustache}}", "{{body.x}}"] {
475            let (tpl, warnings) = Template::parse(input);
476            assert!(warnings.is_empty(), "input {input}: warnings {warnings:?}");
477            assert!(tpl.is_literal(), "input {input}: should be literal");
478            let ctx = test_ctx();
479            assert_eq!(
480                tpl.render(&ctx),
481                input,
482                "input {input}: should pass through verbatim"
483            );
484        }
485    }
486
487    #[test]
488    fn test_known_namespace_bad_leaf_warns() {
489        for input in ["{{request.headres.x}}", "{{client.mac}}"] {
490            let (tpl, warnings) = Template::parse(input);
491            assert_eq!(warnings.len(), 1, "input {input}: warnings {warnings:?}");
492            assert!(tpl.is_literal());
493            let ctx = test_ctx();
494            assert_eq!(tpl.render(&ctx), input);
495        }
496    }
497
498    #[test]
499    fn test_env_substituted_at_parse() {
500        let env = |name: &str| -> Option<String> {
501            if name == "REGION" {
502                Some("eu".to_string())
503            } else {
504                None
505            }
506        };
507        let (tpl, warnings) = Template::parse_with_env("{{env.REGION}}", &env);
508        assert!(warnings.is_empty());
509        assert!(tpl.is_literal());
510        let ctx = test_ctx();
511        assert_eq!(tpl.render(&ctx), "eu");
512    }
513
514    #[test]
515    fn test_env_unset_passes_and_warns() {
516        let (tpl, warnings) = Template::parse_with_env("{{env.NOPE}}", &no_env);
517        assert_eq!(warnings.len(), 1);
518        assert!(tpl.is_literal());
519        let ctx = test_ctx();
520        assert_eq!(tpl.render(&ctx), "{{env.NOPE}}");
521    }
522
523    #[test]
524    fn test_unclosed_braces_literal() {
525        let (tpl, warnings) = Template::parse("{{request.method");
526        assert!(warnings.is_empty());
527        assert!(tpl.is_literal());
528        let ctx = test_ctx();
529        assert_eq!(tpl.render(&ctx), "{{request.method");
530    }
531
532    #[test]
533    fn test_message_key_with_dots() {
534        let (tpl, warnings) = Template::parse("{{message.consumer.key_id}}");
535        assert!(warnings.is_empty());
536        assert_eq!(
537            tpl.segments,
538            vec![Segment::Ref(TemplateRef::Message(
539                "consumer.key_id".to_string()
540            ))]
541        );
542    }
543
544    // ---- Rendering ----------------------------------------------------------
545
546    #[test]
547    fn test_render_request_basics() {
548        let (tpl, _) = Template::parse(
549            "{{request.method}} {{request.path}} {{request.host}} {{request.scheme}}",
550        );
551        let ctx = test_ctx();
552        assert_eq!(tpl.render(&ctx), "GET /api/users example.com https");
553    }
554
555    #[test]
556    fn test_render_headers_case_insensitive_dashes() {
557        let (tpl, warnings) = Template::parse("{{request.headers.X-User-ID}}");
558        assert!(warnings.is_empty());
559        let ctx = test_ctx();
560        assert_eq!(tpl.render(&ctx), "u-42");
561    }
562
563    #[test]
564    fn test_render_response_header_and_status() {
565        let (tpl, _) = Template::parse("{{response.status}} {{response.headers.x-cache}}");
566        let ctx = test_ctx();
567        assert_eq!(tpl.render(&ctx), "200 HIT");
568    }
569
570    #[test]
571    fn test_render_query_first_value() {
572        let (tpl, _) = Template::parse("{{request.query.name}}");
573        let ctx = test_ctx();
574        assert_eq!(tpl.render(&ctx), "jack");
575    }
576
577    #[test]
578    fn test_render_cookie() {
579        let (tpl, _) = Template::parse("{{request.cookies.theme}}");
580        let ctx = test_ctx();
581        assert_eq!(tpl.render(&ctx), "dark");
582    }
583
584    #[test]
585    fn test_render_message_stringified() {
586        let (tpl, _) = Template::parse("{{message.consumer.key_id}} {{message.retries}}");
587        let ctx = test_ctx();
588        assert_eq!(tpl.render(&ctx), "key-123 3");
589    }
590
591    #[test]
592    fn test_render_client_ip_port() {
593        let (tpl, _) = Template::parse("{{client.ip}}:{{client.port}}");
594        let ctx = test_ctx();
595        assert_eq!(tpl.render(&ctx), "10.1.2.3:44321");
596    }
597
598    #[test]
599    fn test_render_bodies_lossy() {
600        let (tpl, _) = Template::parse("{{request.body}}|{{response.body}}");
601        let ctx = test_ctx();
602        assert_eq!(tpl.render(&ctx), "req-body|resp-body");
603    }
604
605    #[test]
606    fn test_absent_subject_renders_empty() {
607        let (tpl, _) = Template::parse("[{{request.headers.missing}}]");
608        let ctx = test_ctx();
609        assert_eq!(tpl.render(&ctx), "[]");
610    }
611
612    #[test]
613    fn test_dollar_untouched_in_render() {
614        let (tpl, _) = Template::parse("pa$sword4 {{request.method}}");
615        let ctx = test_ctx();
616        assert_eq!(tpl.render(&ctx), "pa$sword4 GET");
617    }
618
619    // ---- Legacy interop -------------------------------------------------------
620
621    #[test]
622    fn test_render_with_legacy_applies_dollar_after() {
623        let (tpl, _) = Template::parse("{{request.method}} $uri");
624        let ctx = test_ctx();
625        assert_eq!(tpl.render_with_legacy(&ctx), "GET /api/users");
626    }
627
628    #[test]
629    fn test_render_plain_ignores_dollar() {
630        let (tpl, _) = Template::parse("$uri");
631        let ctx = test_ctx();
632        assert_eq!(tpl.render(&ctx), "$uri");
633    }
634
635    #[test]
636    fn test_legacy_does_not_interpolate_ref_output() {
637        // A ref's rendered value (`pa$sword4`, arriving as runtime data through
638        // a header) must survive byte-identical, while a legacy `$uri` literal
639        // in the same template still resolves — proving the `$` pass only
640        // touches literal segments, not ref output.
641        let mut ctx = test_ctx();
642        ctx.request
643            .headers
644            .insert("x-password".to_string(), vec!["pa$sword4".to_string()]);
645        let (tpl, _) = Template::parse("{{request.headers.x-password}} $uri");
646        assert_eq!(tpl.render_with_legacy(&ctx), "pa$sword4 /api/users");
647    }
648
649    #[test]
650    fn test_legacy_ref_output_cannot_read_other_vars() {
651        // A client-controlled header value that looks like a `$var` reference
652        // must not be able to read another part of the context back out when
653        // it flows through a ref in a legacy-enabled field.
654        let mut ctx = test_ctx();
655        ctx.request.headers.insert(
656            "authorization".to_string(),
657            vec!["Bearer secret-token-xyz".to_string()],
658        );
659        ctx.request.headers.insert(
660            "x-tenant".to_string(),
661            vec!["$http_authorization".to_string()],
662        );
663        let (tpl, _) = Template::parse("tenant={{request.headers.x-tenant}}");
664        let rendered = tpl.render_with_legacy(&ctx);
665        assert_eq!(rendered, "tenant=$http_authorization");
666        assert!(
667            !rendered.contains("secret-token-xyz"),
668            "ref output must not be re-interpolated as a $var read primitive: {rendered}"
669        );
670    }
671
672    /// `{{response.body}}` parses into a `ResponseBody` ref, so a node that
673    /// renders this template genuinely reads the body and must not be treated
674    /// as stream-safe.
675    #[test]
676    fn test_template_reports_a_response_body_reference() {
677        let (tpl, _) = Template::parse("status={{response.body}}");
678        assert!(tpl.references_response_body());
679    }
680
681    /// The legacy `$resp_body` is resolved at render time and never becomes a
682    /// segment, so a segment-only scan would miss it entirely -- the exact
683    /// shape of the silent-empty-body bug this guards against.
684    #[test]
685    fn test_template_reports_a_legacy_resp_body_reference() {
686        let (tpl, _) = Template::parse("body=$resp_body");
687        assert!(tpl.references_response_body());
688    }
689
690    /// A template referencing other parts of the context must stay stream-safe;
691    /// over-reporting here would needlessly buffer most real policies.
692    #[test]
693    fn test_template_without_a_body_reference_is_stream_safe() {
694        let (tpl, _) = Template::parse("{{request.path}} {{response.status}} $http_host");
695        assert!(!tpl.references_response_body());
696        let (literal, _) = Template::parse("a plain string");
697        assert!(!literal.references_response_body());
698    }
699}