Skip to main content

shep_core/config/
template.rs

1//! The `{{...}}` grammar for Flockfile values.
2//!
3//! Four tokens in env values, args, and the two log-path fields:
4//! `{{instance}}` and `{{name}}` substitute from the sheep's identity,
5//! `{{SHEP_HOME}}` from the directory the shepherd keeps its state in, and
6//! `{{secret:KEY}}` (or `{{secret:namespace/KEY}}`) reads
7//! [`crate::secrets`]. An unknown token between doubled braces is refused
8//! at config time rather than reaching a child process as literal text.
9//!
10//! # Casing
11//!
12//! **An uppercase name is something from the environment; a lowercase one is
13//! something from the config.** `SHEP_HOME` is an environment variable, so
14//! it is `{{SHEP_HOME}}`. `name` and `instance` are fields of the app, so
15//! they stay lowercase. `{{secret:API_KEY}}` is both halves at once: a
16//! lowercase `secret:` names the mechanism, and the uppercase key is an
17//! environment-variable-shaped name.
18//!
19//! The rule is convention rather than enforcement, since [`crate::secrets`]
20//! accepts a key in any case. It exists so a reader can predict what a token
21//! added later will look like.
22//!
23//! Doubled braces avoid collision with single-brace content already in these
24//! values: JSON blobs, regex quantifiers, Go or Helm templates passed
25//! through as args.
26//!
27//! `{{{{` and `}}}}` escape to literal `{{` and `}}`. A lone `}}`, as in
28//! `{"a":{"b":1}}`, is ordinary text and passes through unchanged.
29
30use core::convert::Infallible;
31use core::fmt;
32use std::borrow::Cow;
33use std::path::Path;
34
35use crate::secrets::{Resolution, SecretRef, SecretView};
36
37/// The positional tokens this grammar knows, in the order an error lists
38/// them.
39const TOKENS: &[&str] = &["instance", "name", SHEP_HOME];
40
41/// The prefix marking a store lookup, as it appears inside the braces.
42const SECRET_PREFIX: &str = "secret:";
43
44/// The token naming the shepherd's own home directory.
45///
46/// Uppercase because it names an environment variable; see the casing rule
47/// in this module's own documentation.
48const SHEP_HOME: &str = "SHEP_HOME";
49
50/// The store reference `token` names, or `None` when it is not a well-formed
51/// `{{secret:...}}` body.
52///
53/// [`SecretRef::parse`] is the only grammar for a reference, so a token
54/// [`validate`] accepts is one [`render`] can parse.
55///
56/// `pub(crate)`: [`crate::secrets::references`] shares this rather than
57/// re-deriving what a `secret:` body is.
58pub(crate) fn secret_reference(token: &str) -> Option<SecretRef<'_>> {
59    token.strip_prefix(SECRET_PREFIX).and_then(SecretRef::parse)
60}
61
62/// A value that is not a valid template.
63///
64/// `pub(crate)`: `normalize` is the only caller, and wraps this in its own
65/// [`NormalizeError::BadTemplate`](super::normalize::NormalizeError::BadTemplate).
66#[non_exhaustive]
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub(crate) enum TemplateError {
69    /// A `{{...}}` naming something this grammar does not define
70    UnknownToken {
71        /// The token as the user wrote it, without the braces
72        token: String,
73    },
74    /// A `{{` with no closing `}}`
75    Unclosed,
76}
77
78impl fmt::Display for TemplateError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::UnknownToken { token } if token.starts_with(SECRET_PREFIX) => write!(
82                f,
83                "`{{{{{token}}}}}` is not a valid secret reference: write \
84                 `{{{{secret:KEY}}}}` or `{{{{secret:namespace/KEY}}}}`, where each part \
85                 holds only letters, digits, `.`, `_` or `-` and does not start with `.`"
86            ),
87            Self::UnknownToken { token } => write!(
88                f,
89                "`{{{{{token}}}}}` is not a template token: valid tokens are {}",
90                TOKENS
91                    .iter()
92                    .map(|t| format!("`{{{{{t}}}}}`"))
93                    .chain(core::iter::once(format!("`{{{{{SECRET_PREFIX}...}}}}`")))
94                    .collect::<Vec<_>>()
95                    .join(", ")
96            ),
97            Self::Unclosed => f.write_str("a `{{` in this value is never closed by a `}}`"),
98        }
99    }
100}
101
102impl core::error::Error for TemplateError {}
103
104/// A value whose grammar is valid but whose `{{secret:...}}` cannot be
105/// resolved.
106///
107/// Redacted by construction (IR-41): a variant carries the reference as the
108/// operator wrote it, the namespace and the environment, and no field can
109/// hold a value.
110///
111/// `#[non_exhaustive]`: shep-core is published, so a new way for a
112/// reference to fail must not break an out-of-tree `match`. It costs
113/// in-tree callers nothing, since [`Self::is_retriable`] already gives them
114/// the one classification they act on.
115#[non_exhaustive]
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum RenderError {
118    /// The store holds no value for this reference in this environment
119    Unresolved {
120        /// The reference as it appears in the value, braces and all
121        reference: String,
122        /// The environment the lookup ran against
123        environment: String,
124    },
125    /// No provider dog has pushed the namespace this reference reads for
126    /// the environment it was resolved in
127    NamespaceUnready {
128        /// The namespace the reference names
129        namespace: String,
130        /// The reference as it appears in the value, braces and all
131        reference: String,
132        /// The environment the lookup ran against
133        environment: String,
134    },
135}
136
137impl RenderError {
138    /// Whether waiting could make this reference resolve.
139    ///
140    /// `true` for [`Self::NamespaceUnready`] alone: a provider dog that has
141    /// not pushed this environment yet is the one failure a later attempt
142    /// can clear. An [`Self::Unresolved`] waits on a person instead.
143    #[must_use]
144    pub fn is_retriable(&self) -> bool {
145        matches!(self, Self::NamespaceUnready { .. })
146    }
147}
148
149impl fmt::Display for RenderError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Unresolved {
153                reference,
154                environment,
155            } => write!(
156                f,
157                "`{reference}` has no value in the `{environment}` environment"
158            ),
159            Self::NamespaceUnready {
160                namespace,
161                reference,
162                environment,
163            } => write!(
164                f,
165                "`{reference}` reads the `{namespace}` namespace, which no provider dog \
166                 has pushed to for the `{environment}` environment yet"
167            ),
168        }
169    }
170}
171
172impl core::error::Error for RenderError {}
173
174/// One piece of `value` as [`walk`] sees it: ordinary text, or a token name
175/// with the braces stripped.
176///
177/// `pub(crate)`: [`crate::secrets::references`] matches on this directly
178/// rather than [`walk`] growing a second, narrower traversal.
179pub(crate) enum Segment<'a> {
180    /// A run of ordinary text, copied through unchanged.
181    Literal(&'a str),
182    /// The name between a `{{` and its `}}`, braces stripped.
183    Token(&'a str),
184}
185
186/// How far [`walk`] got through a value.
187pub(crate) enum Completion {
188    /// Every `{{` was closed by a `}}`.
189    Complete,
190    /// A `{{` was never closed; the segments before it were still emitted.
191    Unclosed,
192}
193
194/// Walks `value`, calling `on_segment` for each literal run and each token.
195///
196/// One walker, one closure, so [`validate`], [`render`], [`render_positional`]
197/// and [`crate::secrets::references`] can never disagree about what a token
198/// is.
199///
200/// Generic over the closure's error so each caller keeps its own, with an
201/// unclosed `{{` reported through [`Completion`] rather than as an error
202/// every caller would have to be able to spell.
203///
204/// `pub(crate)`: [`crate::secrets::references`] walks a config's own values
205/// for `{{secret:...}}` tokens rather than parsing them a second way.
206///
207/// # Errors
208///
209/// Whatever `on_segment` returns, at the first segment it refuses.
210pub(crate) fn walk<E>(
211    value: &str,
212    mut on_segment: impl FnMut(Segment<'_>) -> Result<(), E>,
213) -> Result<Completion, E> {
214    let bytes = value.as_bytes();
215    let mut at = 0;
216    let mut literal_from = 0;
217    while at < bytes.len() {
218        if bytes[at..].starts_with(b"{{{{") {
219            on_segment(Segment::Literal(&value[literal_from..at]))?;
220            on_segment(Segment::Literal("{{"))?;
221            at += 4;
222            literal_from = at;
223        } else if bytes[at..].starts_with(b"}}}}") {
224            on_segment(Segment::Literal(&value[literal_from..at]))?;
225            on_segment(Segment::Literal("}}"))?;
226            at += 4;
227            literal_from = at;
228        } else if bytes[at..].starts_with(b"{{") {
229            on_segment(Segment::Literal(&value[literal_from..at]))?;
230            let rest = &value[at + 2..];
231            let Some(end) = rest.find("}}") else {
232                return Ok(Completion::Unclosed);
233            };
234            on_segment(Segment::Token(&rest[..end]))?;
235            at += 2 + end + 2;
236            literal_from = at;
237        } else {
238            at += 1;
239        }
240    }
241    on_segment(Segment::Literal(&value[literal_from..]))?;
242    Ok(Completion::Complete)
243}
244
245/// Writes `token` back with its braces, for a token the caller leaves alone.
246fn push_token(out: &mut String, token: &str) {
247    out.push_str("{{");
248    out.push_str(token);
249    out.push_str("}}");
250}
251
252/// The value `reference` names in `secrets`.
253///
254/// # Errors
255///
256/// - [`RenderError::NamespaceUnready`]: the reference names a namespace no
257///   provider has pushed for this view's environment.
258/// - [`RenderError::Unresolved`]: every other miss.
259fn resolve_secret<'a>(
260    reference: &SecretRef<'_>,
261    secrets: &'a SecretView,
262) -> Result<&'a str, RenderError> {
263    match (secrets.resolve(reference), reference.namespace) {
264        (Resolution::Found(value), _) => Ok(value),
265        (Resolution::MissingNamespace, Some(namespace)) => Err(RenderError::NamespaceUnready {
266            namespace: namespace.to_string(),
267            reference: reference.to_string(),
268            environment: secrets.environment().to_string(),
269        }),
270        (Resolution::MissingKey | Resolution::MissingNamespace, _) => {
271            Err(RenderError::Unresolved {
272                reference: reference.to_string(),
273                environment: secrets.environment().to_string(),
274            })
275        }
276    }
277}
278
279/// Whether any token in `value` satisfies `wanted`.
280///
281/// Walks the same tokenizer the renderers resolve against, so a token this
282/// misses is one they would not have substituted either. An escaped
283/// `{{{{name}}}}` is a literal here, as it is there.
284fn holds_token(value: &str, wanted: impl Fn(&str) -> bool) -> bool {
285    let mut found = false;
286    let _ = walk::<Infallible>(value, |segment| {
287        if let Segment::Token(token) = segment
288            && wanted(token)
289        {
290            found = true;
291        }
292        Ok(())
293    });
294    found
295}
296
297/// Whether `value` carries a `{{secret:...}}` this grammar would resolve.
298///
299/// `pub(crate)`: `normalize` asks it of the two log-path fields, which may
300/// not hold a secret.
301pub(crate) fn holds_secret(value: &str) -> bool {
302    holds_token(value, |token| secret_reference(token).is_some())
303}
304
305/// Whether `value` carries a `{{SHEP_HOME}}`.
306///
307/// `pub(crate)`: `normalize` asks it of every templated field, to refuse a
308/// value nothing could expand rather than hand a child a path with braces
309/// in it.
310pub(crate) fn holds_shep_home(value: &str) -> bool {
311    holds_token(value, |token| token == SHEP_HOME)
312}
313
314/// Checks that every `{{...}}` in `value` names a token this grammar defines.
315///
316/// `pub(crate)`: only `normalize` asks this, at config time. [`render`] stays
317/// public since shep-daemon's `assemble` runs it on already-validated values.
318///
319/// # Errors
320///
321/// - [`TemplateError::UnknownToken`]: a token this grammar does not define.
322/// - [`TemplateError::Unclosed`]: a `{{` with no closing `}}`.
323pub(crate) fn validate(value: &str) -> Result<(), TemplateError> {
324    let completion = walk(value, |segment| match segment {
325        Segment::Literal(_) => Ok(()),
326        Segment::Token(token) if TOKENS.contains(&token) || secret_reference(token).is_some() => {
327            Ok(())
328        }
329        Segment::Token(token) => Err(TemplateError::UnknownToken {
330            token: token.to_string(),
331        }),
332    })?;
333    match completion {
334        Completion::Complete => Ok(()),
335        Completion::Unclosed => Err(TemplateError::Unclosed),
336    }
337}
338
339/// The text a token that both renderers resolve identically expands to, or
340/// `None` for a token neither knows positionally.
341///
342/// The positional tokens live here rather than in each renderer's match: one
343/// added to only one of them would make a value render one way under
344/// `render_positional` and another under `render`, which is not a difference
345/// either caller could see coming.
346///
347/// `{{SHEP_HOME}}` with no `shep_home` answers `None`, so it is written back
348/// with its braces the way an undefined token is. `normalize` refuses that
349/// value before either renderer sees it, so the arm is a degradation rather
350/// than a reachable spelling.
351fn positional<'a>(
352    token: &str,
353    name: &'a str,
354    slot: &'a str,
355    shep_home: Option<&'a Path>,
356) -> Option<Cow<'a, str>> {
357    match token {
358        "instance" => Some(Cow::Borrowed(slot)),
359        "name" => Some(Cow::Borrowed(name)),
360        SHEP_HOME => shep_home.map(|home| home.to_string_lossy()),
361        _ => None,
362    }
363}
364
365/// Substitutes `{{instance}}`, `{{name}}` and `{{SHEP_HOME}}` only, leaving
366/// every other token, `{{secret:...}}` included, exactly as written.
367///
368/// For callers that have no store to consult. `normalize` uses it to compare
369/// two instances' log paths, where a secret resolves to the same value for
370/// both instances and so cannot tell them apart anyway.
371///
372/// `shep_home` is the directory `$SHEP_HOME` names, from
373/// [`crate::paths::shep_home`]. `None` leaves `{{SHEP_HOME}}` written back
374/// with its braces; `normalize` refuses that value rather than letting one
375/// reach a child.
376///
377/// Call `validate` first: an unclosed `{{` renders truncated at that
378/// point.
379#[must_use]
380pub fn render_positional(
381    value: &str,
382    name: &str,
383    instance: u32,
384    shep_home: Option<&Path>,
385) -> String {
386    let mut out = String::with_capacity(value.len());
387    let slot = instance.to_string();
388    let _: Result<Completion, Infallible> = walk(value, |segment| {
389        match segment {
390            Segment::Literal(literal) => out.push_str(literal),
391            Segment::Token(token) => match positional(token, name, &slot, shep_home) {
392                Some(text) => out.push_str(&text),
393                None => push_token(&mut out, token),
394            },
395        }
396        Ok(())
397    });
398    out
399}
400
401/// Substitutes every token in `value`, resolving `{{secret:...}}` against
402/// `secrets`.
403///
404/// `shep_home` is the directory `$SHEP_HOME` names, and carries the same
405/// meaning it does in [`render_positional`]: the two share one resolver, so
406/// a token cannot expand one way here and another way there.
407///
408/// Call `validate` first: this assumes the grammar already passed, so a
409/// token this grammar does not define is written back as it was, and an
410/// unclosed `{{` renders truncated at that point.
411///
412/// # Errors
413///
414/// - [`RenderError::Unresolved`]: a reference the store has no value for in
415///   this view's environment. Nothing but a person will supply it.
416/// - [`RenderError::NamespaceUnready`]: a namespace no provider dog has
417///   pushed to for this view's environment yet.
418///   [`RenderError::is_retriable`] is `true` for this one alone.
419pub fn render(
420    value: &str,
421    name: &str,
422    instance: u32,
423    shep_home: Option<&Path>,
424    secrets: &SecretView,
425) -> Result<String, RenderError> {
426    let mut out = String::with_capacity(value.len());
427    let slot = instance.to_string();
428    walk(value, |segment| {
429        match segment {
430            Segment::Literal(literal) => out.push_str(literal),
431            Segment::Token(token) => match positional(token, name, &slot, shep_home) {
432                Some(text) => out.push_str(&text),
433                None => match secret_reference(token) {
434                    Some(reference) => out.push_str(resolve_secret(&reference, secrets)?),
435                    None => push_token(&mut out, token),
436                },
437            },
438        }
439        Ok(())
440    })?;
441    Ok(out)
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn the_identity_tokens_render() {
450        assert_eq!(
451            render_positional("z-{{instance}}", "worker", 3, None),
452            "z-3"
453        );
454        assert_eq!(
455            render_positional("{{name}}-{{instance}}d", "worker", 3, None),
456            "worker-3d"
457        );
458        assert_eq!(
459            render_positional("91{{instance}}", "worker", 7, None),
460            "917"
461        );
462    }
463
464    #[test]
465    fn a_value_with_no_token_is_returned_unchanged() {
466        // The collision case the doubled braces exist for: single braces are
467        // ordinary content and must survive untouched. Both renderers, since
468        // a JSON blob reaches a child through the fallible one.
469        let empty = SecretView::empty("production".to_string());
470        for value in [
471            r#"{"ts":"%t","level":"%l"}"#,
472            r#"{"a":{"b":1}}"#,
473            "^[a-z]{2,3}$",
474            "plain",
475        ] {
476            assert_eq!(
477                render_positional(value, "worker", 1, None),
478                value,
479                "unchanged: {value}"
480            );
481            assert_eq!(
482                render(value, "worker", 1, None, &empty).unwrap(),
483                value,
484                "unchanged: {value}"
485            );
486            assert!(validate(value).is_ok(), "and accepted: {value}");
487        }
488    }
489
490    #[test]
491    fn shep_home_renders_from_the_directory_it_is_handed() {
492        let home = Path::new("/home/ada/.shep");
493        let empty = SecretView::empty("production".to_string());
494        for value in ["{{SHEP_HOME}}/logs/out.log", "{{SHEP_HOME}}"] {
495            let expected = value.replace("{{SHEP_HOME}}", "/home/ada/.shep");
496            assert_eq!(
497                render_positional(value, "worker", 0, Some(home)),
498                expected,
499                "positionally: {value}"
500            );
501            assert_eq!(
502                render(value, "worker", 0, Some(home), &empty).unwrap(),
503                expected,
504                "and through the store: {value}"
505            );
506        }
507    }
508
509    /// The invariant `positional` exists for: a token both renderers reach
510    /// through one resolver cannot expand one way here and another there.
511    /// This is the check that fails if a later token is wired into only one.
512    #[test]
513    fn every_positional_token_renders_alike_in_both_renderers() {
514        let home = Path::new("/home/ada/.shep");
515        let empty = SecretView::empty("production".to_string());
516        for token in TOKENS {
517            let value = format!("<{{{{{token}}}}}>");
518            assert_eq!(
519                render_positional(&value, "worker", 4, Some(home)),
520                render(&value, "worker", 4, Some(home), &empty).unwrap(),
521                "both renderers agree on {value}"
522            );
523        }
524    }
525
526    /// Instance-independent on purpose: it is the one positional token that
527    /// cannot tell two slots apart, which is what normalize's log-path
528    /// collision check reads it for.
529    #[test]
530    fn shep_home_renders_the_same_for_every_instance() {
531        let home = Path::new("/home/ada/.shep");
532        let value = "{{SHEP_HOME}}/logs/{{name}}.log";
533        assert_eq!(
534            render_positional(value, "worker", 0, Some(home)),
535            render_positional(value, "worker", 1, Some(home))
536        );
537    }
538
539    #[test]
540    fn shep_home_with_no_home_is_written_back_with_its_braces() {
541        // normalize refuses this value rather than letting it reach a child,
542        // so the arm is a degradation; what it must not do is drop the token.
543        assert_eq!(
544            render_positional("{{SHEP_HOME}}/logs/out.log", "worker", 0, None),
545            "{{SHEP_HOME}}/logs/out.log"
546        );
547    }
548
549    #[test]
550    fn doubling_escapes_shep_home_too() {
551        assert_eq!(
552            render_positional(
553                "{{{{SHEP_HOME}}}}",
554                "worker",
555                0,
556                Some(Path::new("/home/ada/.shep"))
557            ),
558            "{{SHEP_HOME}}"
559        );
560    }
561
562    /// Casing is the rule, not a spelling shep is relaxed about: a lowercase
563    /// name would read as a config field, and this grammar has none by that
564    /// name.
565    #[test]
566    fn a_lowercase_shep_home_is_not_the_token() {
567        let err = validate("{{shep_home}}/logs").unwrap_err();
568        assert!(matches!(&err, TemplateError::UnknownToken { token } if token == "shep_home"));
569        assert!(
570            err.to_string().contains("{{SHEP_HOME}}"),
571            "and the error shows the casing: {err}"
572        );
573    }
574
575    /// The sibling of the test below, and the one with teeth: `holds_secret`
576    /// is what `normalize` refuses a log path on, so a `secret_reference`
577    /// that started accepting an empty key would widen that refusal with
578    /// nothing to say so.
579    #[test]
580    fn holds_secret_sees_a_reference_and_not_its_escape() {
581        assert!(holds_secret("{{secret:DB_PASSWORD}}"));
582        assert!(holds_secret("postgres://u:{{secret:PW}}@db/app"));
583        assert!(holds_secret("{{secret:vercel/API_KEY}}"));
584        assert!(!holds_secret("{{{{secret:DB_PASSWORD}}}}"));
585        assert!(!holds_secret("{{secret:}}"), "not a well-formed reference");
586        assert!(!holds_secret("{{sekret:K}}"), "not this grammar's prefix");
587        assert!(!holds_secret("{{name}}-out.log"));
588        assert!(!holds_secret("/var/log/out.log"));
589    }
590
591    #[test]
592    fn holds_shep_home_sees_the_token_and_not_its_escape() {
593        assert!(holds_shep_home("{{SHEP_HOME}}/logs/out.log"));
594        assert!(!holds_shep_home("{{{{SHEP_HOME}}}}/logs/out.log"));
595        assert!(!holds_shep_home("/var/log/out.log"));
596        assert!(!holds_shep_home("{{name}}-out.log"));
597    }
598
599    #[test]
600    fn an_unknown_token_is_refused_by_name() {
601        let err = validate("z-{{instnace}}").unwrap_err();
602        assert!(matches!(&err, TemplateError::UnknownToken { token } if token == "instnace"));
603        let rendered = err.to_string();
604        assert!(rendered.contains("instnace"), "names the typo: {rendered}");
605        assert!(
606            rendered.contains("instance"),
607            "and what is valid: {rendered}"
608        );
609        assert!(
610            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
611            "no em or en dash in copy a user reads: {rendered}"
612        );
613    }
614
615    #[test]
616    fn doubling_escapes_a_literal_token() {
617        assert_eq!(
618            render_positional("{{{{instance}}}}", "worker", 3, None),
619            "{{instance}}"
620        );
621        assert!(validate("{{{{ .Values.port }}}}").is_ok());
622        assert_eq!(
623            render_positional("{{{{ .Values.port }}}}", "worker", 3, None),
624            "{{ .Values.port }}",
625            "a Helm template passes through for the tool that consumes it"
626        );
627    }
628
629    #[test]
630    fn an_unclosed_token_is_refused() {
631        assert!(validate("z-{{instance").is_err());
632    }
633
634    fn view(environment: &str) -> SecretView {
635        use crate::secrets::ProviderCache;
636        use std::collections::{BTreeMap, BTreeSet};
637        let store = BTreeMap::from([(
638            "DB_PASSWORD".to_string(),
639            BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
640        )]);
641        let providers = ProviderCache {
642            values: BTreeMap::from([(
643                "vercel".to_string(),
644                BTreeMap::from([(
645                    "API_KEY".to_string(),
646                    BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
647                )]),
648            )]),
649            pushed: BTreeMap::from([(
650                "vercel".to_string(),
651                BTreeSet::from(["production".to_string()]),
652            )]),
653        };
654        SecretView::new(environment.to_string(), store, providers)
655    }
656
657    #[test]
658    fn a_secret_token_validates_with_and_without_a_namespace() {
659        assert!(validate("{{secret:DB_PASSWORD}}").is_ok());
660        assert!(validate("{{secret:vercel/API_KEY}}").is_ok());
661        assert!(validate("postgres://u:{{secret:DB_PASSWORD}}@db/app").is_ok());
662    }
663
664    #[test]
665    fn a_malformed_reference_is_refused_at_config_time() {
666        for bad in [
667            "{{secret:}}",
668            "{{secret:/KEY}}",
669            "{{secret:ns/}}",
670            "{{secret:a/b/c}}",
671            "{{secret:has space}}",
672        ] {
673            let err = validate(bad).unwrap_err();
674            let rendered = err.to_string();
675            assert!(rendered.contains("secret"), "{bad}: {rendered}");
676        }
677    }
678
679    #[test]
680    fn an_unknown_prefix_is_still_refused_by_name() {
681        // The closed token set is the whole reason the prefix exists.
682        let err = validate("{{sekret:K}}").unwrap_err();
683        assert!(matches!(&err, TemplateError::UnknownToken { token } if token == "sekret:K"));
684    }
685
686    #[test]
687    fn render_substitutes_a_resolved_secret() {
688        assert_eq!(
689            render(
690                "pw={{secret:DB_PASSWORD}}",
691                "web",
692                0,
693                None,
694                &view("production")
695            )
696            .unwrap(),
697            "pw=hunter2"
698        );
699        assert_eq!(
700            render(
701                "{{secret:vercel/API_KEY}}",
702                "web",
703                0,
704                None,
705                &view("production")
706            )
707            .unwrap(),
708            "sk_live"
709        );
710    }
711
712    #[test]
713    fn positional_tokens_still_render_beside_a_secret() {
714        assert_eq!(
715            render(
716                "{{name}}-{{instance}}-{{secret:DB_PASSWORD}}",
717                "web",
718                3,
719                None,
720                &view("production")
721            )
722            .unwrap(),
723            "web-3-hunter2"
724        );
725    }
726
727    #[test]
728    fn an_unresolvable_key_errors_naming_the_reference_and_the_environment() {
729        let err = render("{{secret:ABSENT}}", "web", 0, None, &view("production")).unwrap_err();
730        assert!(!err.is_retriable(), "a missing key is nobody's to retry");
731        let rendered = err.to_string();
732        assert!(rendered.contains("{{secret:ABSENT}}"), "{rendered}");
733        assert!(rendered.contains("production"), "{rendered}");
734    }
735
736    #[test]
737    fn a_secret_missing_only_in_this_environment_errors_rather_than_borrowing_another() {
738        let err = render("{{secret:DB_PASSWORD}}", "web", 0, None, &view("staging")).unwrap_err();
739        assert!(err.to_string().contains("staging"));
740    }
741
742    #[test]
743    fn an_unready_namespace_is_retriable_and_says_which_one() {
744        let err = render("{{secret:vault/ANY}}", "web", 0, None, &view("production")).unwrap_err();
745        assert!(err.is_retriable(), "no dog has pushed under this name yet");
746        let rendered = err.to_string();
747        assert!(rendered.contains("vault"), "{rendered}");
748    }
749
750    #[test]
751    fn a_namespace_that_is_up_and_lacks_the_key_is_not_retriable() {
752        let err = render(
753            "{{secret:vercel/ABSENT}}",
754            "web",
755            0,
756            None,
757            &view("production"),
758        )
759        .unwrap_err();
760        assert!(!err.is_retriable());
761    }
762
763    /// Every variant, both renderings, as exact strings (IR-41): a field
764    /// added later that captured a resolved value would leak through the
765    /// derived `Debug`, and a `contains` check cannot see a field it was
766    /// never told to look for.
767    #[test]
768    fn no_render_error_ever_prints_a_value() {
769        let unresolved =
770            render("{{secret:ABSENT}}", "web", 0, None, &view("production")).unwrap_err();
771        assert_eq!(
772            unresolved.to_string(),
773            "`{{secret:ABSENT}}` has no value in the `production` environment"
774        );
775        assert_eq!(
776            format!("{unresolved:?}"),
777            "Unresolved { reference: \"{{secret:ABSENT}}\", environment: \"production\" }"
778        );
779
780        let unready =
781            render("{{secret:vault/ANY}}", "web", 0, None, &view("production")).unwrap_err();
782        assert_eq!(
783            unready.to_string(),
784            "`{{secret:vault/ANY}}` reads the `vault` namespace, which no provider dog \
785             has pushed to for the `production` environment yet"
786        );
787        assert_eq!(
788            format!("{unready:?}"),
789            "NamespaceUnready { namespace: \"vault\", reference: \"{{secret:vault/ANY}}\", \
790             environment: \"production\" }"
791        );
792
793        for rendered in [unresolved.to_string(), unready.to_string()] {
794            assert!(
795                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
796                "no em or en dash in copy a user reads: {rendered}"
797            );
798        }
799    }
800
801    #[test]
802    fn render_positional_leaves_a_secret_token_alone() {
803        // normalize's log-path collision check runs at config time with no
804        // store, and two instances share a secret's value anyway.
805        assert_eq!(
806            render_positional("{{secret:DB_PASSWORD}}-{{instance}}", "web", 2, None),
807            "{{secret:DB_PASSWORD}}-2"
808        );
809    }
810
811    #[test]
812    fn doubling_still_escapes_a_secret_token() {
813        assert_eq!(
814            render(
815                "{{{{secret:DB_PASSWORD}}}}",
816                "web",
817                0,
818                None,
819                &view("production")
820            )
821            .unwrap(),
822            "{{secret:DB_PASSWORD}}"
823        );
824    }
825}