1use core::fmt;
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17use crate::config::AppConfig;
18use crate::config::template;
19use crate::file_lock::FileLock;
20
21pub const SECRETS_VERSION: u32 = 1;
27
28pub const MAX_KEY_BYTES: usize = 128;
30
31pub const MAX_VALUE_BYTES: usize = 4096;
36
37pub const ALL_ENVIRONMENTS: &str = "all";
42
43#[derive(Default, Serialize, Deserialize)]
48struct SecretFile {
49 version: u32,
50 entries: BTreeMap<String, BTreeMap<String, String>>,
51}
52
53impl fmt::Debug for SecretFile {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.debug_struct("SecretFile")
57 .field("version", &self.version)
58 .field("keys", &self.entries.len())
59 .finish()
60 }
61}
62
63#[non_exhaustive]
76#[derive(Debug)]
77pub enum SecretError {
78 Io(std::io::Error),
80 Decode(serde_json::Error),
85 InvalidKey(String),
88 InvalidEnvironment(String),
90 ValueTooLong {
92 key: String,
94 len: usize,
96 },
97 FutureVersion(u32),
100}
101
102impl fmt::Display for SecretError {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::Io(err) => write!(f, "secret store I/O failed: {err}"),
106 Self::Decode(err) => write!(f, "secret store failed to parse: {err}"),
107 Self::InvalidKey(key) => write!(f, "`{key}` is not a valid secret key"),
108 Self::InvalidEnvironment(environment) => {
109 write!(f, "`{environment}` is not a valid environment name")
110 }
111 Self::ValueTooLong { key, len } => write!(
112 f,
113 "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
114 ),
115 Self::FutureVersion(version) => write!(
116 f,
117 "secret store is version {version}, newer than this build understands"
118 ),
119 }
120 }
121}
122
123impl core::error::Error for SecretError {
124 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
125 match self {
126 Self::Io(err) => Some(err),
127 Self::Decode(err) => Some(err),
128 Self::InvalidKey(_)
129 | Self::InvalidEnvironment(_)
130 | Self::ValueTooLong { .. }
131 | Self::FutureVersion(_) => None,
132 }
133 }
134}
135
136impl From<std::io::Error> for SecretError {
137 fn from(source: std::io::Error) -> Self {
138 Self::Io(source)
139 }
140}
141
142impl From<serde_json::Error> for SecretError {
143 fn from(source: serde_json::Error) -> Self {
144 Self::Decode(source)
145 }
146}
147
148#[must_use]
159pub fn is_name(value: &str) -> bool {
160 !value.is_empty()
161 && value.len() <= MAX_KEY_BYTES
162 && !value.starts_with('.')
163 && value
164 .bytes()
165 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
166}
167
168fn check_key(key: &str) -> Result<(), SecretError> {
174 if is_name(key) {
175 Ok(())
176 } else {
177 Err(SecretError::InvalidKey(key.to_string()))
178 }
179}
180
181fn check_environment(environment: &str) -> Result<(), SecretError> {
187 if is_name(environment) {
188 Ok(())
189 } else {
190 Err(SecretError::InvalidEnvironment(environment.to_string()))
191 }
192}
193
194fn read_file(path: &Path) -> Result<SecretFile, SecretError> {
200 let raw = match std::fs::read_to_string(path) {
201 Ok(raw) => raw,
202 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(SecretFile::default()),
203 Err(err) => return Err(SecretError::Io(err)),
204 };
205 let file: SecretFile = serde_json::from_str(&raw)?;
206 if file.version > SECRETS_VERSION {
207 return Err(SecretError::FutureVersion(file.version));
208 }
209 Ok(file)
210}
211
212fn write_file(path: &Path, file: &SecretFile) -> Result<(), SecretError> {
214 crate::atomic_file::write_json(path, "secrets", file).map_err(SecretError::Io)
215}
216
217pub fn all(path: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, SecretError> {
236 Ok(read_file(path)?.entries)
237}
238
239pub fn get(path: &Path, key: &str, environment: &str) -> Result<Option<String>, SecretError> {
252 check_key(key)?;
253 check_environment(environment)?;
254 Ok(all(path)?
255 .remove(key)
256 .and_then(|mut by_environment| by_environment.remove(environment)))
257}
258
259pub fn set(path: &Path, key: &str, environment: &str, value: &str) -> Result<(), SecretError> {
274 check_key(key)?;
275 check_environment(environment)?;
276 if value.len() > MAX_VALUE_BYTES {
277 return Err(SecretError::ValueTooLong {
278 key: key.to_string(),
279 len: value.len(),
280 });
281 }
282
283 let _lock = FileLock::acquire(path)?;
284 let mut file = read_file(path)?;
285 file.version = SECRETS_VERSION;
286 file.entries
287 .entry(key.to_string())
288 .or_default()
289 .insert(environment.to_string(), value.to_string());
290 write_file(path, &file)
291}
292
293pub fn unset(path: &Path, key: &str, environment: &str) -> Result<bool, SecretError> {
303 check_key(key)?;
304 check_environment(environment)?;
305
306 let _lock = FileLock::acquire(path)?;
307 let mut file = read_file(path)?;
308 let Some(by_environment) = file.entries.get_mut(key) else {
309 return Ok(false);
310 };
311 let was_present = by_environment.remove(environment).is_some();
312 if was_present {
313 if by_environment.is_empty() {
314 file.entries.remove(key);
315 }
316 file.version = SECRETS_VERSION;
317 write_file(path, &file)?;
318 }
319 Ok(was_present)
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub struct SecretRef<'a> {
328 pub namespace: Option<&'a str>,
330 pub key: &'a str,
332}
333
334impl<'a> SecretRef<'a> {
335 #[must_use]
341 pub fn parse(body: &'a str) -> Option<Self> {
342 match body.split_once('/') {
343 None if is_name(body) => Some(Self {
344 namespace: None,
345 key: body,
346 }),
347 Some((namespace, key)) if is_name(namespace) && is_name(key) => Some(Self {
348 namespace: Some(namespace),
349 key,
350 }),
351 _ => None,
352 }
353 }
354}
355
356impl fmt::Display for SecretRef<'_> {
357 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358 f.write_str("{{secret:")?;
359 if let Some(namespace) = self.namespace {
360 f.write_str(namespace)?;
361 f.write_str("/")?;
362 }
363 f.write_str(self.key)?;
364 f.write_str("}}")
365 }
366}
367
368#[must_use]
377pub fn references(config: &AppConfig) -> BTreeSet<String> {
378 let mut found = BTreeSet::new();
379 let mut scan = |value: &str| {
380 let _ = template::walk::<core::convert::Infallible>(value, |segment| {
381 if let template::Segment::Token(token) = segment
382 && let Some(reference) = template::secret_reference(token)
383 {
384 found.insert(match reference.namespace {
385 Some(namespace) => format!("{namespace}/{}", reference.key),
386 None => reference.key.to_string(),
387 });
388 }
389 Ok(())
390 });
391 };
392 for value in config.env.values() {
393 scan(value);
394 }
395 for value in &config.args {
396 scan(value);
397 }
398 if let Some(value) = &config.out_file {
399 scan(value);
400 }
401 if let Some(value) = &config.err_file {
402 scan(value);
403 }
404 found
405}
406
407#[must_use]
413pub fn namespaces_of(config: &AppConfig) -> BTreeSet<String> {
414 references(config)
415 .iter()
416 .filter_map(|reference| SecretRef::parse(reference))
417 .filter_map(|reference| reference.namespace.map(str::to_string))
418 .collect()
419}
420
421pub type NamespaceValues = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
424
425pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
433
434#[derive(Default, Clone)]
448pub struct ProviderCache {
449 pub values: NamespaceValues,
451 pub pushed: PushedPairs,
453}
454
455impl fmt::Debug for ProviderCache {
457 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458 f.debug_struct("ProviderCache")
459 .field("namespaces", &self.values.len())
460 .field("pushed", &self.pushed.len())
461 .finish()
462 }
463}
464
465#[derive(Default, Deserialize)]
470struct ProviderCacheFile {
471 version: u32,
472 #[serde(default)]
473 namespaces: NamespaceValues,
474 #[serde(default)]
475 pushed: PushedPairs,
476}
477
478impl fmt::Debug for ProviderCacheFile {
481 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482 f.debug_struct("ProviderCacheFile")
483 .field("version", &self.version)
484 .field("namespaces", &self.namespaces.len())
485 .field("pushed", &self.pushed.len())
486 .finish()
487 }
488}
489
490pub const PROVIDER_CACHE_VERSION: u32 = 2;
497
498#[must_use]
508pub fn provider_cache_on_disk(path: &Path) -> ProviderCache {
509 let Ok(raw) = std::fs::read_to_string(path) else {
510 return ProviderCache::default();
511 };
512 match serde_json::from_str::<ProviderCacheFile>(&raw) {
513 Ok(file) if file.version == PROVIDER_CACHE_VERSION => ProviderCache {
514 values: file.namespaces,
515 pushed: file.pushed,
516 },
517 _ => ProviderCache::default(),
518 }
519}
520
521pub struct SecretView {
529 environment: String,
530 store: BTreeMap<String, BTreeMap<String, String>>,
531 providers: ProviderCache,
532}
533
534impl fmt::Debug for SecretView {
536 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 f.debug_struct("SecretView")
538 .field("environment", &self.environment)
539 .field("keys", &self.store.len())
540 .field("namespaces", &self.providers.values.len())
541 .finish()
542 }
543}
544
545impl SecretView {
546 #[must_use]
548 pub fn new(
549 environment: String,
550 store: BTreeMap<String, BTreeMap<String, String>>,
551 providers: ProviderCache,
552 ) -> Self {
553 Self {
554 environment,
555 store,
556 providers,
557 }
558 }
559
560 #[must_use]
564 pub fn empty(environment: String) -> Self {
565 Self::new(environment, BTreeMap::new(), ProviderCache::default())
566 }
567
568 #[must_use]
570 pub fn environment(&self) -> &str {
571 &self.environment
572 }
573
574 #[must_use]
588 pub fn resolve(&self, reference: &SecretRef<'_>) -> Resolution<'_> {
589 let table = match reference.namespace {
590 None => Some(&self.store),
591 Some(namespace) => self.providers.values.get(namespace),
592 };
593 if let Some(value) =
594 table
595 .and_then(|table| table.get(reference.key))
596 .and_then(|by_environment| {
597 by_environment
598 .get(&self.environment)
599 .or_else(|| by_environment.get(ALL_ENVIRONMENTS))
600 })
601 {
602 return Resolution::Found(value.as_str());
603 }
604 match reference.namespace {
605 None => Resolution::MissingKey,
606 Some(namespace) if self.is_pushed(namespace) => Resolution::MissingKey,
607 Some(_) => Resolution::MissingNamespace,
608 }
609 }
610
611 fn is_pushed(&self, namespace: &str) -> bool {
621 self.providers
622 .pushed
623 .get(namespace)
624 .is_some_and(|environments| {
625 environments.contains(&self.environment) || environments.contains(ALL_ENVIRONMENTS)
626 })
627 }
628}
629
630pub enum Resolution<'a> {
639 Found(&'a str),
641 MissingKey,
645 MissingNamespace,
647}
648
649impl fmt::Debug for Resolution<'_> {
651 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652 f.write_str(match self {
653 Self::Found(_) => "Found(..)",
654 Self::MissingKey => "MissingKey",
655 Self::MissingNamespace => "MissingNamespace",
656 })
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn a_value_round_trips_through_one_environment() {
666 let dir = tempfile::tempdir().unwrap();
667 let path = dir.path().join("secrets.json");
668 set(&path, "DB_PASSWORD", "production", "hunter2").unwrap();
669 assert_eq!(
670 get(&path, "DB_PASSWORD", "production").unwrap().as_deref(),
671 Some("hunter2")
672 );
673 assert_eq!(get(&path, "DB_PASSWORD", "staging").unwrap(), None);
674 }
675
676 #[test]
677 fn a_missing_store_reads_as_empty_rather_than_enoent() {
678 let dir = tempfile::tempdir().unwrap();
679 let path = dir.path().join("secrets.json");
680 assert!(all(&path).unwrap().is_empty());
681 assert_eq!(get(&path, "ANY", "production").unwrap(), None);
682 }
683
684 #[test]
685 fn unset_removes_one_environment_and_leaves_the_others() {
686 let dir = tempfile::tempdir().unwrap();
687 let path = dir.path().join("secrets.json");
688 set(&path, "K", "production", "p").unwrap();
689 set(&path, "K", "staging", "s").unwrap();
690 assert!(unset(&path, "K", "staging").unwrap());
691 assert_eq!(get(&path, "K", "production").unwrap().as_deref(), Some("p"));
692 assert_eq!(get(&path, "K", "staging").unwrap(), None);
693 assert!(!unset(&path, "K", "staging").unwrap(), "already gone");
694 }
695
696 #[test]
697 fn a_key_that_empties_is_removed_rather_than_left_as_an_empty_map() {
698 let dir = tempfile::tempdir().unwrap();
699 let path = dir.path().join("secrets.json");
700 set(&path, "K", "production", "p").unwrap();
701 assert!(unset(&path, "K", "production").unwrap());
702 assert!(all(&path).unwrap().is_empty(), "no empty husk left behind");
703 }
704
705 #[test]
706 fn a_bad_key_is_refused_by_name_and_writes_nothing() {
707 let dir = tempfile::tempdir().unwrap();
708 let path = dir.path().join("secrets.json");
709 for key in ["", ".hidden", "has space", "has/slash", "has:colon"] {
710 let err = set(&path, key, "production", "v").unwrap_err();
711 assert!(
712 matches!(&err, SecretError::InvalidKey(k) if k == key),
713 "{key:?}: {err:?}"
714 );
715 }
716 assert!(!path.exists(), "a refused set must not create the store");
717 }
718
719 #[test]
720 fn the_all_slot_is_writable_like_any_other_environment() {
721 let dir = tempfile::tempdir().unwrap();
724 let path = dir.path().join("secrets.json");
725 set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
726 assert_eq!(
727 get(&path, "K", "all").unwrap().as_deref(),
728 Some("everywhere")
729 );
730 }
731
732 #[test]
733 fn get_does_not_fall_back_to_the_all_slot() {
734 let dir = tempfile::tempdir().unwrap();
738 let path = dir.path().join("secrets.json");
739 set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
740 assert_eq!(get(&path, "K", "staging").unwrap(), None);
741 }
742
743 #[test]
744 fn an_environment_outside_the_grammar_is_refused() {
745 let dir = tempfile::tempdir().unwrap();
746 let path = dir.path().join("secrets.json");
747 for env in ["", "has space", "has/slash"] {
748 let err = set(&path, "K", env, "v").unwrap_err();
749 assert!(
750 matches!(&err, SecretError::InvalidEnvironment(e) if e == env),
751 "{env:?}: {err:?}"
752 );
753 }
754 }
755
756 #[test]
757 fn an_oversized_value_is_refused_by_length() {
758 let dir = tempfile::tempdir().unwrap();
759 let path = dir.path().join("secrets.json");
760 let big = "x".repeat(MAX_VALUE_BYTES + 1);
761 let err = set(&path, "K", "production", &big).unwrap_err();
762 assert!(matches!(err, SecretError::ValueTooLong { len, .. } if len == big.len()));
763 }
764
765 #[test]
766 fn a_future_version_is_refused_rather_than_overwritten() {
767 let dir = tempfile::tempdir().unwrap();
768 let path = dir.path().join("secrets.json");
769 std::fs::write(&path, r#"{"version":999,"entries":{}}"#).unwrap();
770 assert!(matches!(all(&path), Err(SecretError::FutureVersion(999))));
771 assert!(matches!(
772 set(&path, "K", "production", "v"),
773 Err(SecretError::FutureVersion(999))
774 ));
775 let raw = std::fs::read_to_string(&path).unwrap();
776 assert!(raw.contains("999"), "the refused store is untouched");
777 }
778
779 #[test]
780 #[cfg(unix)]
781 fn the_store_is_owner_only() {
782 use std::os::unix::fs::PermissionsExt as _;
783 let dir = tempfile::tempdir().unwrap();
784 let path = dir.path().join("secrets.json");
785 set(&path, "K", "production", "v").unwrap();
786 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
787 assert_eq!(mode, 0o600);
788 }
789
790 #[test]
791 fn a_reference_parses_with_and_without_a_namespace() {
792 let bare = SecretRef::parse("DB_PASSWORD").unwrap();
793 assert_eq!(bare.namespace, None);
794 assert_eq!(bare.key, "DB_PASSWORD");
795
796 let scoped = SecretRef::parse("vercel/DB_PASSWORD").unwrap();
797 assert_eq!(scoped.namespace, Some("vercel"));
798 assert_eq!(scoped.key, "DB_PASSWORD");
799
800 for bad in ["", "/KEY", "ns/", "a/b/c", "ns/bad key", "bad ns/KEY"] {
801 assert!(SecretRef::parse(bad).is_none(), "{bad:?} must not parse");
802 }
803 }
804
805 #[test]
806 fn references_finds_every_secret_in_a_config_and_nothing_else() {
807 let mut config = AppConfig::minimal("web", "./srv");
808 config.env.insert("A".into(), "{{secret:ONE}}".into());
809 config.env.insert("B".into(), "plain".into());
810 config
811 .env
812 .insert("C".into(), "{{name}}-{{secret:vercel/TWO}}".into());
813 config.args = vec!["--x={{secret:ONE}}".into()];
814
815 let found = references(&config);
816 assert_eq!(
817 found,
818 BTreeSet::from(["ONE".to_string(), "vercel/TWO".to_string()]),
819 "deduplicated, and no positional tokens"
820 );
821 }
822
823 #[test]
824 fn namespaces_of_a_config_is_the_seam_boot_ordering_will_want() {
825 let mut config = AppConfig::minimal("web", "./srv");
826 config.env.insert("A".into(), "{{secret:ONE}}".into());
827 config
828 .env
829 .insert("B".into(), "{{secret:vercel/TWO}}".into());
830 assert_eq!(
831 namespaces_of(&config),
832 BTreeSet::from(["vercel".to_string()])
833 );
834 }
835
836 #[test]
837 fn provider_cache_on_disk_reads_a_real_cache_file() {
838 let dir = tempfile::tempdir().unwrap();
839 let path = dir.path().join("secrets-cache.json");
840 std::fs::write(
841 &path,
842 r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
843 )
844 .unwrap();
845 let cache = provider_cache_on_disk(&path);
846 assert_eq!(cache.values["vercel"]["API_KEY"]["production"], "sk_live");
847 assert_eq!(
848 cache.pushed["vercel"],
849 BTreeSet::from(["production".to_string()])
850 );
851 }
852
853 #[test]
854 fn provider_cache_on_disk_is_empty_for_a_missing_or_broken_file() {
855 let dir = tempfile::tempdir().unwrap();
856 assert!(
857 provider_cache_on_disk(&dir.path().join("absent.json"))
858 .values
859 .is_empty()
860 );
861
862 let broken = dir.path().join("broken.json");
863 std::fs::write(&broken, "not json").unwrap();
864 assert!(provider_cache_on_disk(&broken).values.is_empty());
865
866 let future = dir.path().join("future.json");
867 std::fs::write(&future, r#"{"version":999,"namespaces":{}}"#).unwrap();
868 assert!(provider_cache_on_disk(&future).values.is_empty());
869 }
870
871 #[test]
872 fn resolution_prefers_the_exact_environment_then_all_then_gives_up() {
873 let mut store = BTreeMap::new();
874 store.insert(
875 "K".to_string(),
876 BTreeMap::from([
877 ("production".to_string(), "prod".to_string()),
878 ("all".to_string(), "fallback".to_string()),
879 ]),
880 );
881 store.insert(
882 "ONLY_ALL".to_string(),
883 BTreeMap::from([("all".to_string(), "everywhere".to_string())]),
884 );
885 store.insert(
886 "ONLY_PROD".to_string(),
887 BTreeMap::from([("production".to_string(), "prod".to_string())]),
888 );
889
890 let view = SecretView::new("staging".to_string(), store, ProviderCache::default());
891 assert!(matches!(
892 view.resolve(&SecretRef {
893 namespace: None,
894 key: "K"
895 }),
896 Resolution::Found("fallback")
897 ));
898 assert!(matches!(
899 view.resolve(&SecretRef {
900 namespace: None,
901 key: "ONLY_ALL"
902 }),
903 Resolution::Found("everywhere")
904 ));
905 assert!(matches!(
907 view.resolve(&SecretRef {
908 namespace: None,
909 key: "ONLY_PROD"
910 }),
911 Resolution::MissingKey
912 ));
913 assert!(matches!(
914 view.resolve(&SecretRef {
915 namespace: None,
916 key: "ABSENT"
917 }),
918 Resolution::MissingKey
919 ));
920 }
921
922 fn vercel_production() -> ProviderCache {
925 ProviderCache {
926 values: BTreeMap::from([(
927 "vercel".to_string(),
928 BTreeMap::from([(
929 "PRESENT".to_string(),
930 BTreeMap::from([("production".to_string(), "v".to_string())]),
931 )]),
932 )]),
933 pushed: BTreeMap::from([(
934 "vercel".to_string(),
935 BTreeSet::from(["production".to_string()]),
936 )]),
937 }
938 }
939
940 #[test]
941 fn an_unpopulated_namespace_is_told_apart_from_a_missing_key() {
942 let view = SecretView::new(
943 "production".to_string(),
944 BTreeMap::new(),
945 vercel_production(),
946 );
947
948 assert!(matches!(
949 view.resolve(&SecretRef {
950 namespace: Some("vercel"),
951 key: "PRESENT"
952 }),
953 Resolution::Found("v")
954 ));
955 assert!(matches!(
957 view.resolve(&SecretRef {
958 namespace: Some("vercel"),
959 key: "ABSENT"
960 }),
961 Resolution::MissingKey
962 ));
963 assert!(matches!(
965 view.resolve(&SecretRef {
966 namespace: Some("vault"),
967 key: "ANY"
968 }),
969 Resolution::MissingNamespace
970 ));
971 }
972
973 #[test]
979 fn a_namespace_pushed_for_another_environment_is_not_populated_for_this_one() {
980 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_production());
981
982 assert!(
983 matches!(
984 view.resolve(&SecretRef {
985 namespace: Some("vercel"),
986 key: "PRESENT"
987 }),
988 Resolution::MissingNamespace
989 ),
990 "staging has had no push, so waiting is what fixes this"
991 );
992 }
993
994 #[test]
998 fn a_pushed_pair_missing_a_key_stays_permanent() {
999 let view = SecretView::new(
1000 "production".to_string(),
1001 BTreeMap::new(),
1002 vercel_production(),
1003 );
1004
1005 assert!(matches!(
1006 view.resolve(&SecretRef {
1007 namespace: Some("vercel"),
1008 key: "ABSENT"
1009 }),
1010 Resolution::MissingKey
1011 ));
1012 }
1013
1014 fn vercel_all() -> ProviderCache {
1017 ProviderCache {
1018 values: BTreeMap::from([(
1019 "vercel".to_string(),
1020 BTreeMap::from([(
1021 "PRESENT".to_string(),
1022 BTreeMap::from([(ALL_ENVIRONMENTS.to_string(), "v".to_string())]),
1023 )]),
1024 )]),
1025 pushed: BTreeMap::from([(
1026 "vercel".to_string(),
1027 BTreeSet::from([ALL_ENVIRONMENTS.to_string()]),
1028 )]),
1029 }
1030 }
1031
1032 #[test]
1037 fn an_all_slot_push_makes_a_genuinely_missing_key_permanent() {
1038 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1039
1040 assert!(matches!(
1041 view.resolve(&SecretRef {
1042 namespace: Some("vercel"),
1043 key: "ABSENT"
1044 }),
1045 Resolution::MissingKey
1046 ));
1047 }
1048
1049 #[test]
1053 fn an_all_slot_push_resolves_its_key_for_every_environment() {
1054 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1055
1056 assert!(matches!(
1057 view.resolve(&SecretRef {
1058 namespace: Some("vercel"),
1059 key: "PRESENT"
1060 }),
1061 Resolution::Found("v")
1062 ));
1063 }
1064
1065 #[test]
1069 fn an_empty_push_populates_the_pair_it_carried() {
1070 let view = SecretView::new(
1071 "production".to_string(),
1072 BTreeMap::new(),
1073 ProviderCache {
1074 values: BTreeMap::from([("vercel".to_string(), BTreeMap::new())]),
1075 pushed: BTreeMap::from([(
1076 "vercel".to_string(),
1077 BTreeSet::from(["production".to_string()]),
1078 )]),
1079 },
1080 );
1081
1082 assert!(matches!(
1083 view.resolve(&SecretRef {
1084 namespace: Some("vercel"),
1085 key: "ANY"
1086 }),
1087 Resolution::MissingKey
1088 ));
1089 }
1090
1091 #[test]
1092 fn a_reference_displays_the_way_an_operator_wrote_it() {
1093 assert_eq!(
1094 SecretRef {
1095 namespace: None,
1096 key: "K"
1097 }
1098 .to_string(),
1099 "{{secret:K}}"
1100 );
1101 assert_eq!(
1102 SecretRef {
1103 namespace: Some("vercel"),
1104 key: "K"
1105 }
1106 .to_string(),
1107 "{{secret:vercel/K}}"
1108 );
1109 }
1110
1111 #[test]
1114 fn debug_never_prints_a_value() {
1115 let store = BTreeMap::from([(
1116 "K".to_string(),
1117 BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1118 )]);
1119 let view = SecretView::new("production".to_string(), store, ProviderCache::default());
1120 let rendered = format!("{view:?}");
1121 assert_eq!(
1122 rendered,
1123 "SecretView { environment: \"production\", keys: 1, namespaces: 0 }"
1124 );
1125 assert!(!rendered.contains("hunter2"));
1126 }
1127
1128 #[test]
1133 fn a_secret_file_debug_never_prints_a_value() {
1134 let file = SecretFile {
1135 version: SECRETS_VERSION,
1136 entries: BTreeMap::from([(
1137 "K".to_string(),
1138 BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1139 )]),
1140 };
1141 let rendered = format!("{file:?}");
1142 assert_eq!(rendered, "SecretFile { version: 1, keys: 1 }");
1143 assert!(!rendered.contains("hunter2"));
1144 }
1145
1146 #[test]
1150 fn a_provider_cache_file_debug_never_prints_a_value() {
1151 let file = ProviderCacheFile {
1152 version: PROVIDER_CACHE_VERSION,
1153 namespaces: BTreeMap::from([(
1154 "vercel".to_string(),
1155 BTreeMap::from([(
1156 "API_KEY".to_string(),
1157 BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
1158 )]),
1159 )]),
1160 pushed: BTreeMap::from([(
1161 "vercel".to_string(),
1162 BTreeSet::from(["production".to_string()]),
1163 )]),
1164 };
1165 let rendered = format!("{file:?}");
1166 assert_eq!(
1167 rendered,
1168 "ProviderCacheFile { version: 2, namespaces: 1, pushed: 1 }"
1169 );
1170 assert!(!rendered.contains("sk_live"));
1171 }
1172
1173 #[test]
1175 fn a_provider_cache_debug_never_prints_a_value() {
1176 let cache = vercel_production();
1177 assert_eq!(
1178 format!("{cache:?}"),
1179 "ProviderCache { namespaces: 1, pushed: 1 }"
1180 );
1181 }
1182
1183 #[test]
1185 fn a_resolution_debug_never_prints_the_value_it_found() {
1186 assert_eq!(format!("{:?}", Resolution::Found("hunter2")), "Found(..)");
1187 assert_eq!(format!("{:?}", Resolution::MissingKey), "MissingKey");
1188 assert_eq!(
1189 format!("{:?}", Resolution::MissingNamespace),
1190 "MissingNamespace"
1191 );
1192 }
1193
1194 #[test]
1198 fn error_messages_name_the_key_and_never_a_value() {
1199 let too_long = SecretError::ValueTooLong {
1200 key: "K".to_string(),
1201 len: 9999,
1202 };
1203 assert_eq!(
1204 too_long.to_string(),
1205 format!("value for `K` is 9999 bytes, over the {MAX_VALUE_BYTES}-byte limit")
1206 );
1207 assert_eq!(
1208 format!("{too_long:?}"),
1209 "ValueTooLong { key: \"K\", len: 9999 }"
1210 );
1211
1212 let bad_key = SecretError::InvalidKey("has space".to_string());
1213 assert_eq!(bad_key.to_string(), "`has space` is not a valid secret key");
1214 assert_eq!(format!("{bad_key:?}"), "InvalidKey(\"has space\")");
1215
1216 for rendered in [too_long.to_string(), bad_key.to_string()] {
1217 assert!(
1218 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1219 "no em or en dash in copy a user reads: {rendered}"
1220 );
1221 }
1222 }
1223}