1use core::convert::Infallible;
17use core::fmt;
18
19use crate::secrets::{Resolution, SecretRef, SecretView};
20
21const TOKENS: &[&str] = &["instance", "name"];
24
25const SECRET_PREFIX: &str = "secret:";
27
28pub(crate) fn secret_reference(token: &str) -> Option<SecretRef<'_>> {
37 token.strip_prefix(SECRET_PREFIX).and_then(SecretRef::parse)
38}
39
40#[non_exhaustive]
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) enum TemplateError {
47 UnknownToken {
49 token: String,
51 },
52 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#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum RenderError {
96 Unresolved {
98 reference: String,
100 environment: String,
102 },
103 NamespaceUnready {
106 namespace: String,
108 reference: String,
110 environment: String,
112 },
113}
114
115impl RenderError {
116 #[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
152pub(crate) enum Segment<'a> {
158 Literal(&'a str),
160 Token(&'a str),
162}
163
164pub(crate) enum Completion {
166 Complete,
168 Unclosed,
170}
171
172pub(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
223fn push_token(out: &mut String, token: &str) {
225 out.push_str("{{");
226 out.push_str(token);
227 out.push_str("}}");
228}
229
230fn 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
257pub(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
276pub(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#[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
326pub 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 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 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 #[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 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}