Skip to main content

shep_core/config/
template.rs

1//! The `{{...}}` grammar for Flockfile values.
2//!
3//! Three tokens in env values, args, and the two log-path fields:
4//! `{{instance}}` and `{{name}}` substitute from the sheep's identity, and
5//! `{{secret:KEY}}` (or `{{secret:namespace/KEY}}`) reads
6//! [`crate::secrets`]. An unknown token between doubled braces is refused
7//! at config time rather than reaching a child process as literal text.
8//!
9//! Doubled braces avoid collision with single-brace content already in these
10//! values: JSON blobs, regex quantifiers, Go or Helm templates passed
11//! through as args.
12//!
13//! `{{{{` and `}}}}` escape to literal `{{` and `}}`. A lone `}}`, as in
14//! `{"a":{"b":1}}`, is ordinary text and passes through unchanged.
15
16use core::convert::Infallible;
17use core::fmt;
18
19use crate::secrets::{Resolution, SecretRef, SecretView};
20
21/// The positional tokens this grammar knows, in the order an error lists
22/// them.
23const TOKENS: &[&str] = &["instance", "name"];
24
25/// The prefix marking a store lookup, as it appears inside the braces.
26const SECRET_PREFIX: &str = "secret:";
27
28/// The store reference `token` names, or `None` when it is not a well-formed
29/// `{{secret:...}}` body.
30///
31/// [`SecretRef::parse`] is the only grammar for a reference, so a token
32/// [`validate`] accepts is one [`render`] can parse.
33///
34/// `pub(crate)`: [`crate::secrets::references`] shares this rather than
35/// re-deriving what a `secret:` body is.
36pub(crate) fn secret_reference(token: &str) -> Option<SecretRef<'_>> {
37    token.strip_prefix(SECRET_PREFIX).and_then(SecretRef::parse)
38}
39
40/// A value that is not a valid template.
41///
42/// `pub(crate)`: `normalize` is the only caller, and wraps this in its own
43/// [`NormalizeError::BadTemplate`](super::normalize::NormalizeError::BadTemplate).
44#[non_exhaustive]
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) enum TemplateError {
47    /// A `{{...}}` naming something this grammar does not define
48    UnknownToken {
49        /// The token as the user wrote it, without the braces
50        token: String,
51    },
52    /// A `{{` with no closing `}}`
53    Unclosed,
54}
55
56impl fmt::Display for TemplateError {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            Self::UnknownToken { token } if token.starts_with(SECRET_PREFIX) => write!(
60                f,
61                "`{{{{{token}}}}}` is not a valid secret reference: write \
62                 `{{{{secret:KEY}}}}` or `{{{{secret:namespace/KEY}}}}`, where each part \
63                 holds only letters, digits, `.`, `_` or `-` and does not start with `.`"
64            ),
65            Self::UnknownToken { token } => write!(
66                f,
67                "`{{{{{token}}}}}` is not a template token: valid tokens are {}",
68                TOKENS
69                    .iter()
70                    .map(|t| format!("`{{{{{t}}}}}`"))
71                    .chain(core::iter::once(format!("`{{{{{SECRET_PREFIX}...}}}}`")))
72                    .collect::<Vec<_>>()
73                    .join(", ")
74            ),
75            Self::Unclosed => f.write_str("a `{{` in this value is never closed by a `}}`"),
76        }
77    }
78}
79
80impl core::error::Error for TemplateError {}
81
82/// A value whose grammar is valid but whose `{{secret:...}}` cannot be
83/// resolved.
84///
85/// Redacted by construction (IR-41): a variant carries the reference as the
86/// operator wrote it, the namespace and the environment, and no field can
87/// hold a value.
88///
89/// `#[non_exhaustive]`: shep-core is published, so a new way for a
90/// reference to fail must not break an out-of-tree `match`. It costs
91/// in-tree callers nothing, since [`Self::is_retriable`] already gives them
92/// the one classification they act on.
93#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum RenderError {
96    /// The store holds no value for this reference in this environment
97    Unresolved {
98        /// The reference as it appears in the value, braces and all
99        reference: String,
100        /// The environment the lookup ran against
101        environment: String,
102    },
103    /// No provider dog has pushed the namespace this reference reads for
104    /// the environment it was resolved in
105    NamespaceUnready {
106        /// The namespace the reference names
107        namespace: String,
108        /// The reference as it appears in the value, braces and all
109        reference: String,
110        /// The environment the lookup ran against
111        environment: String,
112    },
113}
114
115impl RenderError {
116    /// Whether waiting could make this reference resolve.
117    ///
118    /// `true` for [`Self::NamespaceUnready`] alone: a provider dog that has
119    /// not pushed this environment yet is the one failure a later attempt
120    /// can clear. An [`Self::Unresolved`] waits on a person instead.
121    #[must_use]
122    pub fn is_retriable(&self) -> bool {
123        matches!(self, Self::NamespaceUnready { .. })
124    }
125}
126
127impl fmt::Display for RenderError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::Unresolved {
131                reference,
132                environment,
133            } => write!(
134                f,
135                "`{reference}` has no value in the `{environment}` environment"
136            ),
137            Self::NamespaceUnready {
138                namespace,
139                reference,
140                environment,
141            } => write!(
142                f,
143                "`{reference}` reads the `{namespace}` namespace, which no provider dog \
144                 has pushed to for the `{environment}` environment yet"
145            ),
146        }
147    }
148}
149
150impl core::error::Error for RenderError {}
151
152/// One piece of `value` as [`walk`] sees it: ordinary text, or a token name
153/// with the braces stripped.
154///
155/// `pub(crate)`: [`crate::secrets::references`] matches on this directly
156/// rather than [`walk`] growing a second, narrower traversal.
157pub(crate) enum Segment<'a> {
158    /// A run of ordinary text, copied through unchanged.
159    Literal(&'a str),
160    /// The name between a `{{` and its `}}`, braces stripped.
161    Token(&'a str),
162}
163
164/// How far [`walk`] got through a value.
165pub(crate) enum Completion {
166    /// Every `{{` was closed by a `}}`.
167    Complete,
168    /// A `{{` was never closed; the segments before it were still emitted.
169    Unclosed,
170}
171
172/// Walks `value`, calling `on_segment` for each literal run and each token.
173///
174/// One walker, one closure, so [`validate`], [`render`], [`render_positional`]
175/// and [`crate::secrets::references`] can never disagree about what a token
176/// is.
177///
178/// Generic over the closure's error so each caller keeps its own, with an
179/// unclosed `{{` reported through [`Completion`] rather than as an error
180/// every caller would have to be able to spell.
181///
182/// `pub(crate)`: [`crate::secrets::references`] walks a config's own values
183/// for `{{secret:...}}` tokens rather than parsing them a second way.
184///
185/// # Errors
186///
187/// Whatever `on_segment` returns, at the first segment it refuses.
188pub(crate) fn walk<E>(
189    value: &str,
190    mut on_segment: impl FnMut(Segment<'_>) -> Result<(), E>,
191) -> Result<Completion, E> {
192    let bytes = value.as_bytes();
193    let mut at = 0;
194    let mut literal_from = 0;
195    while at < bytes.len() {
196        if bytes[at..].starts_with(b"{{{{") {
197            on_segment(Segment::Literal(&value[literal_from..at]))?;
198            on_segment(Segment::Literal("{{"))?;
199            at += 4;
200            literal_from = at;
201        } else if bytes[at..].starts_with(b"}}}}") {
202            on_segment(Segment::Literal(&value[literal_from..at]))?;
203            on_segment(Segment::Literal("}}"))?;
204            at += 4;
205            literal_from = at;
206        } else if bytes[at..].starts_with(b"{{") {
207            on_segment(Segment::Literal(&value[literal_from..at]))?;
208            let rest = &value[at + 2..];
209            let Some(end) = rest.find("}}") else {
210                return Ok(Completion::Unclosed);
211            };
212            on_segment(Segment::Token(&rest[..end]))?;
213            at += 2 + end + 2;
214            literal_from = at;
215        } else {
216            at += 1;
217        }
218    }
219    on_segment(Segment::Literal(&value[literal_from..]))?;
220    Ok(Completion::Complete)
221}
222
223/// Writes `token` back with its braces, for a token the caller leaves alone.
224fn push_token(out: &mut String, token: &str) {
225    out.push_str("{{");
226    out.push_str(token);
227    out.push_str("}}");
228}
229
230/// The value `reference` names in `secrets`.
231///
232/// # Errors
233///
234/// - [`RenderError::NamespaceUnready`]: the reference names a namespace no
235///   provider has pushed for this view's environment.
236/// - [`RenderError::Unresolved`]: every other miss.
237fn resolve_secret<'a>(
238    reference: &SecretRef<'_>,
239    secrets: &'a SecretView,
240) -> Result<&'a str, RenderError> {
241    match (secrets.resolve(reference), reference.namespace) {
242        (Resolution::Found(value), _) => Ok(value),
243        (Resolution::MissingNamespace, Some(namespace)) => Err(RenderError::NamespaceUnready {
244            namespace: namespace.to_string(),
245            reference: reference.to_string(),
246            environment: secrets.environment().to_string(),
247        }),
248        (Resolution::MissingKey | Resolution::MissingNamespace, _) => {
249            Err(RenderError::Unresolved {
250                reference: reference.to_string(),
251                environment: secrets.environment().to_string(),
252            })
253        }
254    }
255}
256
257/// Whether `value` carries a `{{secret:...}}` this grammar would resolve.
258///
259/// `pub(crate)`: `normalize` asks it of the two log-path fields, which may
260/// not hold a secret. Walks the same tokenizer [`render`] resolves against,
261/// so a reference this misses is one `render` would not have substituted
262/// either.
263pub(crate) fn holds_secret(value: &str) -> bool {
264    let mut found = false;
265    let _ = walk::<Infallible>(value, |segment| {
266        if let Segment::Token(token) = segment
267            && secret_reference(token).is_some()
268        {
269            found = true;
270        }
271        Ok(())
272    });
273    found
274}
275
276/// Checks that every `{{...}}` in `value` names a token this grammar defines.
277///
278/// `pub(crate)`: only `normalize` asks this, at config time. [`render`] stays
279/// public since shep-daemon's `assemble` runs it on already-validated values.
280///
281/// # Errors
282///
283/// - [`TemplateError::UnknownToken`]: a token this grammar does not define.
284/// - [`TemplateError::Unclosed`]: a `{{` with no closing `}}`.
285pub(crate) fn validate(value: &str) -> Result<(), TemplateError> {
286    let completion = walk(value, |segment| match segment {
287        Segment::Literal(_) => Ok(()),
288        Segment::Token(token) if TOKENS.contains(&token) || secret_reference(token).is_some() => {
289            Ok(())
290        }
291        Segment::Token(token) => Err(TemplateError::UnknownToken {
292            token: token.to_string(),
293        }),
294    })?;
295    match completion {
296        Completion::Complete => Ok(()),
297        Completion::Unclosed => Err(TemplateError::Unclosed),
298    }
299}
300
301/// Substitutes `{{instance}}` and `{{name}}` only, leaving every other
302/// token, `{{secret:...}}` included, exactly as written.
303///
304/// For callers that have no store to consult. `normalize` uses it to compare
305/// two instances' log paths, where a secret resolves to the same value for
306/// both instances and so cannot tell them apart anyway.
307///
308/// Call `validate` first: an unclosed `{{` renders truncated at that
309/// point.
310#[must_use]
311pub fn render_positional(value: &str, name: &str, instance: u32) -> String {
312    let mut out = String::with_capacity(value.len());
313    let slot = instance.to_string();
314    let _: Result<Completion, Infallible> = walk(value, |segment| {
315        match segment {
316            Segment::Literal(literal) => out.push_str(literal),
317            Segment::Token("instance") => out.push_str(&slot),
318            Segment::Token("name") => out.push_str(name),
319            Segment::Token(token) => push_token(&mut out, token),
320        }
321        Ok(())
322    });
323    out
324}
325
326/// Substitutes every token in `value`, resolving `{{secret:...}}` against
327/// `secrets`.
328///
329/// Call `validate` first: this assumes the grammar already passed, so a
330/// token this grammar does not define is written back as it was, and an
331/// unclosed `{{` renders truncated at that point.
332///
333/// # Errors
334///
335/// - [`RenderError::Unresolved`]: a reference the store has no value for in
336///   this view's environment. Nothing but a person will supply it.
337/// - [`RenderError::NamespaceUnready`]: a namespace no provider dog has
338///   pushed to for this view's environment yet.
339///   [`RenderError::is_retriable`] is `true` for this one alone.
340pub fn render(
341    value: &str,
342    name: &str,
343    instance: u32,
344    secrets: &SecretView,
345) -> Result<String, RenderError> {
346    let mut out = String::with_capacity(value.len());
347    let slot = instance.to_string();
348    walk(value, |segment| {
349        match segment {
350            Segment::Literal(literal) => out.push_str(literal),
351            Segment::Token("instance") => out.push_str(&slot),
352            Segment::Token("name") => out.push_str(name),
353            Segment::Token(token) => match secret_reference(token) {
354                Some(reference) => out.push_str(resolve_secret(&reference, secrets)?),
355                None => push_token(&mut out, token),
356            },
357        }
358        Ok(())
359    })?;
360    Ok(out)
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn the_two_tokens_render() {
369        assert_eq!(render_positional("z-{{instance}}", "worker", 3), "z-3");
370        assert_eq!(
371            render_positional("{{name}}-{{instance}}d", "worker", 3),
372            "worker-3d"
373        );
374        assert_eq!(render_positional("91{{instance}}", "worker", 7), "917");
375    }
376
377    #[test]
378    fn a_value_with_no_token_is_returned_unchanged() {
379        // The collision case the doubled braces exist for: single braces are
380        // ordinary content and must survive untouched. Both renderers, since
381        // a JSON blob reaches a child through the fallible one.
382        let empty = SecretView::empty("production".to_string());
383        for value in [
384            r#"{"ts":"%t","level":"%l"}"#,
385            r#"{"a":{"b":1}}"#,
386            "^[a-z]{2,3}$",
387            "plain",
388        ] {
389            assert_eq!(
390                render_positional(value, "worker", 1),
391                value,
392                "unchanged: {value}"
393            );
394            assert_eq!(
395                render(value, "worker", 1, &empty).unwrap(),
396                value,
397                "unchanged: {value}"
398            );
399            assert!(validate(value).is_ok(), "and accepted: {value}");
400        }
401    }
402
403    #[test]
404    fn an_unknown_token_is_refused_by_name() {
405        let err = validate("z-{{instnace}}").unwrap_err();
406        assert!(matches!(&err, TemplateError::UnknownToken { token } if token == "instnace"));
407        let rendered = err.to_string();
408        assert!(rendered.contains("instnace"), "names the typo: {rendered}");
409        assert!(
410            rendered.contains("instance"),
411            "and what is valid: {rendered}"
412        );
413        assert!(
414            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
415            "no em or en dash in copy a user reads: {rendered}"
416        );
417    }
418
419    #[test]
420    fn doubling_escapes_a_literal_token() {
421        assert_eq!(
422            render_positional("{{{{instance}}}}", "worker", 3),
423            "{{instance}}"
424        );
425        assert!(validate("{{{{ .Values.port }}}}").is_ok());
426        assert_eq!(
427            render_positional("{{{{ .Values.port }}}}", "worker", 3),
428            "{{ .Values.port }}",
429            "a Helm template passes through for the tool that consumes it"
430        );
431    }
432
433    #[test]
434    fn an_unclosed_token_is_refused() {
435        assert!(validate("z-{{instance").is_err());
436    }
437
438    fn view(environment: &str) -> SecretView {
439        use crate::secrets::ProviderCache;
440        use std::collections::{BTreeMap, BTreeSet};
441        let store = BTreeMap::from([(
442            "DB_PASSWORD".to_string(),
443            BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
444        )]);
445        let providers = ProviderCache {
446            values: BTreeMap::from([(
447                "vercel".to_string(),
448                BTreeMap::from([(
449                    "API_KEY".to_string(),
450                    BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
451                )]),
452            )]),
453            pushed: BTreeMap::from([(
454                "vercel".to_string(),
455                BTreeSet::from(["production".to_string()]),
456            )]),
457        };
458        SecretView::new(environment.to_string(), store, providers)
459    }
460
461    #[test]
462    fn a_secret_token_validates_with_and_without_a_namespace() {
463        assert!(validate("{{secret:DB_PASSWORD}}").is_ok());
464        assert!(validate("{{secret:vercel/API_KEY}}").is_ok());
465        assert!(validate("postgres://u:{{secret:DB_PASSWORD}}@db/app").is_ok());
466    }
467
468    #[test]
469    fn a_malformed_reference_is_refused_at_config_time() {
470        for bad in [
471            "{{secret:}}",
472            "{{secret:/KEY}}",
473            "{{secret:ns/}}",
474            "{{secret:a/b/c}}",
475            "{{secret:has space}}",
476        ] {
477            let err = validate(bad).unwrap_err();
478            let rendered = err.to_string();
479            assert!(rendered.contains("secret"), "{bad}: {rendered}");
480        }
481    }
482
483    #[test]
484    fn an_unknown_prefix_is_still_refused_by_name() {
485        // The closed token set is the whole reason the prefix exists.
486        let err = validate("{{sekret:K}}").unwrap_err();
487        assert!(matches!(&err, TemplateError::UnknownToken { token } if token == "sekret:K"));
488    }
489
490    #[test]
491    fn render_substitutes_a_resolved_secret() {
492        assert_eq!(
493            render("pw={{secret:DB_PASSWORD}}", "web", 0, &view("production")).unwrap(),
494            "pw=hunter2"
495        );
496        assert_eq!(
497            render("{{secret:vercel/API_KEY}}", "web", 0, &view("production")).unwrap(),
498            "sk_live"
499        );
500    }
501
502    #[test]
503    fn positional_tokens_still_render_beside_a_secret() {
504        assert_eq!(
505            render(
506                "{{name}}-{{instance}}-{{secret:DB_PASSWORD}}",
507                "web",
508                3,
509                &view("production")
510            )
511            .unwrap(),
512            "web-3-hunter2"
513        );
514    }
515
516    #[test]
517    fn an_unresolvable_key_errors_naming_the_reference_and_the_environment() {
518        let err = render("{{secret:ABSENT}}", "web", 0, &view("production")).unwrap_err();
519        assert!(!err.is_retriable(), "a missing key is nobody's to retry");
520        let rendered = err.to_string();
521        assert!(rendered.contains("{{secret:ABSENT}}"), "{rendered}");
522        assert!(rendered.contains("production"), "{rendered}");
523    }
524
525    #[test]
526    fn a_secret_missing_only_in_this_environment_errors_rather_than_borrowing_another() {
527        let err = render("{{secret:DB_PASSWORD}}", "web", 0, &view("staging")).unwrap_err();
528        assert!(err.to_string().contains("staging"));
529    }
530
531    #[test]
532    fn an_unready_namespace_is_retriable_and_says_which_one() {
533        let err = render("{{secret:vault/ANY}}", "web", 0, &view("production")).unwrap_err();
534        assert!(err.is_retriable(), "no dog has pushed under this name yet");
535        let rendered = err.to_string();
536        assert!(rendered.contains("vault"), "{rendered}");
537    }
538
539    #[test]
540    fn a_namespace_that_is_up_and_lacks_the_key_is_not_retriable() {
541        let err = render("{{secret:vercel/ABSENT}}", "web", 0, &view("production")).unwrap_err();
542        assert!(!err.is_retriable());
543    }
544
545    /// Every variant, both renderings, as exact strings (IR-41): a field
546    /// added later that captured a resolved value would leak through the
547    /// derived `Debug`, and a `contains` check cannot see a field it was
548    /// never told to look for.
549    #[test]
550    fn no_render_error_ever_prints_a_value() {
551        let unresolved = render("{{secret:ABSENT}}", "web", 0, &view("production")).unwrap_err();
552        assert_eq!(
553            unresolved.to_string(),
554            "`{{secret:ABSENT}}` has no value in the `production` environment"
555        );
556        assert_eq!(
557            format!("{unresolved:?}"),
558            "Unresolved { reference: \"{{secret:ABSENT}}\", environment: \"production\" }"
559        );
560
561        let unready = render("{{secret:vault/ANY}}", "web", 0, &view("production")).unwrap_err();
562        assert_eq!(
563            unready.to_string(),
564            "`{{secret:vault/ANY}}` reads the `vault` namespace, which no provider dog \
565             has pushed to for the `production` environment yet"
566        );
567        assert_eq!(
568            format!("{unready:?}"),
569            "NamespaceUnready { namespace: \"vault\", reference: \"{{secret:vault/ANY}}\", \
570             environment: \"production\" }"
571        );
572
573        for rendered in [unresolved.to_string(), unready.to_string()] {
574            assert!(
575                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
576                "no em or en dash in copy a user reads: {rendered}"
577            );
578        }
579    }
580
581    #[test]
582    fn render_positional_leaves_a_secret_token_alone() {
583        // normalize's log-path collision check runs at config time with no
584        // store, and two instances share a secret's value anyway.
585        assert_eq!(
586            render_positional("{{secret:DB_PASSWORD}}-{{instance}}", "web", 2),
587            "{{secret:DB_PASSWORD}}-2"
588        );
589    }
590
591    #[test]
592    fn doubling_still_escapes_a_secret_token() {
593        assert_eq!(
594            render("{{{{secret:DB_PASSWORD}}}}", "web", 0, &view("production")).unwrap(),
595            "{{secret:DB_PASSWORD}}"
596        );
597    }
598}