1use core::fmt;
12use std::collections::{BTreeMap, BTreeSet};
13use std::io::Write as _;
14use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use crate::config::AppConfig;
19use crate::config::template;
20use crate::file_lock::FileLock;
21
22pub const SECRETS_VERSION: u32 = 1;
28
29pub const MAX_KEY_BYTES: usize = 128;
31
32pub const MAX_VALUE_BYTES: usize = 4096;
37
38pub const ALL_ENVIRONMENTS: &str = "all";
43
44#[derive(Default, Serialize, Deserialize)]
49struct SecretFile {
50 version: u32,
51 entries: BTreeMap<String, BTreeMap<String, String>>,
52}
53
54impl fmt::Debug for SecretFile {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.debug_struct("SecretFile")
58 .field("version", &self.version)
59 .field("keys", &self.entries.len())
60 .finish()
61 }
62}
63
64#[non_exhaustive]
77#[derive(Debug)]
78pub enum SecretError {
79 Io(std::io::Error),
81 Decode(serde_json::Error),
86 InvalidKey(String),
89 InvalidEnvironment(String),
91 ValueTooLong {
93 key: String,
95 len: usize,
97 },
98 FutureVersion(u32),
101}
102
103impl fmt::Display for SecretError {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::Io(err) => write!(f, "secret store I/O failed: {err}"),
107 Self::Decode(err) => write!(f, "secret store failed to parse: {err}"),
108 Self::InvalidKey(key) => write!(f, "`{key}` is not a valid secret key"),
109 Self::InvalidEnvironment(environment) => {
110 write!(f, "`{environment}` is not a valid environment name")
111 }
112 Self::ValueTooLong { key, len } => write!(
113 f,
114 "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
115 ),
116 Self::FutureVersion(version) => write!(
117 f,
118 "secret store is version {version}, newer than this build understands"
119 ),
120 }
121 }
122}
123
124impl core::error::Error for SecretError {
125 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
126 match self {
127 Self::Io(err) => Some(err),
128 Self::Decode(err) => Some(err),
129 Self::InvalidKey(_)
130 | Self::InvalidEnvironment(_)
131 | Self::ValueTooLong { .. }
132 | Self::FutureVersion(_) => None,
133 }
134 }
135}
136
137impl From<std::io::Error> for SecretError {
138 fn from(source: std::io::Error) -> Self {
139 Self::Io(source)
140 }
141}
142
143impl From<serde_json::Error> for SecretError {
144 fn from(source: serde_json::Error) -> Self {
145 Self::Decode(source)
146 }
147}
148
149#[must_use]
160pub fn is_name(value: &str) -> bool {
161 !value.is_empty()
162 && value.len() <= MAX_KEY_BYTES
163 && !value.starts_with('.')
164 && value
165 .bytes()
166 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
167}
168
169fn check_key(key: &str) -> Result<(), SecretError> {
175 if is_name(key) {
176 Ok(())
177 } else {
178 Err(SecretError::InvalidKey(key.to_string()))
179 }
180}
181
182fn check_environment(environment: &str) -> Result<(), SecretError> {
188 if is_name(environment) {
189 Ok(())
190 } else {
191 Err(SecretError::InvalidEnvironment(environment.to_string()))
192 }
193}
194
195fn read_file(path: &Path) -> Result<SecretFile, SecretError> {
201 let raw = match std::fs::read_to_string(path) {
202 Ok(raw) => raw,
203 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(SecretFile::default()),
204 Err(err) => return Err(SecretError::Io(err)),
205 };
206 let file: SecretFile = serde_json::from_str(&raw)?;
207 if file.version > SECRETS_VERSION {
208 return Err(SecretError::FutureVersion(file.version));
209 }
210 Ok(file)
211}
212
213fn write_file(path: &Path, file: &SecretFile) -> Result<(), SecretError> {
216 let parent = path.parent().unwrap_or_else(|| Path::new("."));
217 let mut tmp = crate::atomic_file::create_staging_file(parent, "secrets", ".tmp")?;
218
219 let json = serde_json::to_string_pretty(file)?;
220 tmp.write_all(json.as_bytes())?;
221 tmp.write_all(b"\n")?;
222 tmp.as_file().sync_all()?;
223
224 tmp.persist(path)
228 .map_err(|err| SecretError::Io(err.error))?;
229
230 crate::atomic_file::sync_dir(parent)?;
233 Ok(())
234}
235
236pub fn all(path: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, SecretError> {
255 Ok(read_file(path)?.entries)
256}
257
258pub fn get(path: &Path, key: &str, environment: &str) -> Result<Option<String>, SecretError> {
271 check_key(key)?;
272 check_environment(environment)?;
273 Ok(all(path)?
274 .remove(key)
275 .and_then(|mut by_environment| by_environment.remove(environment)))
276}
277
278pub fn set(path: &Path, key: &str, environment: &str, value: &str) -> Result<(), SecretError> {
293 check_key(key)?;
294 check_environment(environment)?;
295 if value.len() > MAX_VALUE_BYTES {
296 return Err(SecretError::ValueTooLong {
297 key: key.to_string(),
298 len: value.len(),
299 });
300 }
301
302 let _lock = FileLock::acquire(path)?;
303 let mut file = read_file(path)?;
304 file.version = SECRETS_VERSION;
305 file.entries
306 .entry(key.to_string())
307 .or_default()
308 .insert(environment.to_string(), value.to_string());
309 write_file(path, &file)
310}
311
312pub fn unset(path: &Path, key: &str, environment: &str) -> Result<bool, SecretError> {
322 check_key(key)?;
323 check_environment(environment)?;
324
325 let _lock = FileLock::acquire(path)?;
326 let mut file = read_file(path)?;
327 let Some(by_environment) = file.entries.get_mut(key) else {
328 return Ok(false);
329 };
330 let was_present = by_environment.remove(environment).is_some();
331 if was_present {
332 if by_environment.is_empty() {
333 file.entries.remove(key);
334 }
335 file.version = SECRETS_VERSION;
336 write_file(path, &file)?;
337 }
338 Ok(was_present)
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub struct SecretRef<'a> {
347 pub namespace: Option<&'a str>,
349 pub key: &'a str,
351}
352
353impl<'a> SecretRef<'a> {
354 #[must_use]
360 pub fn parse(body: &'a str) -> Option<Self> {
361 match body.split_once('/') {
362 None if is_name(body) => Some(Self {
363 namespace: None,
364 key: body,
365 }),
366 Some((namespace, key)) if is_name(namespace) && is_name(key) => Some(Self {
367 namespace: Some(namespace),
368 key,
369 }),
370 _ => None,
371 }
372 }
373}
374
375impl fmt::Display for SecretRef<'_> {
376 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377 f.write_str("{{secret:")?;
378 if let Some(namespace) = self.namespace {
379 f.write_str(namespace)?;
380 f.write_str("/")?;
381 }
382 f.write_str(self.key)?;
383 f.write_str("}}")
384 }
385}
386
387#[must_use]
396pub fn references(config: &AppConfig) -> BTreeSet<String> {
397 let mut found = BTreeSet::new();
398 let mut scan = |value: &str| {
399 let _ = template::walk::<core::convert::Infallible>(value, |segment| {
400 if let template::Segment::Token(token) = segment
401 && let Some(reference) = template::secret_reference(token)
402 {
403 found.insert(match reference.namespace {
404 Some(namespace) => format!("{namespace}/{}", reference.key),
405 None => reference.key.to_string(),
406 });
407 }
408 Ok(())
409 });
410 };
411 for value in config.env.values() {
412 scan(value);
413 }
414 for value in &config.args {
415 scan(value);
416 }
417 if let Some(value) = &config.out_file {
418 scan(value);
419 }
420 if let Some(value) = &config.err_file {
421 scan(value);
422 }
423 found
424}
425
426#[must_use]
432pub fn namespaces_of(config: &AppConfig) -> BTreeSet<String> {
433 references(config)
434 .iter()
435 .filter_map(|reference| SecretRef::parse(reference))
436 .filter_map(|reference| reference.namespace.map(str::to_string))
437 .collect()
438}
439
440pub type NamespaceValues = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
443
444pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
452
453#[derive(Default, Clone)]
467pub struct ProviderCache {
468 pub values: NamespaceValues,
470 pub pushed: PushedPairs,
472}
473
474impl fmt::Debug for ProviderCache {
476 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477 f.debug_struct("ProviderCache")
478 .field("namespaces", &self.values.len())
479 .field("pushed", &self.pushed.len())
480 .finish()
481 }
482}
483
484#[derive(Default, Deserialize)]
489struct ProviderCacheFile {
490 version: u32,
491 #[serde(default)]
492 namespaces: NamespaceValues,
493 #[serde(default)]
494 pushed: PushedPairs,
495}
496
497impl fmt::Debug for ProviderCacheFile {
500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 f.debug_struct("ProviderCacheFile")
502 .field("version", &self.version)
503 .field("namespaces", &self.namespaces.len())
504 .field("pushed", &self.pushed.len())
505 .finish()
506 }
507}
508
509pub const PROVIDER_CACHE_VERSION: u32 = 2;
516
517#[must_use]
527pub fn provider_cache_on_disk(path: &Path) -> ProviderCache {
528 let Ok(raw) = std::fs::read_to_string(path) else {
529 return ProviderCache::default();
530 };
531 match serde_json::from_str::<ProviderCacheFile>(&raw) {
532 Ok(file) if file.version == PROVIDER_CACHE_VERSION => ProviderCache {
533 values: file.namespaces,
534 pushed: file.pushed,
535 },
536 _ => ProviderCache::default(),
537 }
538}
539
540pub struct SecretView {
548 environment: String,
549 store: BTreeMap<String, BTreeMap<String, String>>,
550 providers: ProviderCache,
551}
552
553impl fmt::Debug for SecretView {
555 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556 f.debug_struct("SecretView")
557 .field("environment", &self.environment)
558 .field("keys", &self.store.len())
559 .field("namespaces", &self.providers.values.len())
560 .finish()
561 }
562}
563
564impl SecretView {
565 #[must_use]
567 pub fn new(
568 environment: String,
569 store: BTreeMap<String, BTreeMap<String, String>>,
570 providers: ProviderCache,
571 ) -> Self {
572 Self {
573 environment,
574 store,
575 providers,
576 }
577 }
578
579 #[must_use]
583 pub fn empty(environment: String) -> Self {
584 Self::new(environment, BTreeMap::new(), ProviderCache::default())
585 }
586
587 #[must_use]
589 pub fn environment(&self) -> &str {
590 &self.environment
591 }
592
593 #[must_use]
607 pub fn resolve(&self, reference: &SecretRef<'_>) -> Resolution<'_> {
608 let table = match reference.namespace {
609 None => Some(&self.store),
610 Some(namespace) => self.providers.values.get(namespace),
611 };
612 if let Some(value) =
613 table
614 .and_then(|table| table.get(reference.key))
615 .and_then(|by_environment| {
616 by_environment
617 .get(&self.environment)
618 .or_else(|| by_environment.get(ALL_ENVIRONMENTS))
619 })
620 {
621 return Resolution::Found(value.as_str());
622 }
623 match reference.namespace {
624 None => Resolution::MissingKey,
625 Some(namespace) if self.is_pushed(namespace) => Resolution::MissingKey,
626 Some(_) => Resolution::MissingNamespace,
627 }
628 }
629
630 fn is_pushed(&self, namespace: &str) -> bool {
640 self.providers
641 .pushed
642 .get(namespace)
643 .is_some_and(|environments| {
644 environments.contains(&self.environment) || environments.contains(ALL_ENVIRONMENTS)
645 })
646 }
647}
648
649pub enum Resolution<'a> {
658 Found(&'a str),
660 MissingKey,
664 MissingNamespace,
666}
667
668impl fmt::Debug for Resolution<'_> {
670 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
671 f.write_str(match self {
672 Self::Found(_) => "Found(..)",
673 Self::MissingKey => "MissingKey",
674 Self::MissingNamespace => "MissingNamespace",
675 })
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn a_value_round_trips_through_one_environment() {
685 let dir = tempfile::tempdir().unwrap();
686 let path = dir.path().join("secrets.json");
687 set(&path, "DB_PASSWORD", "production", "hunter2").unwrap();
688 assert_eq!(
689 get(&path, "DB_PASSWORD", "production").unwrap().as_deref(),
690 Some("hunter2")
691 );
692 assert_eq!(get(&path, "DB_PASSWORD", "staging").unwrap(), None);
693 }
694
695 #[test]
696 fn a_missing_store_reads_as_empty_rather_than_enoent() {
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("secrets.json");
699 assert!(all(&path).unwrap().is_empty());
700 assert_eq!(get(&path, "ANY", "production").unwrap(), None);
701 }
702
703 #[test]
704 fn unset_removes_one_environment_and_leaves_the_others() {
705 let dir = tempfile::tempdir().unwrap();
706 let path = dir.path().join("secrets.json");
707 set(&path, "K", "production", "p").unwrap();
708 set(&path, "K", "staging", "s").unwrap();
709 assert!(unset(&path, "K", "staging").unwrap());
710 assert_eq!(get(&path, "K", "production").unwrap().as_deref(), Some("p"));
711 assert_eq!(get(&path, "K", "staging").unwrap(), None);
712 assert!(!unset(&path, "K", "staging").unwrap(), "already gone");
713 }
714
715 #[test]
716 fn a_key_that_empties_is_removed_rather_than_left_as_an_empty_map() {
717 let dir = tempfile::tempdir().unwrap();
718 let path = dir.path().join("secrets.json");
719 set(&path, "K", "production", "p").unwrap();
720 assert!(unset(&path, "K", "production").unwrap());
721 assert!(all(&path).unwrap().is_empty(), "no empty husk left behind");
722 }
723
724 #[test]
725 fn a_bad_key_is_refused_by_name_and_writes_nothing() {
726 let dir = tempfile::tempdir().unwrap();
727 let path = dir.path().join("secrets.json");
728 for key in ["", ".hidden", "has space", "has/slash", "has:colon"] {
729 let err = set(&path, key, "production", "v").unwrap_err();
730 assert!(
731 matches!(&err, SecretError::InvalidKey(k) if k == key),
732 "{key:?}: {err:?}"
733 );
734 }
735 assert!(!path.exists(), "a refused set must not create the store");
736 }
737
738 #[test]
739 fn the_all_slot_is_writable_like_any_other_environment() {
740 let dir = tempfile::tempdir().unwrap();
743 let path = dir.path().join("secrets.json");
744 set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
745 assert_eq!(
746 get(&path, "K", "all").unwrap().as_deref(),
747 Some("everywhere")
748 );
749 }
750
751 #[test]
752 fn get_does_not_fall_back_to_the_all_slot() {
753 let dir = tempfile::tempdir().unwrap();
757 let path = dir.path().join("secrets.json");
758 set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
759 assert_eq!(get(&path, "K", "staging").unwrap(), None);
760 }
761
762 #[test]
763 fn an_environment_outside_the_grammar_is_refused() {
764 let dir = tempfile::tempdir().unwrap();
765 let path = dir.path().join("secrets.json");
766 for env in ["", "has space", "has/slash"] {
767 let err = set(&path, "K", env, "v").unwrap_err();
768 assert!(
769 matches!(&err, SecretError::InvalidEnvironment(e) if e == env),
770 "{env:?}: {err:?}"
771 );
772 }
773 }
774
775 #[test]
776 fn an_oversized_value_is_refused_by_length() {
777 let dir = tempfile::tempdir().unwrap();
778 let path = dir.path().join("secrets.json");
779 let big = "x".repeat(MAX_VALUE_BYTES + 1);
780 let err = set(&path, "K", "production", &big).unwrap_err();
781 assert!(matches!(err, SecretError::ValueTooLong { len, .. } if len == big.len()));
782 }
783
784 #[test]
785 fn a_future_version_is_refused_rather_than_overwritten() {
786 let dir = tempfile::tempdir().unwrap();
787 let path = dir.path().join("secrets.json");
788 std::fs::write(&path, r#"{"version":999,"entries":{}}"#).unwrap();
789 assert!(matches!(all(&path), Err(SecretError::FutureVersion(999))));
790 assert!(matches!(
791 set(&path, "K", "production", "v"),
792 Err(SecretError::FutureVersion(999))
793 ));
794 let raw = std::fs::read_to_string(&path).unwrap();
795 assert!(raw.contains("999"), "the refused store is untouched");
796 }
797
798 #[test]
799 #[cfg(unix)]
800 fn the_store_is_owner_only() {
801 use std::os::unix::fs::PermissionsExt as _;
802 let dir = tempfile::tempdir().unwrap();
803 let path = dir.path().join("secrets.json");
804 set(&path, "K", "production", "v").unwrap();
805 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
806 assert_eq!(mode, 0o600);
807 }
808
809 #[test]
810 fn a_reference_parses_with_and_without_a_namespace() {
811 let bare = SecretRef::parse("DB_PASSWORD").unwrap();
812 assert_eq!(bare.namespace, None);
813 assert_eq!(bare.key, "DB_PASSWORD");
814
815 let scoped = SecretRef::parse("vercel/DB_PASSWORD").unwrap();
816 assert_eq!(scoped.namespace, Some("vercel"));
817 assert_eq!(scoped.key, "DB_PASSWORD");
818
819 for bad in ["", "/KEY", "ns/", "a/b/c", "ns/bad key", "bad ns/KEY"] {
820 assert!(SecretRef::parse(bad).is_none(), "{bad:?} must not parse");
821 }
822 }
823
824 #[test]
825 fn references_finds_every_secret_in_a_config_and_nothing_else() {
826 let mut config = AppConfig::minimal("web", "./srv");
827 config.env.insert("A".into(), "{{secret:ONE}}".into());
828 config.env.insert("B".into(), "plain".into());
829 config
830 .env
831 .insert("C".into(), "{{name}}-{{secret:vercel/TWO}}".into());
832 config.args = vec!["--x={{secret:ONE}}".into()];
833
834 let found = references(&config);
835 assert_eq!(
836 found,
837 BTreeSet::from(["ONE".to_string(), "vercel/TWO".to_string()]),
838 "deduplicated, and no positional tokens"
839 );
840 }
841
842 #[test]
843 fn namespaces_of_a_config_is_the_seam_boot_ordering_will_want() {
844 let mut config = AppConfig::minimal("web", "./srv");
845 config.env.insert("A".into(), "{{secret:ONE}}".into());
846 config
847 .env
848 .insert("B".into(), "{{secret:vercel/TWO}}".into());
849 assert_eq!(
850 namespaces_of(&config),
851 BTreeSet::from(["vercel".to_string()])
852 );
853 }
854
855 #[test]
856 fn provider_cache_on_disk_reads_a_real_cache_file() {
857 let dir = tempfile::tempdir().unwrap();
858 let path = dir.path().join("secrets-cache.json");
859 std::fs::write(
860 &path,
861 r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
862 )
863 .unwrap();
864 let cache = provider_cache_on_disk(&path);
865 assert_eq!(cache.values["vercel"]["API_KEY"]["production"], "sk_live");
866 assert_eq!(
867 cache.pushed["vercel"],
868 BTreeSet::from(["production".to_string()])
869 );
870 }
871
872 #[test]
873 fn provider_cache_on_disk_is_empty_for_a_missing_or_broken_file() {
874 let dir = tempfile::tempdir().unwrap();
875 assert!(
876 provider_cache_on_disk(&dir.path().join("absent.json"))
877 .values
878 .is_empty()
879 );
880
881 let broken = dir.path().join("broken.json");
882 std::fs::write(&broken, "not json").unwrap();
883 assert!(provider_cache_on_disk(&broken).values.is_empty());
884
885 let future = dir.path().join("future.json");
886 std::fs::write(&future, r#"{"version":999,"namespaces":{}}"#).unwrap();
887 assert!(provider_cache_on_disk(&future).values.is_empty());
888 }
889
890 #[test]
891 fn resolution_prefers_the_exact_environment_then_all_then_gives_up() {
892 let mut store = BTreeMap::new();
893 store.insert(
894 "K".to_string(),
895 BTreeMap::from([
896 ("production".to_string(), "prod".to_string()),
897 ("all".to_string(), "fallback".to_string()),
898 ]),
899 );
900 store.insert(
901 "ONLY_ALL".to_string(),
902 BTreeMap::from([("all".to_string(), "everywhere".to_string())]),
903 );
904 store.insert(
905 "ONLY_PROD".to_string(),
906 BTreeMap::from([("production".to_string(), "prod".to_string())]),
907 );
908
909 let view = SecretView::new("staging".to_string(), store, ProviderCache::default());
910 assert!(matches!(
911 view.resolve(&SecretRef {
912 namespace: None,
913 key: "K"
914 }),
915 Resolution::Found("fallback")
916 ));
917 assert!(matches!(
918 view.resolve(&SecretRef {
919 namespace: None,
920 key: "ONLY_ALL"
921 }),
922 Resolution::Found("everywhere")
923 ));
924 assert!(matches!(
926 view.resolve(&SecretRef {
927 namespace: None,
928 key: "ONLY_PROD"
929 }),
930 Resolution::MissingKey
931 ));
932 assert!(matches!(
933 view.resolve(&SecretRef {
934 namespace: None,
935 key: "ABSENT"
936 }),
937 Resolution::MissingKey
938 ));
939 }
940
941 fn vercel_production() -> ProviderCache {
944 ProviderCache {
945 values: BTreeMap::from([(
946 "vercel".to_string(),
947 BTreeMap::from([(
948 "PRESENT".to_string(),
949 BTreeMap::from([("production".to_string(), "v".to_string())]),
950 )]),
951 )]),
952 pushed: BTreeMap::from([(
953 "vercel".to_string(),
954 BTreeSet::from(["production".to_string()]),
955 )]),
956 }
957 }
958
959 #[test]
960 fn an_unpopulated_namespace_is_told_apart_from_a_missing_key() {
961 let view = SecretView::new(
962 "production".to_string(),
963 BTreeMap::new(),
964 vercel_production(),
965 );
966
967 assert!(matches!(
968 view.resolve(&SecretRef {
969 namespace: Some("vercel"),
970 key: "PRESENT"
971 }),
972 Resolution::Found("v")
973 ));
974 assert!(matches!(
976 view.resolve(&SecretRef {
977 namespace: Some("vercel"),
978 key: "ABSENT"
979 }),
980 Resolution::MissingKey
981 ));
982 assert!(matches!(
984 view.resolve(&SecretRef {
985 namespace: Some("vault"),
986 key: "ANY"
987 }),
988 Resolution::MissingNamespace
989 ));
990 }
991
992 #[test]
998 fn a_namespace_pushed_for_another_environment_is_not_populated_for_this_one() {
999 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_production());
1000
1001 assert!(
1002 matches!(
1003 view.resolve(&SecretRef {
1004 namespace: Some("vercel"),
1005 key: "PRESENT"
1006 }),
1007 Resolution::MissingNamespace
1008 ),
1009 "staging has had no push, so waiting is what fixes this"
1010 );
1011 }
1012
1013 #[test]
1017 fn a_pushed_pair_missing_a_key_stays_permanent() {
1018 let view = SecretView::new(
1019 "production".to_string(),
1020 BTreeMap::new(),
1021 vercel_production(),
1022 );
1023
1024 assert!(matches!(
1025 view.resolve(&SecretRef {
1026 namespace: Some("vercel"),
1027 key: "ABSENT"
1028 }),
1029 Resolution::MissingKey
1030 ));
1031 }
1032
1033 fn vercel_all() -> ProviderCache {
1036 ProviderCache {
1037 values: BTreeMap::from([(
1038 "vercel".to_string(),
1039 BTreeMap::from([(
1040 "PRESENT".to_string(),
1041 BTreeMap::from([(ALL_ENVIRONMENTS.to_string(), "v".to_string())]),
1042 )]),
1043 )]),
1044 pushed: BTreeMap::from([(
1045 "vercel".to_string(),
1046 BTreeSet::from([ALL_ENVIRONMENTS.to_string()]),
1047 )]),
1048 }
1049 }
1050
1051 #[test]
1056 fn an_all_slot_push_makes_a_genuinely_missing_key_permanent() {
1057 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1058
1059 assert!(matches!(
1060 view.resolve(&SecretRef {
1061 namespace: Some("vercel"),
1062 key: "ABSENT"
1063 }),
1064 Resolution::MissingKey
1065 ));
1066 }
1067
1068 #[test]
1072 fn an_all_slot_push_resolves_its_key_for_every_environment() {
1073 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1074
1075 assert!(matches!(
1076 view.resolve(&SecretRef {
1077 namespace: Some("vercel"),
1078 key: "PRESENT"
1079 }),
1080 Resolution::Found("v")
1081 ));
1082 }
1083
1084 #[test]
1088 fn an_empty_push_populates_the_pair_it_carried() {
1089 let view = SecretView::new(
1090 "production".to_string(),
1091 BTreeMap::new(),
1092 ProviderCache {
1093 values: BTreeMap::from([("vercel".to_string(), BTreeMap::new())]),
1094 pushed: BTreeMap::from([(
1095 "vercel".to_string(),
1096 BTreeSet::from(["production".to_string()]),
1097 )]),
1098 },
1099 );
1100
1101 assert!(matches!(
1102 view.resolve(&SecretRef {
1103 namespace: Some("vercel"),
1104 key: "ANY"
1105 }),
1106 Resolution::MissingKey
1107 ));
1108 }
1109
1110 #[test]
1111 fn a_reference_displays_the_way_an_operator_wrote_it() {
1112 assert_eq!(
1113 SecretRef {
1114 namespace: None,
1115 key: "K"
1116 }
1117 .to_string(),
1118 "{{secret:K}}"
1119 );
1120 assert_eq!(
1121 SecretRef {
1122 namespace: Some("vercel"),
1123 key: "K"
1124 }
1125 .to_string(),
1126 "{{secret:vercel/K}}"
1127 );
1128 }
1129
1130 #[test]
1133 fn debug_never_prints_a_value() {
1134 let store = BTreeMap::from([(
1135 "K".to_string(),
1136 BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1137 )]);
1138 let view = SecretView::new("production".to_string(), store, ProviderCache::default());
1139 let rendered = format!("{view:?}");
1140 assert_eq!(
1141 rendered,
1142 "SecretView { environment: \"production\", keys: 1, namespaces: 0 }"
1143 );
1144 assert!(!rendered.contains("hunter2"));
1145 }
1146
1147 #[test]
1152 fn a_secret_file_debug_never_prints_a_value() {
1153 let file = SecretFile {
1154 version: SECRETS_VERSION,
1155 entries: BTreeMap::from([(
1156 "K".to_string(),
1157 BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1158 )]),
1159 };
1160 let rendered = format!("{file:?}");
1161 assert_eq!(rendered, "SecretFile { version: 1, keys: 1 }");
1162 assert!(!rendered.contains("hunter2"));
1163 }
1164
1165 #[test]
1169 fn a_provider_cache_file_debug_never_prints_a_value() {
1170 let file = ProviderCacheFile {
1171 version: PROVIDER_CACHE_VERSION,
1172 namespaces: BTreeMap::from([(
1173 "vercel".to_string(),
1174 BTreeMap::from([(
1175 "API_KEY".to_string(),
1176 BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
1177 )]),
1178 )]),
1179 pushed: BTreeMap::from([(
1180 "vercel".to_string(),
1181 BTreeSet::from(["production".to_string()]),
1182 )]),
1183 };
1184 let rendered = format!("{file:?}");
1185 assert_eq!(
1186 rendered,
1187 "ProviderCacheFile { version: 2, namespaces: 1, pushed: 1 }"
1188 );
1189 assert!(!rendered.contains("sk_live"));
1190 }
1191
1192 #[test]
1194 fn a_provider_cache_debug_never_prints_a_value() {
1195 let cache = vercel_production();
1196 assert_eq!(
1197 format!("{cache:?}"),
1198 "ProviderCache { namespaces: 1, pushed: 1 }"
1199 );
1200 }
1201
1202 #[test]
1204 fn a_resolution_debug_never_prints_the_value_it_found() {
1205 assert_eq!(format!("{:?}", Resolution::Found("hunter2")), "Found(..)");
1206 assert_eq!(format!("{:?}", Resolution::MissingKey), "MissingKey");
1207 assert_eq!(
1208 format!("{:?}", Resolution::MissingNamespace),
1209 "MissingNamespace"
1210 );
1211 }
1212
1213 #[test]
1217 fn error_messages_name_the_key_and_never_a_value() {
1218 let too_long = SecretError::ValueTooLong {
1219 key: "K".to_string(),
1220 len: 9999,
1221 };
1222 assert_eq!(
1223 too_long.to_string(),
1224 format!("value for `K` is 9999 bytes, over the {MAX_VALUE_BYTES}-byte limit")
1225 );
1226 assert_eq!(
1227 format!("{too_long:?}"),
1228 "ValueTooLong { key: \"K\", len: 9999 }"
1229 );
1230
1231 let bad_key = SecretError::InvalidKey("has space".to_string());
1232 assert_eq!(bad_key.to_string(), "`has space` is not a valid secret key");
1233 assert_eq!(format!("{bad_key:?}"), "InvalidKey(\"has space\")");
1234
1235 for rendered in [too_long.to_string(), bad_key.to_string()] {
1236 assert!(
1237 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1238 "no em or en dash in copy a user reads: {rendered}"
1239 );
1240 }
1241 }
1242}