Skip to main content

vector/
template.rs

1//! Functionality for managing template fields used by Vector's sinks.
2use std::{borrow::Cow, convert::TryFrom, fmt, hash::Hash, path::PathBuf, sync::LazyLock};
3
4use bytes::Bytes;
5use chrono::{
6    FixedOffset, Utc,
7    format::{Item, strftime::StrftimeItems},
8};
9use http::Uri;
10use regex::Regex;
11use snafu::Snafu;
12use tracing::warn;
13use vector_lib::{
14    configurable::{ConfigurableNumber, ConfigurableString, NumberClass, configurable_component},
15    gauge,
16    internal_event::GaugeName,
17    lookup::lookup_v2::parse_target_path,
18};
19
20use crate::{
21    config::log_schema,
22    event::{EventRef, Metric, Value},
23};
24
25static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(?P<key>[^\}]+)\}\}").unwrap());
26
27/// Errors raised whilst parsing a Template field.
28#[allow(missing_docs)]
29#[derive(Clone, Debug, Eq, PartialEq, Snafu)]
30pub enum TemplateParseError {
31    #[snafu(display("Invalid strftime item"))]
32    StrftimeError,
33    #[snafu(display(
34        "Invalid field path in template {:?} (see https://vector.dev/docs/reference/configuration/template-syntax/)",
35        path
36    ))]
37    InvalidPathSyntax { path: String },
38    #[snafu(display("Invalid numeric template"))]
39    InvalidNumericTemplate { template: String },
40}
41
42/// Errors raised whilst rendering a Template.
43#[allow(missing_docs)]
44#[derive(Clone, Debug, Eq, PartialEq, Snafu)]
45pub enum TemplateRenderingError {
46    #[snafu(display("Missing fields on event: {:?}", missing_keys))]
47    MissingKeys { missing_keys: Vec<String> },
48    #[snafu(display("Not numeric: {:?}", input))]
49    NotNumeric { input: String },
50    /// The rendered value was rejected by the confinement check attached to
51    /// this template — the event should be dropped as an intentional discard.
52    ///
53    /// `rendered_preview` is bounded to [`CONFINED_PREVIEW_BYTES`] bytes to
54    /// avoid two problems: leaking secrets in fields that carry credentials
55    /// (e.g. `Authorization: Bearer ...` header templates), and amplifying
56    /// attacker-controlled oversized field values into logs.
57    #[snafu(display(
58        "rendered value ({rendered_len} bytes, preview {rendered_preview:?}) \
59         confined: {message}"
60    ))]
61    Confined {
62        rendered_preview: String,
63        rendered_len: usize,
64        message: String,
65    },
66}
67
68/// Maximum number of bytes of a rejected rendered value to include in a
69/// [`TemplateRenderingError::Confined`] error. Kept small so log lines
70/// remain bounded even under attacker-controlled input.
71pub const CONFINED_PREVIEW_BYTES: usize = 32;
72
73/// Build a bounded preview of a rendered value for inclusion in
74/// [`TemplateRenderingError::Confined`]. Truncates on a UTF-8 char boundary.
75pub fn confined_preview(rendered: &str) -> String {
76    if rendered.len() <= CONFINED_PREVIEW_BYTES {
77        return rendered.to_string();
78    }
79    let mut end = CONFINED_PREVIEW_BYTES;
80    while end > 0 && !rendered.is_char_boundary(end) {
81        end -= 1;
82    }
83    rendered.get(..end).unwrap_or("").to_string()
84}
85
86/// A templated field.
87///
88/// In many cases, components can be configured so that part of the component's functionality can be
89/// customized on a per-event basis. For example, you have a sink that writes events to a file and you want to
90/// specify which file an event should go to by using an event field as part of the
91/// input to the filename used.
92///
93/// By using `Template`, users can specify either fixed strings or templated strings. Templated strings use a common syntax to
94/// refer to fields in an event that is used as the input data when rendering the template. An example of a fixed string
95/// is `my-file.log`. An example of a template string is `my-file-{{key}}.log`, where `{{key}}`
96/// is the key's value when the template is rendered into a string.
97#[configurable_component]
98#[configurable(metadata(docs::templateable))]
99#[derive(Clone, Default)]
100#[serde(try_from = "String", into = "String")]
101pub struct Template {
102    src: String,
103
104    #[serde(skip)]
105    parts: Vec<Part>,
106
107    #[serde(skip)]
108    is_static: bool,
109
110    #[serde(skip)]
111    reserve_size: usize,
112
113    #[serde(skip)]
114    tz_offset: Option<FixedOffset>,
115
116    /// Optional confinement check attached at build time by sinks that render
117    /// templates into security-relevant identifiers (file paths, object-storage
118    /// keys, HDFS paths, …). `render_string` runs this check after rendering
119    /// and returns [`TemplateRenderingError::Confined`] if it fails.
120    ///
121    /// Skipped for serialization, hashing, and equality — two templates with
122    /// the same source string are the same template regardless of whether a
123    /// confinement hook has been attached.
124    #[serde(skip)]
125    confinement: Option<ConfinementChecker>,
126}
127
128impl fmt::Debug for Template {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.debug_struct("Template")
131            .field("src", &self.src)
132            .field("is_static", &self.is_static)
133            .field("tz_offset", &self.tz_offset)
134            .field("confinement", &self.confinement.as_ref().map(|_| "<fn>"))
135            .finish()
136    }
137}
138
139impl PartialEq for Template {
140    fn eq(&self, other: &Self) -> bool {
141        self.src == other.src && self.tz_offset == other.tz_offset
142    }
143}
144
145impl Eq for Template {}
146
147impl Hash for Template {
148    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
149        self.src.hash(state);
150        self.tz_offset.hash(state);
151    }
152}
153
154impl TryFrom<&str> for Template {
155    type Error = TemplateParseError;
156
157    fn try_from(src: &str) -> Result<Self, Self::Error> {
158        Template::try_from(Cow::Borrowed(src))
159    }
160}
161
162impl TryFrom<String> for Template {
163    type Error = TemplateParseError;
164
165    fn try_from(src: String) -> Result<Self, Self::Error> {
166        Template::try_from(Cow::Owned(src))
167    }
168}
169
170impl TryFrom<PathBuf> for Template {
171    type Error = TemplateParseError;
172
173    fn try_from(p: PathBuf) -> Result<Self, Self::Error> {
174        Template::try_from(p.to_string_lossy().into_owned())
175    }
176}
177
178impl TryFrom<Cow<'_, str>> for Template {
179    type Error = TemplateParseError;
180
181    fn try_from(src: Cow<'_, str>) -> Result<Self, Self::Error> {
182        parse_template(&src).map(|parts| {
183            let is_static =
184                parts.is_empty() || (parts.len() == 1 && matches!(parts[0], Part::Literal(..)));
185
186            // Calculate a minimum size to reserve for rendered string. This doesn't have to be
187            // exact, and can't be because of references and time format specifiers. We just want a
188            // better starting number than 0 to avoid the first reallocations if possible.
189            let reserve_size = parts
190                .iter()
191                .map(|part| match part {
192                    Part::Literal(lit) => lit.len(),
193                    // We can't really put a useful number here, assume at least one byte will come
194                    // from the input event.
195                    Part::Reference(_path) => 1,
196                    Part::Strftime(parsed) => parsed.reserve_size(),
197                })
198                .sum();
199
200            Template {
201                parts,
202                src: src.into_owned(),
203                is_static,
204                reserve_size,
205                tz_offset: None,
206                confinement: None,
207            }
208        })
209    }
210}
211
212impl From<Template> for String {
213    fn from(template: Template) -> String {
214        template.src
215    }
216}
217
218impl fmt::Display for Template {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        self.src.fmt(f)
221    }
222}
223
224// This is safe because we literally defer to `String` for the schema of `Template`.
225impl ConfigurableString for Template {}
226
227impl Template {
228    /// Set tz offset.
229    pub const fn with_tz_offset(mut self, tz_offset: Option<FixedOffset>) -> Self {
230        self.tz_offset = tz_offset;
231        self
232    }
233
234    /// Attach a [`ConfinementChecker`] to this template.
235    ///
236    /// After rendering, `render_string` passes the result to the checker. On
237    /// failure, `render_string` returns [`TemplateRenderingError::Confined`]
238    /// and the caller should discard the event as an intentional security drop.
239    ///
240    /// Called by [`Template::confine`] at build time. Call sites that don't
241    /// set a checker are unaffected — [`TemplateRenderingError::Confined`] can
242    /// never be produced by a template with no checker attached.
243    pub(crate) fn with_confinement_checker(mut self, checker: ConfinementChecker) -> Self {
244        self.confinement = Some(checker);
245        self
246    }
247
248    /// Confine this template to its literal prefix.
249    ///
250    /// Consumes `self` and returns it with a confinement checker attached (if
251    /// applicable). Three outcomes:
252    ///
253    /// - `dangerously_allow_unconfined_template_resolution` is `true` — no
254    ///   checker attached; a SECURITY warning is emitted. Full opt-out: both
255    ///   startup validation and runtime checks are bypassed.
256    /// - Static template (no event-field references) — returned unchanged.
257    /// - Dynamic template with a non-empty literal prefix — checker attached.
258    /// - Dynamic template with no derivable prefix — error.
259    pub fn confine(
260        self,
261        config: &ConfinementConfig,
262        component_name: &'static str,
263        field_name: &'static str,
264    ) -> crate::Result<Self> {
265        // Full opt-out: bypass all confinement for this template (startup AND
266        // runtime). The per-sink gauge is emitted by each sink's build() on
267        // the success path — not here — so a failed reload cannot clobber the
268        // still-active sink's gauge.
269        if config.dangerously_allow_unconfined_template_resolution {
270            ConfinementConfig::warn_unconfined_template("sink", component_name, field_name);
271            return Ok(self);
272        }
273        match ConfinementChecker::for_template(&self) {
274            Ok(Some(checker)) => Ok(self.with_confinement_checker(checker)),
275            // Static template (no event-field references) — always safe.
276            Ok(None) => Ok(self),
277            Err(e) => Err(e.into()),
278        }
279    }
280
281    /// Run the confinement check attached to this template against a raw
282    /// string, without going through a normal render. Callers that bypass
283    /// template rendering entirely (for example the elasticsearch sink's
284    /// `auto_routing` path, which reads routing values directly off events)
285    /// must call this to keep the confinement contract intact for those
286    /// values.
287    ///
288    /// Returns `Ok(())` if no confinement checker is attached (static
289    /// templates and pure-dynamic templates with the opt-out set both fall
290    /// through this way), or if the checker accepts the value. Returns
291    /// [`TemplateRenderingError::Confined`] otherwise.
292    pub fn check_confinement(&self, rendered: &str) -> Result<(), TemplateRenderingError> {
293        if let Some(checker) = &self.confinement {
294            checker
295                .confine(rendered)
296                .map_err(|e| TemplateRenderingError::Confined {
297                    rendered_preview: confined_preview(rendered),
298                    rendered_len: rendered.len(),
299                    message: e.to_string(),
300                })?;
301        }
302        Ok(())
303    }
304
305    /// Renders the given template with data from the event, returning raw bytes.
306    pub fn render<'a>(
307        &self,
308        event: impl Into<EventRef<'a>>,
309    ) -> Result<Bytes, TemplateRenderingError> {
310        self.render_string(event.into()).map(Into::into)
311    }
312
313    /// Renders the given template with data from the event.
314    ///
315    /// If a confinement check was attached at build time (see
316    /// [`Template::confine`]),
317    /// it runs after rendering. A confinement failure returns
318    /// [`TemplateRenderingError::Confined`] — callers should emit an
319    /// intentional-drop event and discard the event, not treat it as a
320    /// field-missing error.
321    pub fn render_string<'a>(
322        &self,
323        event: impl Into<EventRef<'a>>,
324    ) -> Result<String, TemplateRenderingError> {
325        let rendered = if self.is_static {
326            self.src.clone()
327        } else {
328            self.render_event(event.into())?
329        };
330        if let Some(checker) = &self.confinement {
331            checker
332                .confine(&rendered)
333                .map_err(|e| TemplateRenderingError::Confined {
334                    rendered_preview: confined_preview(&rendered),
335                    rendered_len: rendered.len(),
336                    message: e.to_string(),
337                })?;
338        }
339        Ok(rendered)
340    }
341
342    fn render_event(&self, event: EventRef<'_>) -> Result<String, TemplateRenderingError> {
343        let mut missing_keys = Vec::new();
344        let mut out = String::with_capacity(self.reserve_size);
345        for part in &self.parts {
346            match part {
347                Part::Literal(lit) => out.push_str(lit),
348                Part::Strftime(items) => {
349                    out.push_str(&render_timestamp(items, event, self.tz_offset))
350                }
351                Part::Reference(key) => {
352                    out.push_str(
353                        &match event {
354                            EventRef::Log(log) => log
355                                .parse_path_and_get_value(key)
356                                .ok()
357                                .and_then(|v| v.map(Value::to_string_lossy)),
358                            EventRef::Metric(metric) => {
359                                render_metric_field(key, metric).map(Cow::Borrowed)
360                            }
361                            EventRef::Trace(trace) => trace
362                                .parse_path_and_get_value(key)
363                                .ok()
364                                .and_then(|v| v.map(Value::to_string_lossy)),
365                        }
366                        .unwrap_or_else(|| {
367                            missing_keys.push(key.to_owned());
368                            Cow::Borrowed("")
369                        }),
370                    );
371                }
372            }
373        }
374        if missing_keys.is_empty() {
375            Ok(out)
376        } else {
377            Err(TemplateRenderingError::MissingKeys { missing_keys })
378        }
379    }
380
381    /// Returns the names of the fields that are rendered in this template.
382    pub fn get_fields(&self) -> Option<Vec<String>> {
383        let parts: Vec<_> = self
384            .parts
385            .iter()
386            .filter_map(|part| {
387                if let Part::Reference(r) = part {
388                    Some(r.to_owned())
389                } else {
390                    None
391                }
392            })
393            .collect();
394        (!parts.is_empty()).then_some(parts)
395    }
396
397    /// Longest leading substring of the template source that is rendered
398    /// verbatim — no `{{ field }}` reference and no strftime specifier.
399    ///
400    /// Sinks use this to derive a confinement boundary from the
401    /// operator-authored portion of the template.
402    pub fn literal_prefix(&self) -> &str {
403        let bytes = self.src.as_bytes();
404        let mut i = 0;
405        while i < bytes.len() {
406            // `{{` starts a field reference.
407            if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
408                break;
409            }
410            // Any `%` may start a strftime sequence. `%%` is an escaped `%`,
411            // but in a mixed literal like `/tmp/100%%/%Y/` the whole segment
412            // is processed by chrono, which decodes `%%` to `%` and expands
413            // `%Y` to the year. We cannot know what chrono will emit without
414            // an actual timestamp, so stop at the first `%` unconditionally.
415            if bytes[i] == b'%' {
416                break;
417            }
418            i += 1;
419        }
420        self.src.split_at(i).0
421    }
422
423    #[allow(clippy::missing_const_for_fn)] // Adding `const` results in https://doc.rust-lang.org/error_codes/E0015.html
424    /// Returns a reference to the template string.
425    pub fn get_ref(&self) -> &str {
426        &self.src
427    }
428
429    /// Returns `true` if this template string has a length of zero, and `false` otherwise.
430    pub const fn is_empty(&self) -> bool {
431        self.src.is_empty()
432    }
433
434    /// A dynamic template string contains sections that depend on the input event or time.
435    pub const fn is_dynamic(&self) -> bool {
436        !self.is_static
437    }
438}
439
440/// The source of a `uint` template. May be a constant numeric value or a template string.
441#[derive(Clone, Debug, Eq, Hash, PartialEq)]
442#[configurable_component]
443#[serde(untagged)]
444enum UnsignedIntTemplateSource {
445    /// A static unsigned number.
446    Number(u64),
447    /// A string, which may be a template.
448    String(String),
449}
450
451impl Default for UnsignedIntTemplateSource {
452    fn default() -> Self {
453        Self::Number(Default::default())
454    }
455}
456
457impl fmt::Display for UnsignedIntTemplateSource {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        match self {
460            Self::Number(i) => i.fmt(f),
461            Self::String(s) => s.fmt(f),
462        }
463    }
464}
465
466/// Unsigned integer template.
467#[configurable_component]
468#[configurable(metadata(docs::templateable))]
469#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
470#[serde(
471    try_from = "UnsignedIntTemplateSource",
472    into = "UnsignedIntTemplateSource"
473)]
474pub struct UnsignedIntTemplate {
475    src: UnsignedIntTemplateSource,
476
477    #[serde(skip)]
478    parts: Vec<Part>,
479
480    #[serde(skip)]
481    tz_offset: Option<FixedOffset>,
482}
483
484impl TryFrom<UnsignedIntTemplateSource> for UnsignedIntTemplate {
485    type Error = TemplateParseError;
486
487    fn try_from(src: UnsignedIntTemplateSource) -> Result<Self, Self::Error> {
488        match src {
489            UnsignedIntTemplateSource::Number(num) => Ok(UnsignedIntTemplate {
490                src: UnsignedIntTemplateSource::Number(num),
491                parts: Vec::new(),
492                tz_offset: None,
493            }),
494            UnsignedIntTemplateSource::String(s) => UnsignedIntTemplate::try_from(s),
495        }
496    }
497}
498
499impl From<UnsignedIntTemplate> for UnsignedIntTemplateSource {
500    fn from(template: UnsignedIntTemplate) -> UnsignedIntTemplateSource {
501        template.src
502    }
503}
504
505impl TryFrom<&str> for UnsignedIntTemplate {
506    type Error = TemplateParseError;
507
508    fn try_from(src: &str) -> Result<Self, Self::Error> {
509        UnsignedIntTemplate::try_from(Cow::Borrowed(src))
510    }
511}
512
513impl TryFrom<String> for UnsignedIntTemplate {
514    type Error = TemplateParseError;
515
516    fn try_from(src: String) -> Result<Self, Self::Error> {
517        UnsignedIntTemplate::try_from(Cow::Owned(src))
518    }
519}
520
521impl From<u64> for UnsignedIntTemplate {
522    fn from(num: u64) -> UnsignedIntTemplate {
523        UnsignedIntTemplate {
524            src: UnsignedIntTemplateSource::Number(num),
525            parts: Vec::new(),
526            tz_offset: None,
527        }
528    }
529}
530
531impl TryFrom<Cow<'_, str>> for UnsignedIntTemplate {
532    type Error = TemplateParseError;
533
534    fn try_from(src: Cow<'_, str>) -> Result<Self, Self::Error> {
535        parse_template(&src).and_then(|parts| {
536            let is_static =
537                parts.is_empty() || (parts.len() == 1 && matches!(parts[0], Part::Literal(..)));
538
539            if is_static {
540                match src.parse::<u64>() {
541                    Ok(num) => Ok(UnsignedIntTemplate {
542                        src: UnsignedIntTemplateSource::Number(num),
543                        parts,
544                        tz_offset: None,
545                    }),
546                    Err(_) => Err(TemplateParseError::InvalidNumericTemplate {
547                        template: src.into_owned(),
548                    }),
549                }
550            } else {
551                Ok(UnsignedIntTemplate {
552                    parts,
553                    src: UnsignedIntTemplateSource::String(src.into_owned()),
554                    tz_offset: None,
555                })
556            }
557        })
558    }
559}
560
561impl From<UnsignedIntTemplate> for String {
562    fn from(template: UnsignedIntTemplate) -> String {
563        template.src.to_string()
564    }
565}
566
567impl fmt::Display for UnsignedIntTemplate {
568    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569        self.src.fmt(f)
570    }
571}
572
573impl ConfigurableString for UnsignedIntTemplate {}
574impl ConfigurableNumber for UnsignedIntTemplate {
575    type Numeric = u64;
576
577    fn class() -> NumberClass {
578        NumberClass::Unsigned
579    }
580}
581
582impl UnsignedIntTemplate {
583    /// Renders the given template with data from the event.
584    pub fn render<'a>(
585        &self,
586        event: impl Into<EventRef<'a>>,
587    ) -> Result<u64, TemplateRenderingError> {
588        match self.src {
589            UnsignedIntTemplateSource::Number(num) => Ok(num),
590            UnsignedIntTemplateSource::String(_) => self.render_event(event.into()),
591        }
592    }
593
594    /// set tz offset
595    pub const fn with_tz_offset(mut self, tz_offset: Option<FixedOffset>) -> Self {
596        self.tz_offset = tz_offset;
597        self
598    }
599
600    fn render_event(&self, event: EventRef<'_>) -> Result<u64, TemplateRenderingError> {
601        let mut missing_keys = Vec::new();
602        let mut out = String::with_capacity(20);
603        for part in &self.parts {
604            match part {
605                Part::Literal(lit) => out.push_str(lit),
606                Part::Reference(key) => {
607                    out.push_str(
608                        &match event {
609                            EventRef::Log(log) => log
610                                .parse_path_and_get_value(key)
611                                .ok()
612                                .and_then(|v| v.map(Value::to_string_lossy)),
613                            EventRef::Metric(metric) => {
614                                render_metric_field(key, metric).map(Cow::Borrowed)
615                            }
616                            EventRef::Trace(trace) => trace
617                                .parse_path_and_get_value(key)
618                                .ok()
619                                .and_then(|v| v.map(Value::to_string_lossy)),
620                        }
621                        .unwrap_or_else(|| {
622                            missing_keys.push(key.to_owned());
623                            Cow::Borrowed("")
624                        }),
625                    );
626                }
627                Part::Strftime(items) => {
628                    out.push_str(&render_timestamp(items, event, self.tz_offset))
629                }
630            }
631        }
632        if missing_keys.is_empty() {
633            out.parse::<u64>()
634                .map_err(|_| TemplateRenderingError::NotNumeric { input: out })
635        } else {
636            Err(TemplateRenderingError::MissingKeys { missing_keys })
637        }
638    }
639
640    /// Returns the names of the fields that are rendered in this template.
641    pub fn get_fields(&self) -> Option<Vec<String>> {
642        let parts: Vec<_> = self
643            .parts
644            .iter()
645            .filter_map(|part| {
646                if let Part::Reference(r) = part {
647                    Some(r.to_owned())
648                } else {
649                    None
650                }
651            })
652            .collect();
653        (!parts.is_empty()).then_some(parts)
654    }
655}
656
657/// One part of the template string after parsing.
658#[derive(Clone, Debug, Eq, Hash, PartialEq)]
659enum Part {
660    /// A literal piece of text to be copied verbatim into the output.
661    Literal(String),
662    /// A literal piece of text containing a time format string.
663    Strftime(ParsedStrftime),
664    /// A reference to the source event, to be copied from the relevant field or tag.
665    Reference(String),
666}
667
668// Wrap the parsed time formatter in order to provide `impl Hash` and some convenience functions.
669#[derive(Clone, Debug, Eq, Hash, PartialEq)]
670struct ParsedStrftime(Box<[Item<'static>]>);
671
672impl ParsedStrftime {
673    fn parse(fmt: &str) -> Result<Self, TemplateParseError> {
674        Ok(Self(
675            StrftimeItems::new(fmt)
676                .map(|item| match item {
677                    // Box the references so they outlive the reference
678                    Item::Space(space) => Item::OwnedSpace(space.into()),
679                    Item::Literal(lit) => Item::OwnedLiteral(lit.into()),
680                    // And copy all the others
681                    Item::Fixed(f) => Item::Fixed(f),
682                    Item::Numeric(num, pad) => Item::Numeric(num, pad),
683                    Item::Error => Item::Error,
684                    Item::OwnedSpace(space) => Item::OwnedSpace(space),
685                    Item::OwnedLiteral(lit) => Item::OwnedLiteral(lit),
686                })
687                .map(|item| {
688                    matches!(item, Item::Error)
689                        .then(|| Err(TemplateParseError::StrftimeError))
690                        .unwrap_or(Ok(item))
691                })
692                .collect::<Result<Vec<_>, _>>()?
693                .into(),
694        ))
695    }
696
697    fn is_dynamic(&self) -> bool {
698        self.0.iter().any(|item| match item {
699            Item::Fixed(_) => true,
700            Item::Numeric(_, _) => true,
701            Item::Error
702            | Item::Space(_)
703            | Item::OwnedSpace(_)
704            | Item::Literal(_)
705            | Item::OwnedLiteral(_) => false,
706        })
707    }
708
709    fn as_items(&self) -> impl Iterator<Item = &Item<'static>> + Clone {
710        self.0.iter()
711    }
712
713    fn reserve_size(&self) -> usize {
714        self.0
715            .iter()
716            .map(|item| match item {
717                Item::Literal(lit) => lit.len(),
718                Item::OwnedLiteral(lit) => lit.len(),
719                Item::Space(space) => space.len(),
720                Item::OwnedSpace(space) => space.len(),
721                Item::Error => 0,
722                Item::Numeric(_, _) => 2,
723                Item::Fixed(_) => 2,
724            })
725            .sum()
726    }
727}
728
729fn parse_literal(src: &str) -> Result<Part, TemplateParseError> {
730    let parsed = ParsedStrftime::parse(src)?;
731    Ok(if parsed.is_dynamic() {
732        Part::Strftime(parsed)
733    } else {
734        Part::Literal(src.to_string())
735    })
736}
737
738// Pre-parse the template string into a series of parts to be filled in at render time.
739fn parse_template(src: &str) -> Result<Vec<Part>, TemplateParseError> {
740    let mut last_end = 0;
741    let mut parts = Vec::new();
742    for cap in RE.captures_iter(src) {
743        let all = cap.get(0).expect("Capture 0 is always defined");
744        if all.start() > last_end {
745            #[expect(
746                clippy::string_slice,
747                reason = "indices come from regex match positions, always char boundaries"
748            )]
749            parts.push(parse_literal(&src[last_end..all.start()])?);
750        }
751
752        let path = cap[1].trim().to_owned();
753
754        // This checks the syntax, but doesn't yet store it for use later
755        // see: https://github.com/vectordotdev/vector/issues/14864
756        if parse_target_path(&path).is_err() {
757            return Err(TemplateParseError::InvalidPathSyntax { path });
758        }
759
760        parts.push(Part::Reference(path));
761        last_end = all.end();
762    }
763    if src.len() > last_end {
764        #[expect(
765            clippy::string_slice,
766            reason = "last_end comes from a regex match end position, always a char boundary"
767        )]
768        parts.push(parse_literal(&src[last_end..])?);
769    }
770
771    Ok(parts)
772}
773
774fn render_metric_field<'a>(key: &str, metric: &'a Metric) -> Option<&'a str> {
775    match key {
776        "name" => Some(metric.name()),
777        "namespace" => metric.namespace(),
778        _ if let Some(tag_key) = key.strip_prefix("tags.") => {
779            metric.tags().and_then(|tags| tags.get(tag_key))
780        }
781        _ => None,
782    }
783}
784
785fn render_timestamp(
786    items: &ParsedStrftime,
787    event: EventRef<'_>,
788    tz_offset: Option<FixedOffset>,
789) -> String {
790    let timestamp = match event {
791        EventRef::Log(log) => log.get_timestamp().and_then(Value::as_timestamp).copied(),
792        EventRef::Metric(metric) => metric.timestamp(),
793        EventRef::Trace(trace) => {
794            log_schema()
795                .timestamp_key_target_path()
796                .and_then(|timestamp_key| {
797                    trace
798                        .get(timestamp_key)
799                        .and_then(Value::as_timestamp)
800                        .copied()
801                })
802        }
803    }
804    .unwrap_or_else(Utc::now);
805
806    match tz_offset {
807        Some(offset) => timestamp
808            .with_timezone(&offset)
809            .format_with_items(items.as_items())
810            .to_string(),
811        None => timestamp
812            .with_timezone(&chrono::Utc)
813            .format_with_items(items.as_items())
814            .to_string(),
815    }
816}
817
818use crate::sinks::util::path_confinement::MAX_RENDERED_PATH_LEN;
819
820#[derive(Debug, Snafu)]
821#[snafu(module(build_error))]
822pub(crate) enum BuildError {
823    /// Template has event-field references but no literal prefix to confine them to.
824    #[snafu(display(
825        "template references event fields ({fields:?}) but has no \
826         literal string prefix to derive a confinement base from. Add a static \
827         prefix to your template, or set \
828         `dangerously_allow_unconfined_template_resolution: true` to opt out."
829    ))]
830    NoDerivableBase {
831        /// The event fields referenced by the template.
832        fields: Vec<String>,
833    },
834
835    /// The only derivable prefix is a bare root (`/`), which would allow writes
836    /// to any path under the server's namespace root.
837    #[snafu(display(
838        "template has only `\"/\"` as its literal prefix (from {prefix:?}), \
839         which would permit writes anywhere in the namespace root. Add a \
840         non-root static prefix to your template, or set \
841         `dangerously_allow_unconfined_template_resolution: true` to opt out."
842    ))]
843    DerivedBaseIsRoot {
844        /// The literal prefix that resolved to root.
845        prefix: String,
846    },
847
848    /// The template is an HTTP/HTTPS URI but the static prefix ends before the
849    /// authority (host + optional port), so the rendered URL's destination host
850    /// is entirely event-controlled. Supply a static scheme + host, or set
851    /// `dangerously_allow_unconfined_template_resolution: true` to opt out.
852    #[snafu(display(
853        "HTTP/HTTPS template {prefix:?} has no static authority (host): the \
854         destination host would be fully event-controlled. Add a static host to \
855         your URI template, or set \
856         `dangerously_allow_unconfined_template_resolution: true` to opt out."
857    ))]
858    NoStaticUriAuthority {
859        /// The literal prefix that contained no host.
860        prefix: String,
861    },
862
863    /// The operator-authored URI prefix contains a percent-encoded path separator
864    /// (`%2f` or `%5c`) or a raw backslash. These would cause every rendered
865    /// event to be dropped at runtime; reject at build time instead.
866    #[snafu(display(
867        "HTTP/HTTPS URI prefix {prefix:?} contains %2F, %5C, or a raw backslash \
868         in the static portion. Use a literal `/` in the path instead."
869    ))]
870    EncodedSeparatorInUriPrefix {
871        /// The literal prefix that contained the encoded separator.
872        prefix: String,
873    },
874
875    /// The template is an HTTP/HTTPS URI containing `?` or `#` in combination
876    /// with `{{ field }}` references.
877    ///
878    /// A field-rendered value can inject a `?query` or a `#fragment` that
879    /// steers routing or (for `#`) silently drops any operator-authored
880    /// suffix through `http::Uri`'s fragment truncation.
881    #[snafu(display(
882        "HTTP/HTTPS template {template:?} mixes `{{{{ field }}}}` references \
883         with `?` or `#`, which cannot be confined. Move event-driven routing \
884         into the URL path, or set \
885         `dangerously_allow_unconfined_template_resolution: true` to opt out."
886    ))]
887    DynamicUriQueryOrFragment {
888        /// The full template source that mixed dynamic fields with `?` or `#`.
889        template: String,
890    },
891}
892
893#[derive(Debug, Snafu)]
894#[snafu(module(confine_error))]
895pub(crate) enum ConfineError {
896    /// Rendered value contains a NUL byte.
897    #[snafu(display("rendered value contains a NUL byte"))]
898    NulByte,
899
900    /// Rendered value exceeds the maximum allowed byte length.
901    #[snafu(display("rendered value is {len} bytes; maximum allowed is {max}"))]
902    TooLong {
903        /// Actual length of the rendered value in bytes.
904        len: usize,
905        /// Maximum allowed length in bytes.
906        max: usize,
907    },
908
909    /// Rendered value does not start with the required base prefix.
910    #[snafu(display("rendered value {rendered:?} does not start with the base prefix {base:?}"))]
911    OutsideBase {
912        /// The rendered value that failed confinement.
913        rendered: String,
914        /// The required base prefix.
915        base: String,
916    },
917
918    /// Rejected because a `..` segment could escape the namespace root on
919    /// filesystem-like protocols (e.g. WebHDFS) even when the string prefix
920    /// check passes (e.g. `safe/../../escape` starts with `safe/`).
921    #[snafu(display("rendered value {rendered:?} contains a `..` path segment"))]
922    DotDotSegment {
923        /// The rendered value that contained the `..` segment.
924        rendered: String,
925    },
926
927    /// Rendered URI could not be parsed.
928    #[snafu(display("rendered value {rendered:?} is not a valid URI"))]
929    UriParseFailed {
930        /// The rendered value that could not be parsed.
931        rendered: String,
932    },
933
934    /// Rendered URI has a different scheme or authority (host + port) than the
935    /// operator-configured base. This covers both `@`-userinfo injection
936    /// (`trusted.host@evil.com`) and host-extension attacks
937    /// (`trusted.host.evil.com`).
938    #[snafu(display(
939        "rendered URI {rendered:?} has authority {actual:?} but the confined base \
940         requires {expected:?}"
941    ))]
942    UriAuthorityMismatch {
943        /// The rendered value that failed confinement.
944        rendered: String,
945        /// The authority that was required.
946        expected: String,
947        /// The authority that was actually present.
948        actual: String,
949    },
950}
951
952/// Confinement checker stored on a [`Template`] at build time.
953///
954/// Dispatches to `PrefixChecker` for non-URI fields (Kafka topics, Redis
955/// keys, tenant IDs, …) or `UriChecker` for HTTP/HTTPS URI fields. Common
956/// guards (NUL bytes, length limit) run before dispatching.
957#[derive(Clone, Debug)]
958pub(crate) enum ConfinementChecker {
959    Prefix(PrefixChecker),
960    Uri(UriChecker),
961}
962
963impl ConfinementChecker {
964    pub(crate) fn for_template(tpl: &Template) -> Result<Option<Self>, BuildError> {
965        let fields = match tpl.get_fields() {
966            Some(f) => f,
967            None => return Ok(None),
968        };
969        let prefix = tpl.literal_prefix();
970        if prefix.is_empty() {
971            return Err(BuildError::NoDerivableBase { fields });
972        }
973        if prefix == "/" {
974            return Err(BuildError::DerivedBaseIsRoot {
975                prefix: prefix.to_string(),
976            });
977        }
978        // Only treat as a URI if the prefix literally starts with http:// or https://.
979        // Testing for the scheme token alone (without "://") would misfire on
980        // templates like `http_{{tenant}}` whose prefix is `http_`.
981        let lp = prefix.to_ascii_lowercase();
982        if lp.starts_with("http://") || lp.starts_with("https://") {
983            // Reject URI templates that have field references AND `?` or `#`.
984            // A static query/fragment is safe (fixed value, not
985            // event-controlled). But once a `{{ field }}` is present, the
986            // rendered path segment can smuggle either:
987            //   - a `?extra=...` query string, or
988            //   - a `#frag` that `http::Uri` truncates before our checker
989            //     sees the path, silently dropping any operator-authored
990            //     suffix like `/ingest`.
991            let src = tpl.get_ref();
992            if src.contains('?') || src.contains('#') {
993                return Err(BuildError::DynamicUriQueryOrFragment {
994                    template: src.to_string(),
995                });
996            }
997            UriChecker::from_prefix(prefix).map(|c| Some(Self::Uri(c)))
998        } else {
999            Ok(Some(Self::Prefix(PrefixChecker {
1000                base: prefix.to_string(),
1001            })))
1002        }
1003    }
1004
1005    pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
1006        if rendered.contains('\0') {
1007            return Err(ConfineError::NulByte);
1008        }
1009        if rendered.len() > MAX_RENDERED_PATH_LEN {
1010            return Err(ConfineError::TooLong {
1011                len: rendered.len(),
1012                max: MAX_RENDERED_PATH_LEN,
1013            });
1014        }
1015        match self {
1016            Self::Prefix(c) => c.confine(rendered),
1017            Self::Uri(c) => c.confine(rendered),
1018        }
1019    }
1020}
1021
1022/// Confinement for non-URI templates (Kafka topics, Redis keys, tenant IDs, …).
1023///
1024/// Enforces that the rendered value starts with the operator-controlled literal
1025/// prefix and contains no `..` path segments.
1026#[derive(Clone, Debug)]
1027pub(crate) struct PrefixChecker {
1028    base: String,
1029}
1030
1031impl PrefixChecker {
1032    pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
1033        // Reject `..` segments: on filesystem-like protocols (e.g. WebHDFS) a
1034        // value like `safe/../../escape` passes `starts_with("safe/")` but
1035        // resolves outside the namespace root on the server.
1036        if rendered.split('/').any(|seg| seg == "..") {
1037            return Err(ConfineError::DotDotSegment {
1038                rendered: rendered.to_string(),
1039            });
1040        }
1041        if !rendered.starts_with(&self.base) {
1042            return Err(ConfineError::OutsideBase {
1043                rendered: rendered.to_string(),
1044                base: self.base.clone(),
1045            });
1046        }
1047        Ok(())
1048    }
1049}
1050
1051/// Confinement for HTTP/HTTPS URI templates.
1052///
1053/// At build time the operator-authored static prefix is parsed with
1054/// `http::Uri` and its scheme, authority, and path are stored normalised
1055/// (lowercased). At render time the rendered value is also parsed with
1056/// `http::Uri` and the structured fields are compared, which avoids all the
1057/// pitfalls of raw-string heuristics (case sensitivity, percent-encoding,
1058/// `@`-injection inside the authority component, etc.).
1059///
1060/// Three checks run on every render:
1061///
1062/// 1. **Authority check** — the rendered URI's scheme and authority must match
1063///    the operator-authored values exactly. This catches `@`-userinfo injection
1064///    (`trusted.host@evil.com`) and host-extension attacks
1065///    (`trusted.host.evil.com`).
1066///
1067/// 2. **Path-prefix check** — the rendered URI's path must start with the
1068///    static path portion derived from the template prefix.
1069///
1070/// 3. **Dot-dot segment check** — no path segment may be `..`, `.%2e`,
1071///    `%2e.`, or `%2e%2e` (case-insensitive). This catches path traversal
1072///    within the same host even when the prefix check passes.
1073///
1074/// URI templates containing `?` are rejected at build time — query parameters
1075/// cannot be safely confined to a static boundary and must not appear in
1076/// dynamic URI templates.
1077#[derive(Clone, Debug)]
1078pub(crate) struct UriChecker {
1079    /// Lowercased scheme, e.g. `"https"`.
1080    scheme: String,
1081    /// Lowercased authority (host + optional port), e.g. `"api.internal"`.
1082    authority: String,
1083    /// Static path portion from the template prefix, e.g. `"/ingest/"`.
1084    path_prefix: String,
1085}
1086
1087impl UriChecker {
1088    pub(crate) fn from_prefix(prefix: &str) -> Result<Self, BuildError> {
1089        let uri = prefix
1090            .parse::<Uri>()
1091            .map_err(|_| BuildError::NoStaticUriAuthority {
1092                prefix: prefix.to_string(),
1093            })?;
1094        let scheme = uri
1095            .scheme_str()
1096            .expect("scheme present because prefix starts with http(s)://")
1097            .to_ascii_lowercase();
1098        let path = uri.path().to_ascii_lowercase();
1099        // Reject encoded path separators, encoded percents, and raw
1100        // backslashes in the operator-authored prefix. Any of these would
1101        // cause every rendered URI to fail the render-time check, silently
1102        // dropping all events; detect at build time instead.
1103        if path.contains("%2f")
1104            || path.contains("%5c")
1105            || path.contains("%25")
1106            || uri.path().contains('\\')
1107        {
1108            return Err(BuildError::EncodedSeparatorInUriPrefix {
1109                prefix: prefix.to_string(),
1110            });
1111        }
1112        match uri.authority() {
1113            Some(auth) if !auth.as_str().is_empty() => Ok(Self {
1114                scheme,
1115                authority: auth.as_str().to_ascii_lowercase(),
1116                path_prefix: uri.path().to_string(),
1117            }),
1118            _ => Err(BuildError::NoStaticUriAuthority {
1119                prefix: prefix.to_string(),
1120            }),
1121        }
1122    }
1123
1124    pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
1125        // Parse with http::Uri so all structural checks use the same tokeniser
1126        // that built the baseline — no raw-string heuristics.
1127        let uri = rendered
1128            .parse::<Uri>()
1129            .map_err(|_| ConfineError::UriParseFailed {
1130                rendered: rendered.to_string(),
1131            })?;
1132
1133        // 1. Authority check: scheme + host must exactly match the base.
1134        //    Catches @-userinfo injection and host-extension attacks.
1135        //    Both sides are lowercased so the comparison is case-insensitive.
1136        let actual_scheme = uri.scheme_str().unwrap_or("").to_ascii_lowercase();
1137        let actual_authority = uri
1138            .authority()
1139            .map(|a| a.as_str().to_ascii_lowercase())
1140            .unwrap_or_default();
1141        if actual_scheme != self.scheme || actual_authority != self.authority {
1142            return Err(ConfineError::UriAuthorityMismatch {
1143                rendered: rendered.to_string(),
1144                expected: format!("{}://{}", self.scheme, self.authority),
1145                actual: format!("{actual_scheme}://{actual_authority}"),
1146            });
1147        }
1148
1149        // 2. Path-prefix check: catches path escape when the template includes
1150        //    a static path (e.g. `https://api.internal/ingest/{{ tenant }}`).
1151        let path = uri.path();
1152        if !path.starts_with(&self.path_prefix) {
1153            return Err(ConfineError::OutsideBase {
1154                rendered: rendered.to_string(),
1155                base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
1156            });
1157        }
1158
1159        // 3. Dot-dot segment check: catches within-prefix path traversal.
1160        //    Also rejects percent-encoded variants that some servers decode
1161        //    before resolving the path (e.g. `/ingest/%2e%2e/admin`).
1162        for segment in path.split('/') {
1163            if segment == ".."
1164                || segment.eq_ignore_ascii_case("%2e%2e")
1165                || segment.eq_ignore_ascii_case(".%2e")
1166                || segment.eq_ignore_ascii_case("%2e.")
1167            {
1168                return Err(ConfineError::DotDotSegment {
1169                    rendered: rendered.to_string(),
1170                });
1171            }
1172        }
1173
1174        // 4. Reject encoded path separators, raw backslashes, and encoded
1175        //    percent signs.
1176        //    `%2f` (encoded `/`) and `%5c` (encoded `\`) are decoded by many
1177        //    servers before path normalization, turning an otherwise-safe
1178        //    segment into a traversal vector.  Raw `\` is accepted by
1179        //    `http::Uri` but treated as a path separator by Windows/IIS,
1180        //    allowing `/ingest/..\admin` to escape the prefix on those hosts.
1181        //    `%25` is the encoded form of `%`; a proxy that decodes once
1182        //    turns `%252e%252e%252fadmin` into `%2e%2e%2fadmin`, which a
1183        //    second decoder resolves to `../admin`. Rejecting `%25` closes
1184        //    the double-encoding bypass.
1185        let path_lc = path.to_ascii_lowercase();
1186        if path_lc.contains("%2f")
1187            || path_lc.contains("%5c")
1188            || path_lc.contains("%25")
1189            || path.contains('\\')
1190        {
1191            return Err(ConfineError::DotDotSegment {
1192                rendered: rendered.to_string(),
1193            });
1194        }
1195
1196        // 4. Reject any rendered query. URI templates containing `?` are
1197        //    rejected at build time, so a query at render time means a field
1198        //    value smuggled `?...` into the path — e.g. tenant value
1199        //    `ok?tenant=evil` renders `.../ingest/ok?tenant=evil`: same
1200        //    authority and path prefix but an attacker-controlled query.
1201        if uri.query().is_some() {
1202            return Err(ConfineError::OutsideBase {
1203                rendered: rendered.to_string(),
1204                base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
1205            });
1206        }
1207        Ok(())
1208    }
1209}
1210
1211/// Serializable config fragment for template confinement.
1212///
1213/// Embed this in a component config with `#[serde(flatten)]` to get the
1214/// `dangerously_allow_unconfined_template_resolution` field. Pass it to
1215/// [`Template::confine`] on each template the component owns.
1216#[configurable_component]
1217#[derive(Clone, Debug, Default)]
1218pub struct ConfinementConfig {
1219    /// Disable all template confinement checks for this sink.
1220    ///
1221    /// **DANGEROUS — disables a security control.**
1222    ///
1223    /// Bypasses both startup validation and runtime confinement for every
1224    /// templated field on this sink. When enabled, a log producer that
1225    /// controls any field used in a template can write to arbitrary keys,
1226    /// paths, or routing destinations. This flag is a full opt-out: it
1227    /// disables confinement even for templates that have a usable static
1228    /// prefix.
1229    #[serde(default)]
1230    pub dangerously_allow_unconfined_template_resolution: bool,
1231}
1232
1233impl ConfinementConfig {
1234    /// Logs a per-template SECURITY warning on the opt-out path. Does NOT
1235    /// touch the gauge — the gauge is emitted once per sink build by
1236    /// [`Self::set_confinement_gauge`].
1237    pub fn warn_unconfined_template(
1238        component_kind: &'static str,
1239        component_type: &'static str,
1240        field: &'static str,
1241    ) {
1242        warn!(
1243            message = "SECURITY: component has `dangerously_allow_unconfined_template_resolution` \
1244                       enabled — template is NOT confined. A log producer that controls any \
1245                       field used in the template can write to arbitrary keys.",
1246            component_kind, component_type, field,
1247        );
1248    }
1249
1250    /// Emit `vector_security_confinement_disabled{component_kind,component_type}`
1251    /// reflecting whether this sink has the confinement policy disabled.
1252    ///
1253    /// Must be called at the END of `SinkConfig::build`, on the success
1254    /// path only. Value = `1` when the flag is set (operator explicitly
1255    /// disabled confinement policy for this sink), `0` otherwise.
1256    ///
1257    /// Emitting earlier means a build that later errors would still leave
1258    /// its gauge write behind. On a reload where the new config fails to
1259    /// build, the topology manager keeps the old sink active but the
1260    /// failed replacement's write would silently misreport the live
1261    /// sink's confinement state.
1262    pub fn set_confinement_gauge(
1263        &self,
1264        component_kind: &'static str,
1265        component_type: &'static str,
1266    ) {
1267        let value = if self.dangerously_allow_unconfined_template_resolution {
1268            1.0
1269        } else {
1270            0.0
1271        };
1272        gauge!(
1273            GaugeName::SecurityConfinementDisabled,
1274            "component_kind" => component_kind,
1275            "component_type" => component_type,
1276        )
1277        .set(value);
1278    }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283    use chrono::{Offset, TimeZone, Utc};
1284    use chrono_tz::Tz;
1285    use vector_lib::{
1286        config::LogNamespace,
1287        lookup::{PathPrefix, metadata_path},
1288        metric_tags,
1289    };
1290    use vrl::event_path;
1291
1292    use super::*;
1293    use crate::event::{Event, LogEvent, MetricKind, MetricValue};
1294
1295    #[test]
1296    fn get_fields() {
1297        let f1 = Template::try_from("{{ foo }}")
1298            .unwrap()
1299            .get_fields()
1300            .unwrap();
1301        let f2 = Template::try_from("{{ foo }}-{{ bar }}")
1302            .unwrap()
1303            .get_fields()
1304            .unwrap();
1305        let f3 = Template::try_from("nofield").unwrap().get_fields();
1306        let f4 = Template::try_from("%F").unwrap().get_fields();
1307        let f5 = UnsignedIntTemplate::try_from("{{ foo }}-{{ bar }}")
1308            .unwrap()
1309            .get_fields()
1310            .unwrap();
1311        let f6 = UnsignedIntTemplate::from(123u64).get_fields();
1312        let f7 = UnsignedIntTemplate::try_from("%s").unwrap().get_fields();
1313
1314        assert_eq!(f1, vec!["foo"]);
1315        assert_eq!(f2, vec!["foo", "bar"]);
1316        assert_eq!(f3, None);
1317        assert_eq!(f4, None);
1318        assert_eq!(f5, vec!["foo", "bar"]);
1319        assert_eq!(f6, None);
1320        assert_eq!(f7, None);
1321    }
1322
1323    #[test]
1324    fn literal_prefix() {
1325        let cases = [
1326            ("/var/log/app.log", "/var/log/app.log"),
1327            ("/var/log/{{ host }}/app.log", "/var/log/"),
1328            ("/var/log/%Y/{{ host }}.log", "/var/log/"),
1329            ("/srv-{{ id }}.log", "/srv-"),
1330            ("{{ full_path }}", ""),
1331            ("/{{ tenant }}/app.log", "/"),
1332            // `%%` stops the prefix scan — in mixed segments like
1333            // `100%%/%Y/{{ x }}` chrono decodes `%%` while expanding `%Y`,
1334            // so we cannot determine the rendered prefix without a timestamp.
1335            ("100%%-literal/{{ x }}", "100"),
1336            ("no-template-at-all", "no-template-at-all"),
1337            ("only-strftime-%F.log", "only-strftime-"),
1338            // single `{` is not a field opener
1339            ("a{b/{{ c }}", "a{b/"),
1340        ];
1341        for (src, expected) in cases {
1342            let tpl = Template::try_from(src).unwrap();
1343            assert_eq!(tpl.literal_prefix(), expected, "src = {src:?}");
1344        }
1345    }
1346
1347    #[test]
1348    fn is_dynamic() {
1349        assert!(Template::try_from("/kube-demo/%F").unwrap().is_dynamic());
1350        assert!(!Template::try_from("/kube-demo/echo").unwrap().is_dynamic());
1351        assert!(
1352            Template::try_from("/kube-demo/{{ foo }}")
1353                .unwrap()
1354                .is_dynamic()
1355        );
1356        assert!(
1357            Template::try_from("/kube-demo/{{ foo }}/%F")
1358                .unwrap()
1359                .is_dynamic()
1360        );
1361    }
1362
1363    #[test]
1364    fn render_log_static() {
1365        let event = Event::Log(LogEvent::from("hello world"));
1366        let template = Template::try_from("foo").unwrap();
1367
1368        assert_eq!(Ok(Bytes::from("foo")), template.render(&event))
1369    }
1370
1371    #[test]
1372    fn render_log_unsigned_number() {
1373        let event = Event::Log(LogEvent::from("hello world"));
1374        let template = UnsignedIntTemplate::from(123);
1375
1376        assert_eq!(Ok(123), template.render(&event))
1377    }
1378
1379    #[test]
1380    fn render_log_unsigned_number_dynamic() {
1381        let mut event = Event::Log(LogEvent::from("hello world"));
1382        event.as_mut_log().insert(event_path!("foo"), 123);
1383
1384        let template = UnsignedIntTemplate::try_from("{{ foo }}").unwrap();
1385        assert_eq!(Ok(123), template.render(&event))
1386    }
1387
1388    #[test]
1389    fn render_log_dynamic() {
1390        let mut event = Event::Log(LogEvent::from("hello world"));
1391        event
1392            .as_mut_log()
1393            .insert(event_path!("log_stream"), "stream");
1394        let template = Template::try_from("{{log_stream}}").unwrap();
1395
1396        assert_eq!(Ok(Bytes::from("stream")), template.render(&event))
1397    }
1398
1399    #[test]
1400    fn render_log_metadata() {
1401        let mut event = Event::Log(LogEvent::from("hello world"));
1402        event
1403            .as_mut_log()
1404            .insert(metadata_path!("metadata_key"), "metadata_value");
1405        let template = Template::try_from("{{%metadata_key}}").unwrap();
1406
1407        assert_eq!(Ok(Bytes::from("metadata_value")), template.render(&event))
1408    }
1409
1410    #[test]
1411    fn render_log_dynamic_with_prefix() {
1412        let mut event = Event::Log(LogEvent::from("hello world"));
1413        event
1414            .as_mut_log()
1415            .insert(event_path!("log_stream"), "stream");
1416        let template = Template::try_from("abcd-{{log_stream}}").unwrap();
1417
1418        assert_eq!(Ok(Bytes::from("abcd-stream")), template.render(&event))
1419    }
1420
1421    #[test]
1422    fn render_log_dynamic_with_postfix() {
1423        let mut event = Event::Log(LogEvent::from("hello world"));
1424        event
1425            .as_mut_log()
1426            .insert(event_path!("log_stream"), "stream");
1427        let template = Template::try_from("{{log_stream}}-abcd").unwrap();
1428
1429        assert_eq!(Ok(Bytes::from("stream-abcd")), template.render(&event))
1430    }
1431
1432    #[test]
1433    fn render_log_dynamic_missing_key() {
1434        let event = Event::Log(LogEvent::from("hello world"));
1435        let template = Template::try_from("{{log_stream}}-{{foo}}").unwrap();
1436
1437        assert_eq!(
1438            Err(TemplateRenderingError::MissingKeys {
1439                missing_keys: vec!["log_stream".to_string(), "foo".to_string()]
1440            }),
1441            template.render(&event)
1442        );
1443    }
1444
1445    #[test]
1446    fn render_log_dynamic_multiple_keys() {
1447        let mut event = Event::Log(LogEvent::from("hello world"));
1448        event.as_mut_log().insert(event_path!("foo"), "bar");
1449        event.as_mut_log().insert(event_path!("baz"), "quux");
1450        let template = Template::try_from("stream-{{foo}}-{{baz}}.log").unwrap();
1451
1452        assert_eq!(
1453            Ok(Bytes::from("stream-bar-quux.log")),
1454            template.render(&event)
1455        )
1456    }
1457
1458    #[test]
1459    fn render_log_dynamic_weird_junk() {
1460        let mut event = Event::Log(LogEvent::from("hello world"));
1461        event.as_mut_log().insert(event_path!("foo"), "bar");
1462        event.as_mut_log().insert(event_path!("baz"), "quux");
1463        let template = Template::try_from(r"{stream}{\{{}}}-{{foo}}-{{baz}}.log").unwrap();
1464
1465        assert_eq!(
1466            Ok(Bytes::from(r"{stream}{\{{}}}-bar-quux.log")),
1467            template.render(&event)
1468        )
1469    }
1470
1471    #[test]
1472    fn render_log_timestamp_strftime_style() {
1473        let ts = Utc
1474            .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1475            .single()
1476            .expect("invalid timestamp");
1477
1478        let mut event = Event::Log(LogEvent::from("hello world"));
1479        event
1480            .as_mut_log()
1481            .insert(log_schema().timestamp_key_target_path().unwrap(), ts);
1482
1483        let template = Template::try_from("abcd-%F").unwrap();
1484
1485        assert_eq!(Ok(Bytes::from("abcd-2001-02-03")), template.render(&event))
1486    }
1487
1488    #[test]
1489    fn render_log_timestamp_strftime_style_namespace() {
1490        let ts = Utc
1491            .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1492            .single()
1493            .expect("invalid timestamp");
1494
1495        let mut event = Event::Log(LogEvent::from("hello world"));
1496        event.as_mut_log().insert(event_path!("@timestamp"), ts);
1497        // use Vector namespace instead of legacy
1498        LogNamespace::Vector.insert_vector_metadata(
1499            event.as_mut_log(),
1500            Some(vrl::path!("foo")),
1501            vrl::path!("foo"),
1502            "bar",
1503        );
1504        let new_schema = event
1505            .as_mut_log()
1506            .metadata()
1507            .schema_definition()
1508            .as_ref()
1509            .clone()
1510            .with_meaning(parse_target_path("@timestamp").unwrap(), "timestamp");
1511        event
1512            .as_mut_log()
1513            .metadata_mut()
1514            .set_schema_definition(&std::sync::Arc::new(new_schema));
1515
1516        let template = Template::try_from("abcd-%F").unwrap();
1517
1518        assert_eq!(Ok(Bytes::from("abcd-2001-02-03")), template.render(&event))
1519    }
1520
1521    #[test]
1522    fn render_log_timestamp_multiple_strftime_style() {
1523        let ts = Utc
1524            .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1525            .single()
1526            .expect("invalid timestamp");
1527
1528        let mut event = Event::Log(LogEvent::from("hello world"));
1529        event
1530            .as_mut_log()
1531            .insert(log_schema().timestamp_key_target_path().unwrap(), ts);
1532
1533        let template = Template::try_from("abcd-%F_%T").unwrap();
1534
1535        assert_eq!(
1536            Ok(Bytes::from("abcd-2001-02-03_04:05:06")),
1537            template.render(&event)
1538        )
1539    }
1540
1541    #[test]
1542    fn render_log_dynamic_with_strftime() {
1543        let ts = Utc
1544            .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1545            .single()
1546            .expect("invalid timestamp");
1547
1548        let mut event = Event::Log(LogEvent::from("hello world"));
1549        event.as_mut_log().insert(event_path!("foo"), "butts");
1550        event.as_mut_log().insert(
1551            (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1552            ts,
1553        );
1554
1555        let template = Template::try_from("{{ foo }}-%F_%T").unwrap();
1556
1557        assert_eq!(
1558            Ok(Bytes::from("butts-2001-02-03_04:05:06")),
1559            template.render(&event)
1560        )
1561    }
1562
1563    #[test]
1564    fn render_log_dynamic_with_nested_strftime() {
1565        let ts = Utc
1566            .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1567            .single()
1568            .expect("invalid timestamp");
1569
1570        let mut event = Event::Log(LogEvent::from("hello world"));
1571        event.as_mut_log().insert(event_path!("format"), "%F");
1572        event.as_mut_log().insert(
1573            (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1574            ts,
1575        );
1576
1577        let template = Template::try_from("nested {{ format }} %T").unwrap();
1578
1579        assert_eq!(
1580            Ok(Bytes::from("nested %F 04:05:06")),
1581            template.render(&event)
1582        )
1583    }
1584
1585    #[test]
1586    fn render_log_dynamic_with_reverse_nested_strftime() {
1587        let ts = Utc
1588            .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1589            .single()
1590            .expect("invalid timestamp");
1591
1592        let mut event = Event::Log(LogEvent::from("hello world"));
1593        event
1594            .as_mut_log()
1595            .insert(&parse_target_path("\"%F\"").unwrap(), "foo");
1596        event.as_mut_log().insert(
1597            (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1598            ts,
1599        );
1600
1601        let template = Template::try_from("nested {{ \"%F\" }} %T").unwrap();
1602
1603        assert_eq!(
1604            Ok(Bytes::from("nested foo 04:05:06")),
1605            template.render(&event)
1606        )
1607    }
1608
1609    #[test]
1610    fn render_metric_timestamp() {
1611        let template = Template::try_from("timestamp %F %T").unwrap();
1612
1613        assert_eq!(
1614            Ok(Bytes::from("timestamp 2002-03-04 05:06:07")),
1615            template.render(&sample_metric())
1616        );
1617    }
1618
1619    #[test]
1620    fn render_metric_with_tags() {
1621        let template = Template::try_from("name={{name}} component={{tags.component}}").unwrap();
1622        let metric = sample_metric().with_tags(Some(metric_tags!(
1623            "test" => "true",
1624            "component" => "template",
1625        )));
1626        assert_eq!(
1627            Ok(Bytes::from("name=a-counter component=template")),
1628            template.render(&metric)
1629        );
1630    }
1631
1632    #[test]
1633    fn render_metric_without_tags() {
1634        let template = Template::try_from("name={{name}} component={{tags.component}}").unwrap();
1635        assert_eq!(
1636            Err(TemplateRenderingError::MissingKeys {
1637                missing_keys: vec!["tags.component".into()]
1638            }),
1639            template.render(&sample_metric())
1640        );
1641    }
1642
1643    #[test]
1644    fn render_metric_with_namespace() {
1645        let template = Template::try_from("namespace={{namespace}} name={{name}}").unwrap();
1646        let metric = sample_metric().with_namespace(Some("vector-test"));
1647        assert_eq!(
1648            Ok(Bytes::from("namespace=vector-test name=a-counter")),
1649            template.render(&metric)
1650        );
1651    }
1652
1653    #[test]
1654    fn render_metric_without_namespace() {
1655        let template = Template::try_from("namespace={{namespace}} name={{name}}").unwrap();
1656        let metric = sample_metric();
1657        assert_eq!(
1658            Err(TemplateRenderingError::MissingKeys {
1659                missing_keys: vec!["namespace".into()]
1660            }),
1661            template.render(&metric)
1662        );
1663    }
1664
1665    #[test]
1666    fn render_log_with_timezone() {
1667        let ts = Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap();
1668
1669        let template = Template::try_from("vector-%Y-%m-%d-%H.log").unwrap();
1670        let mut event = Event::Log(LogEvent::from("hello world"));
1671        event.as_mut_log().insert(
1672            (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1673            ts,
1674        );
1675
1676        let tz: Tz = "Asia/Singapore".parse().unwrap();
1677        let offset = Some(Utc::now().with_timezone(&tz).offset().fix());
1678        assert_eq!(
1679            Ok(Bytes::from("vector-2001-02-03-12.log")),
1680            template.with_tz_offset(offset).render(&event)
1681        );
1682    }
1683
1684    #[test]
1685    fn render_log_unsigned_int_with_timezone() {
1686        let ts = Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap();
1687
1688        let template = UnsignedIntTemplate::try_from("%Y%m%d%H").unwrap();
1689        let mut event = Event::Log(LogEvent::from("hello world"));
1690        event.as_mut_log().insert(event_path!("timestamp"), ts);
1691
1692        let tz: Tz = "Asia/Singapore".parse().unwrap();
1693        let offset = Some(Utc::now().with_timezone(&tz).offset().fix());
1694
1695        assert_eq!(
1696            Ok(2001020312),
1697            template.with_tz_offset(offset).render(&event)
1698        );
1699    }
1700
1701    fn sample_metric() -> Metric {
1702        Metric::new(
1703            "a-counter",
1704            MetricKind::Absolute,
1705            MetricValue::Counter { value: 1.1 },
1706        )
1707        .with_timestamp(Some(
1708            Utc.with_ymd_and_hms(2002, 3, 4, 5, 6, 7)
1709                .single()
1710                .expect("invalid timestamp"),
1711        ))
1712    }
1713
1714    #[test]
1715    fn strftime_error() {
1716        assert_eq!(
1717            Template::try_from("%E").unwrap_err(),
1718            TemplateParseError::StrftimeError
1719        );
1720    }
1721
1722    #[test]
1723    fn strftime_non_int_result() {
1724        let template = UnsignedIntTemplate::try_from("a-%s").unwrap();
1725        let ts = Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap();
1726
1727        let mut event = Event::Log(LogEvent::from("hello world"));
1728        event.as_mut_log().insert(event_path!("timestamp"), ts);
1729
1730        assert_eq!(
1731            Err(TemplateRenderingError::NotNumeric {
1732                input: "a-981173106".to_owned()
1733            }),
1734            template.render(&event)
1735        );
1736    }
1737
1738    #[test]
1739    fn dotdot_bypass_rejected() {
1740        // `safe/../../escape` passes a naive starts_with("safe/") check but must
1741        // be rejected — on filesystem-like protocols (WebHDFS) the server resolves
1742        // `..` and the value escapes the intended namespace.
1743        let tpl = Template::try_from("safe/{{ tenant }}/").unwrap();
1744        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1745
1746        assert!(c.confine("safe/legit/").is_ok());
1747        assert!(matches!(
1748            c.confine("safe/../../escape/").unwrap_err(),
1749            ConfineError::DotDotSegment { .. }
1750        ));
1751        assert!(matches!(
1752            c.confine("safe/../escape/").unwrap_err(),
1753            ConfineError::DotDotSegment { .. }
1754        ));
1755        // `..` only as a literal substring (not a full segment) is fine
1756        assert!(c.confine("safe/not..dotdot/").is_ok());
1757    }
1758
1759    #[test]
1760    fn uri_path_traversal_rejected() {
1761        // `https://api.internal/ingest/{{ tenant }}` with `../../admin` passes the
1762        // authority check (same host) but must be caught by the path-prefix +
1763        // `..` checks.
1764        let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1765        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1766
1767        assert!(c.confine("https://api.internal/ingest/acme").is_ok());
1768        assert!(matches!(
1769            c.confine("https://api.internal/ingest/../../admin")
1770                .unwrap_err(),
1771            ConfineError::DotDotSegment { .. }
1772        ));
1773        // Path that doesn't start with /ingest/ is also rejected.
1774        assert!(matches!(
1775            c.confine("https://api.internal/admin/secret").unwrap_err(),
1776            ConfineError::OutsideBase { .. }
1777        ));
1778    }
1779
1780    #[test]
1781    fn uri_percent_encoded_dotdot_rejected() {
1782        // Servers that decode percent-encoding before path resolution can be
1783        // tricked by `%2e%2e` instead of `..`.  All encoded variants must be
1784        // rejected alongside the literal `..`.
1785        let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1786        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1787
1788        assert!(c.confine("https://api.internal/ingest/legit").is_ok());
1789        assert!(matches!(
1790            c.confine("https://api.internal/ingest/%2e%2e/admin")
1791                .unwrap_err(),
1792            ConfineError::DotDotSegment { .. }
1793        ));
1794        assert!(matches!(
1795            c.confine("https://api.internal/ingest/%2E%2E/admin")
1796                .unwrap_err(),
1797            ConfineError::DotDotSegment { .. }
1798        ));
1799        assert!(matches!(
1800            c.confine("https://api.internal/ingest/.%2e/admin")
1801                .unwrap_err(),
1802            ConfineError::DotDotSegment { .. }
1803        ));
1804        assert!(matches!(
1805            c.confine("https://api.internal/ingest/%2e./admin")
1806                .unwrap_err(),
1807            ConfineError::DotDotSegment { .. }
1808        ));
1809    }
1810
1811    #[test]
1812    fn uri_encoded_slash_traversal_rejected() {
1813        // `%2f` is a percent-encoded `/`. RFC 3986 treats it as a literal slash
1814        // character inside a segment, not a path separator — so `http::Uri` keeps
1815        // `%2e%2e%2fadmin` as a single segment and the segment-level dot-dot checks
1816        // alone would miss it. Many HTTP servers decode `%2f` before resolving the
1817        // path, turning the single segment into `../admin` and escaping the prefix.
1818        // We must also reject `%5c` (encoded backslash) for Windows-backed services.
1819        let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1820        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1821
1822        assert!(matches!(
1823            c.confine("https://api.internal/ingest/%2e%2e%2fadmin")
1824                .unwrap_err(),
1825            ConfineError::DotDotSegment { .. }
1826        ));
1827        assert!(matches!(
1828            c.confine("https://api.internal/ingest/%2e%2e%2Fadmin")
1829                .unwrap_err(),
1830            ConfineError::DotDotSegment { .. }
1831        ));
1832        assert!(matches!(
1833            c.confine("https://api.internal/ingest/safe%2fpath")
1834                .unwrap_err(),
1835            ConfineError::DotDotSegment { .. }
1836        ));
1837        assert!(matches!(
1838            c.confine("https://api.internal/ingest/safe%5cpath")
1839                .unwrap_err(),
1840            ConfineError::DotDotSegment { .. }
1841        ));
1842    }
1843
1844    #[test]
1845    fn uri_authority_mismatch_rejected() {
1846        let tpl = Template::try_from("https://trusted.example.com{{ path }}").unwrap();
1847        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1848
1849        // Normal path extension is fine.
1850        assert!(c.confine("https://trusted.example.com/api/v1").is_ok());
1851
1852        // `@`-userinfo injection: `logs.example.com` becomes the username
1853        // and `evil.com` becomes the actual host.
1854        assert!(matches!(
1855            c.confine("https://trusted.example.com@evil.com/steal")
1856                .unwrap_err(),
1857            ConfineError::UriAuthorityMismatch { .. }
1858        ));
1859
1860        // Host-extension attack: appending to the hostname routes to a
1861        // different host entirely.
1862        assert!(matches!(
1863            c.confine("https://trusted.example.com.evil.com/steal")
1864                .unwrap_err(),
1865            ConfineError::UriAuthorityMismatch { .. }
1866        ));
1867
1868        // `@` in a URI path is fine — it's structurally after the authority.
1869        assert!(
1870            c.confine("https://trusted.example.com/path/%40user")
1871                .is_ok()
1872        );
1873        assert!(c.confine("https://trusted.example.com/path/@user").is_ok());
1874
1875        // Wrong scheme is rejected.
1876        assert!(matches!(
1877            c.confine("http://trusted.example.com/api/v1").unwrap_err(),
1878            ConfineError::UriAuthorityMismatch { .. }
1879        ));
1880    }
1881
1882    #[test]
1883    fn uri_path_field_cannot_smuggle_backslash() {
1884        // Raw `\` is accepted by `http::Uri` but Windows/IIS servers treat it
1885        // as a path separator — `/ingest/..\admin` escapes the prefix on those
1886        // hosts. Reject at render time.
1887        let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1888        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1889        assert!(matches!(
1890            c.confine("https://api.internal/ingest/..\\admin")
1891                .unwrap_err(),
1892            ConfineError::DotDotSegment { .. }
1893        ));
1894        assert!(matches!(
1895            c.confine("https://api.internal/ingest/foo\\..\\admin")
1896                .unwrap_err(),
1897            ConfineError::DotDotSegment { .. }
1898        ));
1899    }
1900
1901    #[test]
1902    fn uri_path_field_cannot_smuggle_double_encoded_traversal() {
1903        // `%25` is the encoded form of `%`. A proxy that decodes once turns
1904        // `%252e%252e%252fadmin` into `%2e%2e%2fadmin`; a second decoder
1905        // resolves it to `../admin`. Reject at render time.
1906        let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1907        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1908        assert!(matches!(
1909            c.confine("https://api.internal/ingest/%252e%252e%252fadmin")
1910                .unwrap_err(),
1911            ConfineError::DotDotSegment { .. }
1912        ));
1913    }
1914
1915    #[test]
1916    fn uri_path_field_cannot_smuggle_query() {
1917        // Templates with no `?` build successfully. A field value that smuggles
1918        // `?...` into the path (e.g. tenant=`ok?tenant=evil`) is caught at
1919        // render time via the query-rejection check.
1920        let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1921        let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1922        assert!(matches!(
1923            c.confine("https://api.internal/ingest/ok?tenant=evil")
1924                .unwrap_err(),
1925            ConfineError::OutsideBase { .. }
1926        ));
1927        // Fragments are stripped by `http::Uri` before reaching the server,
1928        // so a rendered `#frag` doesn't count as a query and is accepted.
1929        assert!(c.confine("https://api.internal/ingest/ok#frag").is_ok());
1930    }
1931
1932    #[test]
1933    fn uri_template_with_query_or_fragment_rejected_at_build() {
1934        // URI templates with field references AND `?` / `#` are rejected. A
1935        // field-rendered value could smuggle a query segment, or a `#frag`
1936        // that `http::Uri` truncates before the checker can see the path
1937        // (silently dropping any operator-authored suffix).
1938        // Static URI templates (no `{{ }}`) are exempt — their query/fragment
1939        // is fixed.
1940        for template_str in &[
1941            "https://api.internal/ingest?tenant={{ tenant }}",
1942            "https://api.internal/ingest?tenant=team-{{ tenant }}",
1943            "https://api.internal/{{ path }}?tenant={{ tenant }}",
1944            "https://api.internal/ingest/{{ path }}?time=%Y",
1945            "https://api.internal/ingest/{{ tenant }}?source=vector",
1946            "https://api.internal/base/{{ tenant }}/ingest#frag",
1947            "https://api.internal/base/{{ tenant }}#frag",
1948        ] {
1949            let tpl = Template::try_from(*template_str).unwrap();
1950            assert!(
1951                matches!(
1952                    ConfinementChecker::for_template(&tpl).unwrap_err(),
1953                    BuildError::DynamicUriQueryOrFragment { .. }
1954                ),
1955                "expected DynamicUriQueryOrFragment for {template_str}"
1956            );
1957        }
1958    }
1959
1960    #[test]
1961    fn static_uri_with_query_allowed() {
1962        // A fully static URI (no `{{ }}`) with a query string is safe: the
1963        // rendered value is fixed and cannot be influenced by event data.
1964        // No checker is installed; `Ok(None)` is the expected result.
1965        let tpl = Template::try_from("https://api.internal/ingest?source=vector").unwrap();
1966        assert!(ConfinementChecker::for_template(&tpl).unwrap().is_none());
1967    }
1968
1969    #[test]
1970    fn no_static_uri_authority_rejected() {
1971        // `https://{{ host }}/ingest` has prefix `https://` — any host can be
1972        // rendered, so the confinement is meaningless. Must be rejected at build.
1973        for template_str in &["https://{{ host }}/ingest", "http://{{ host }}/path"] {
1974            let tpl = Template::try_from(*template_str).unwrap();
1975            assert!(
1976                matches!(
1977                    ConfinementChecker::for_template(&tpl).unwrap_err(),
1978                    BuildError::NoStaticUriAuthority { .. }
1979                ),
1980                "expected NoStaticUriAuthority for {template_str}"
1981            );
1982        }
1983        // A template with a static host is accepted.
1984        let tpl = Template::try_from("https://trusted.example.com{{ path }}").unwrap();
1985        assert!(ConfinementChecker::for_template(&tpl).unwrap().is_some());
1986    }
1987
1988    #[test]
1989    fn root_only_prefix_rejected() {
1990        // `/{{ tenant }}/` has a literal prefix of `/` — every rendered value
1991        // trivially starts with it, so it provides no useful confinement.
1992        let tpl = Template::try_from("/{{ tenant }}/").unwrap();
1993        assert!(matches!(
1994            ConfinementChecker::for_template(&tpl).unwrap_err(),
1995            BuildError::DerivedBaseIsRoot { .. }
1996        ));
1997    }
1998
1999    #[test]
2000    fn non_root_slash_prefix_accepted() {
2001        // `/data/{{ tenant }}/` has a literal prefix of `/data/` — non-root, valid.
2002        let tpl = Template::try_from("/data/{{ tenant }}/").unwrap();
2003        let checker = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
2004        assert!(checker.confine("/data/tenant-a/").is_ok());
2005        assert!(checker.confine("/other/tenant-a/").is_err());
2006    }
2007}