1use core::convert::Infallible;
31use core::fmt;
32use std::borrow::Cow;
33use std::path::Path;
34
35use crate::secrets::{Resolution, SecretRef, SecretView};
36
37const TOKENS: &[&str] = &["instance", "name", SHEP_HOME];
40
41const SECRET_PREFIX: &str = "secret:";
43
44const SHEP_HOME: &str = "SHEP_HOME";
49
50pub(crate) fn secret_reference(token: &str) -> Option<SecretRef<'_>> {
59 token.strip_prefix(SECRET_PREFIX).and_then(SecretRef::parse)
60}
61
62#[non_exhaustive]
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub(crate) enum TemplateError {
69 UnknownToken {
71 token: String,
73 },
74 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#[non_exhaustive]
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum RenderError {
118 Unresolved {
120 reference: String,
122 environment: String,
124 },
125 NamespaceUnready {
128 namespace: String,
130 reference: String,
132 environment: String,
134 },
135}
136
137impl RenderError {
138 #[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
174pub(crate) enum Segment<'a> {
180 Literal(&'a str),
182 Token(&'a str),
184}
185
186pub(crate) enum Completion {
188 Complete,
190 Unclosed,
192}
193
194pub(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
245fn push_token(out: &mut String, token: &str) {
247 out.push_str("{{");
248 out.push_str(token);
249 out.push_str("}}");
250}
251
252fn 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
279fn 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
297pub(crate) fn holds_secret(value: &str) -> bool {
302 holds_token(value, |token| secret_reference(token).is_some())
303}
304
305pub(crate) fn holds_shep_home(value: &str) -> bool {
311 holds_token(value, |token| token == SHEP_HOME)
312}
313
314pub(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
339fn 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#[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
401pub 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 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 #[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 #[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 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 #[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 #[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 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 #[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 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}