1use core::fmt;
13use std::collections::{BTreeMap, BTreeSet};
14use std::io::Write as _;
15use std::path::Path;
16#[cfg(any(unix, windows))]
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23use crate::config::AppConfig;
24use crate::config::template;
25
26pub const SECRETS_VERSION: u32 = 1;
32
33pub const MAX_KEY_BYTES: usize = 128;
35
36pub const MAX_VALUE_BYTES: usize = 4096;
41
42pub const ALL_ENVIRONMENTS: &str = "all";
47
48#[derive(Default, Serialize, Deserialize)]
53struct SecretFile {
54 version: u32,
55 entries: BTreeMap<String, BTreeMap<String, String>>,
56}
57
58impl fmt::Debug for SecretFile {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.debug_struct("SecretFile")
62 .field("version", &self.version)
63 .field("keys", &self.entries.len())
64 .finish()
65 }
66}
67
68#[non_exhaustive]
81#[derive(Debug)]
82pub enum SecretError {
83 Io(std::io::Error),
85 Decode(serde_json::Error),
90 InvalidKey(String),
93 InvalidEnvironment(String),
95 ValueTooLong {
97 key: String,
99 len: usize,
101 },
102 FutureVersion(u32),
105}
106
107impl fmt::Display for SecretError {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 Self::Io(err) => write!(f, "secret store I/O failed: {err}"),
111 Self::Decode(err) => write!(f, "secret store failed to parse: {err}"),
112 Self::InvalidKey(key) => write!(f, "`{key}` is not a valid secret key"),
113 Self::InvalidEnvironment(environment) => {
114 write!(f, "`{environment}` is not a valid environment name")
115 }
116 Self::ValueTooLong { key, len } => write!(
117 f,
118 "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
119 ),
120 Self::FutureVersion(version) => write!(
121 f,
122 "secret store is version {version}, newer than this build understands"
123 ),
124 }
125 }
126}
127
128impl core::error::Error for SecretError {
129 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
130 match self {
131 Self::Io(err) => Some(err),
132 Self::Decode(err) => Some(err),
133 Self::InvalidKey(_)
134 | Self::InvalidEnvironment(_)
135 | Self::ValueTooLong { .. }
136 | Self::FutureVersion(_) => None,
137 }
138 }
139}
140
141impl From<std::io::Error> for SecretError {
142 fn from(source: std::io::Error) -> Self {
143 Self::Io(source)
144 }
145}
146
147impl From<serde_json::Error> for SecretError {
148 fn from(source: serde_json::Error) -> Self {
149 Self::Decode(source)
150 }
151}
152
153#[must_use]
164pub fn is_name(value: &str) -> bool {
165 !value.is_empty()
166 && value.len() <= MAX_KEY_BYTES
167 && !value.starts_with('.')
168 && value
169 .bytes()
170 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
171}
172
173fn check_key(key: &str) -> Result<(), SecretError> {
179 if is_name(key) {
180 Ok(())
181 } else {
182 Err(SecretError::InvalidKey(key.to_string()))
183 }
184}
185
186fn check_environment(environment: &str) -> Result<(), SecretError> {
192 if is_name(environment) {
193 Ok(())
194 } else {
195 Err(SecretError::InvalidEnvironment(environment.to_string()))
196 }
197}
198
199#[cfg(any(unix, windows))]
207fn lock_path(path: &Path) -> PathBuf {
208 let mut name = path
209 .file_name()
210 .map(std::ffi::OsStr::to_os_string)
211 .unwrap_or_default();
212 name.push(".lock");
213 path.parent().unwrap_or_else(|| Path::new(".")).join(name)
214}
215
216struct SecretLock {
222 #[cfg(unix)]
225 _flock: nix::fcntl::Flock<std::fs::File>,
226 #[cfg(windows)]
231 _handle: std::fs::File,
232}
233
234impl SecretLock {
235 #[cfg(unix)]
242 fn acquire(path: &Path) -> std::io::Result<Self> {
243 use nix::fcntl::{Flock, FlockArg};
244 use std::os::unix::fs::OpenOptionsExt as _;
245
246 let file = std::fs::OpenOptions::new()
247 .write(true)
248 .create(true)
249 .truncate(false)
250 .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
251 .open(lock_path(path))?;
252
253 Flock::lock(file, FlockArg::LockExclusive)
254 .map(|flock| Self { _flock: flock })
255 .map_err(|(_file, errno)| std::io::Error::from(errno))
256 }
257
258 #[cfg(windows)]
270 fn acquire(path: &Path) -> std::io::Result<Self> {
271 use std::os::windows::fs::OpenOptionsExt as _;
272
273 const ERROR_SHARING_VIOLATION: i32 = 32;
278
279 const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
284
285 let lock_path = lock_path(path);
286 loop {
287 match std::fs::OpenOptions::new()
288 .write(true)
289 .create(true)
290 .truncate(false)
291 .share_mode(0)
292 .open(&lock_path)
293 {
294 Ok(handle) => return Ok(Self { _handle: handle }),
295 Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
296 std::thread::sleep(RETRY_INTERVAL);
297 }
298 Err(error) => return Err(error),
299 }
300 }
301 }
302}
303
304fn read_file(path: &Path) -> Result<SecretFile, SecretError> {
310 let raw = match std::fs::read_to_string(path) {
311 Ok(raw) => raw,
312 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(SecretFile::default()),
313 Err(err) => return Err(SecretError::Io(err)),
314 };
315 let file: SecretFile = serde_json::from_str(&raw)?;
316 if file.version > SECRETS_VERSION {
317 return Err(SecretError::FutureVersion(file.version));
318 }
319 Ok(file)
320}
321
322fn write_file(path: &Path, file: &SecretFile) -> Result<(), SecretError> {
325 let parent = path.parent().unwrap_or_else(|| Path::new("."));
326 let mut tmp = crate::atomic_file::create_staging_file(parent, "secrets", ".tmp")?;
327
328 let json = serde_json::to_string_pretty(file)?;
329 tmp.write_all(json.as_bytes())?;
330 tmp.write_all(b"\n")?;
331 tmp.as_file().sync_all()?;
332
333 tmp.persist(path)
337 .map_err(|err| SecretError::Io(err.error))?;
338
339 crate::atomic_file::sync_dir(parent)?;
342 Ok(())
343}
344
345pub fn all(path: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, SecretError> {
364 Ok(read_file(path)?.entries)
365}
366
367pub fn get(path: &Path, key: &str, environment: &str) -> Result<Option<String>, SecretError> {
380 check_key(key)?;
381 check_environment(environment)?;
382 Ok(all(path)?
383 .remove(key)
384 .and_then(|mut by_environment| by_environment.remove(environment)))
385}
386
387pub fn set(path: &Path, key: &str, environment: &str, value: &str) -> Result<(), SecretError> {
402 check_key(key)?;
403 check_environment(environment)?;
404 if value.len() > MAX_VALUE_BYTES {
405 return Err(SecretError::ValueTooLong {
406 key: key.to_string(),
407 len: value.len(),
408 });
409 }
410
411 let _lock = SecretLock::acquire(path)?;
412 let mut file = read_file(path)?;
413 file.version = SECRETS_VERSION;
414 file.entries
415 .entry(key.to_string())
416 .or_default()
417 .insert(environment.to_string(), value.to_string());
418 write_file(path, &file)
419}
420
421pub fn unset(path: &Path, key: &str, environment: &str) -> Result<bool, SecretError> {
431 check_key(key)?;
432 check_environment(environment)?;
433
434 let _lock = SecretLock::acquire(path)?;
435 let mut file = read_file(path)?;
436 let Some(by_environment) = file.entries.get_mut(key) else {
437 return Ok(false);
438 };
439 let was_present = by_environment.remove(environment).is_some();
440 if was_present {
441 if by_environment.is_empty() {
442 file.entries.remove(key);
443 }
444 file.version = SECRETS_VERSION;
445 write_file(path, &file)?;
446 }
447 Ok(was_present)
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub struct SecretRef<'a> {
456 pub namespace: Option<&'a str>,
458 pub key: &'a str,
460}
461
462impl<'a> SecretRef<'a> {
463 #[must_use]
469 pub fn parse(body: &'a str) -> Option<Self> {
470 match body.split_once('/') {
471 None if is_name(body) => Some(Self {
472 namespace: None,
473 key: body,
474 }),
475 Some((namespace, key)) if is_name(namespace) && is_name(key) => Some(Self {
476 namespace: Some(namespace),
477 key,
478 }),
479 _ => None,
480 }
481 }
482}
483
484impl fmt::Display for SecretRef<'_> {
485 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486 f.write_str("{{secret:")?;
487 if let Some(namespace) = self.namespace {
488 f.write_str(namespace)?;
489 f.write_str("/")?;
490 }
491 f.write_str(self.key)?;
492 f.write_str("}}")
493 }
494}
495
496#[must_use]
505pub fn references(config: &AppConfig) -> BTreeSet<String> {
506 let mut found = BTreeSet::new();
507 let mut scan = |value: &str| {
508 let _ = template::walk::<core::convert::Infallible>(value, |segment| {
509 if let template::Segment::Token(token) = segment
510 && let Some(reference) = template::secret_reference(token)
511 {
512 found.insert(match reference.namespace {
513 Some(namespace) => format!("{namespace}/{}", reference.key),
514 None => reference.key.to_string(),
515 });
516 }
517 Ok(())
518 });
519 };
520 for value in config.env.values() {
521 scan(value);
522 }
523 for value in &config.args {
524 scan(value);
525 }
526 if let Some(value) = &config.out_file {
527 scan(value);
528 }
529 if let Some(value) = &config.err_file {
530 scan(value);
531 }
532 found
533}
534
535#[must_use]
541pub fn namespaces_of(config: &AppConfig) -> BTreeSet<String> {
542 references(config)
543 .iter()
544 .filter_map(|reference| SecretRef::parse(reference))
545 .filter_map(|reference| reference.namespace.map(str::to_string))
546 .collect()
547}
548
549pub type NamespaceValues = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
552
553pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
561
562#[derive(Default, Clone)]
576pub struct ProviderCache {
577 pub values: NamespaceValues,
579 pub pushed: PushedPairs,
581}
582
583impl fmt::Debug for ProviderCache {
585 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586 f.debug_struct("ProviderCache")
587 .field("namespaces", &self.values.len())
588 .field("pushed", &self.pushed.len())
589 .finish()
590 }
591}
592
593#[derive(Default, Deserialize)]
598struct ProviderCacheFile {
599 version: u32,
600 #[serde(default)]
601 namespaces: NamespaceValues,
602 #[serde(default)]
603 pushed: PushedPairs,
604}
605
606impl fmt::Debug for ProviderCacheFile {
609 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
610 f.debug_struct("ProviderCacheFile")
611 .field("version", &self.version)
612 .field("namespaces", &self.namespaces.len())
613 .field("pushed", &self.pushed.len())
614 .finish()
615 }
616}
617
618pub const PROVIDER_CACHE_VERSION: u32 = 2;
625
626#[must_use]
636pub fn provider_cache_on_disk(path: &Path) -> ProviderCache {
637 let Ok(raw) = std::fs::read_to_string(path) else {
638 return ProviderCache::default();
639 };
640 match serde_json::from_str::<ProviderCacheFile>(&raw) {
641 Ok(file) if file.version == PROVIDER_CACHE_VERSION => ProviderCache {
642 values: file.namespaces,
643 pushed: file.pushed,
644 },
645 _ => ProviderCache::default(),
646 }
647}
648
649pub struct SecretView {
657 environment: String,
658 store: BTreeMap<String, BTreeMap<String, String>>,
659 providers: ProviderCache,
660}
661
662impl fmt::Debug for SecretView {
664 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
665 f.debug_struct("SecretView")
666 .field("environment", &self.environment)
667 .field("keys", &self.store.len())
668 .field("namespaces", &self.providers.values.len())
669 .finish()
670 }
671}
672
673impl SecretView {
674 #[must_use]
676 pub fn new(
677 environment: String,
678 store: BTreeMap<String, BTreeMap<String, String>>,
679 providers: ProviderCache,
680 ) -> Self {
681 Self {
682 environment,
683 store,
684 providers,
685 }
686 }
687
688 #[must_use]
692 pub fn empty(environment: String) -> Self {
693 Self::new(environment, BTreeMap::new(), ProviderCache::default())
694 }
695
696 #[must_use]
698 pub fn environment(&self) -> &str {
699 &self.environment
700 }
701
702 #[must_use]
716 pub fn resolve(&self, reference: &SecretRef<'_>) -> Resolution<'_> {
717 let table = match reference.namespace {
718 None => Some(&self.store),
719 Some(namespace) => self.providers.values.get(namespace),
720 };
721 if let Some(value) =
722 table
723 .and_then(|table| table.get(reference.key))
724 .and_then(|by_environment| {
725 by_environment
726 .get(&self.environment)
727 .or_else(|| by_environment.get(ALL_ENVIRONMENTS))
728 })
729 {
730 return Resolution::Found(value.as_str());
731 }
732 match reference.namespace {
733 None => Resolution::MissingKey,
734 Some(namespace) if self.is_pushed(namespace) => Resolution::MissingKey,
735 Some(_) => Resolution::MissingNamespace,
736 }
737 }
738
739 fn is_pushed(&self, namespace: &str) -> bool {
749 self.providers
750 .pushed
751 .get(namespace)
752 .is_some_and(|environments| {
753 environments.contains(&self.environment) || environments.contains(ALL_ENVIRONMENTS)
754 })
755 }
756}
757
758pub enum Resolution<'a> {
767 Found(&'a str),
769 MissingKey,
773 MissingNamespace,
775}
776
777impl fmt::Debug for Resolution<'_> {
779 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
780 f.write_str(match self {
781 Self::Found(_) => "Found(..)",
782 Self::MissingKey => "MissingKey",
783 Self::MissingNamespace => "MissingNamespace",
784 })
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 #[test]
793 fn a_value_round_trips_through_one_environment() {
794 let dir = tempfile::tempdir().unwrap();
795 let path = dir.path().join("secrets.json");
796 set(&path, "DB_PASSWORD", "production", "hunter2").unwrap();
797 assert_eq!(
798 get(&path, "DB_PASSWORD", "production").unwrap().as_deref(),
799 Some("hunter2")
800 );
801 assert_eq!(get(&path, "DB_PASSWORD", "staging").unwrap(), None);
802 }
803
804 #[test]
805 fn a_missing_store_reads_as_empty_rather_than_enoent() {
806 let dir = tempfile::tempdir().unwrap();
807 let path = dir.path().join("secrets.json");
808 assert!(all(&path).unwrap().is_empty());
809 assert_eq!(get(&path, "ANY", "production").unwrap(), None);
810 }
811
812 #[test]
813 fn unset_removes_one_environment_and_leaves_the_others() {
814 let dir = tempfile::tempdir().unwrap();
815 let path = dir.path().join("secrets.json");
816 set(&path, "K", "production", "p").unwrap();
817 set(&path, "K", "staging", "s").unwrap();
818 assert!(unset(&path, "K", "staging").unwrap());
819 assert_eq!(get(&path, "K", "production").unwrap().as_deref(), Some("p"));
820 assert_eq!(get(&path, "K", "staging").unwrap(), None);
821 assert!(!unset(&path, "K", "staging").unwrap(), "already gone");
822 }
823
824 #[test]
825 fn a_key_that_empties_is_removed_rather_than_left_as_an_empty_map() {
826 let dir = tempfile::tempdir().unwrap();
827 let path = dir.path().join("secrets.json");
828 set(&path, "K", "production", "p").unwrap();
829 assert!(unset(&path, "K", "production").unwrap());
830 assert!(all(&path).unwrap().is_empty(), "no empty husk left behind");
831 }
832
833 #[test]
834 fn a_bad_key_is_refused_by_name_and_writes_nothing() {
835 let dir = tempfile::tempdir().unwrap();
836 let path = dir.path().join("secrets.json");
837 for key in ["", ".hidden", "has space", "has/slash", "has:colon"] {
838 let err = set(&path, key, "production", "v").unwrap_err();
839 assert!(
840 matches!(&err, SecretError::InvalidKey(k) if k == key),
841 "{key:?}: {err:?}"
842 );
843 }
844 assert!(!path.exists(), "a refused set must not create the store");
845 }
846
847 #[test]
848 fn the_all_slot_is_writable_like_any_other_environment() {
849 let dir = tempfile::tempdir().unwrap();
852 let path = dir.path().join("secrets.json");
853 set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
854 assert_eq!(
855 get(&path, "K", "all").unwrap().as_deref(),
856 Some("everywhere")
857 );
858 }
859
860 #[test]
861 fn get_does_not_fall_back_to_the_all_slot() {
862 let dir = tempfile::tempdir().unwrap();
866 let path = dir.path().join("secrets.json");
867 set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
868 assert_eq!(get(&path, "K", "staging").unwrap(), None);
869 }
870
871 #[test]
872 fn an_environment_outside_the_grammar_is_refused() {
873 let dir = tempfile::tempdir().unwrap();
874 let path = dir.path().join("secrets.json");
875 for env in ["", "has space", "has/slash"] {
876 let err = set(&path, "K", env, "v").unwrap_err();
877 assert!(
878 matches!(&err, SecretError::InvalidEnvironment(e) if e == env),
879 "{env:?}: {err:?}"
880 );
881 }
882 }
883
884 #[test]
885 fn an_oversized_value_is_refused_by_length() {
886 let dir = tempfile::tempdir().unwrap();
887 let path = dir.path().join("secrets.json");
888 let big = "x".repeat(MAX_VALUE_BYTES + 1);
889 let err = set(&path, "K", "production", &big).unwrap_err();
890 assert!(matches!(err, SecretError::ValueTooLong { len, .. } if len == big.len()));
891 }
892
893 #[test]
894 fn a_future_version_is_refused_rather_than_overwritten() {
895 let dir = tempfile::tempdir().unwrap();
896 let path = dir.path().join("secrets.json");
897 std::fs::write(&path, r#"{"version":999,"entries":{}}"#).unwrap();
898 assert!(matches!(all(&path), Err(SecretError::FutureVersion(999))));
899 assert!(matches!(
900 set(&path, "K", "production", "v"),
901 Err(SecretError::FutureVersion(999))
902 ));
903 let raw = std::fs::read_to_string(&path).unwrap();
904 assert!(raw.contains("999"), "the refused store is untouched");
905 }
906
907 #[test]
908 #[cfg(unix)]
909 fn the_store_is_owner_only() {
910 use std::os::unix::fs::PermissionsExt as _;
911 let dir = tempfile::tempdir().unwrap();
912 let path = dir.path().join("secrets.json");
913 set(&path, "K", "production", "v").unwrap();
914 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
915 assert_eq!(mode, 0o600);
916 }
917
918 #[test]
919 fn a_reference_parses_with_and_without_a_namespace() {
920 let bare = SecretRef::parse("DB_PASSWORD").unwrap();
921 assert_eq!(bare.namespace, None);
922 assert_eq!(bare.key, "DB_PASSWORD");
923
924 let scoped = SecretRef::parse("vercel/DB_PASSWORD").unwrap();
925 assert_eq!(scoped.namespace, Some("vercel"));
926 assert_eq!(scoped.key, "DB_PASSWORD");
927
928 for bad in ["", "/KEY", "ns/", "a/b/c", "ns/bad key", "bad ns/KEY"] {
929 assert!(SecretRef::parse(bad).is_none(), "{bad:?} must not parse");
930 }
931 }
932
933 #[test]
934 fn references_finds_every_secret_in_a_config_and_nothing_else() {
935 let mut config = AppConfig::minimal("web", "./srv");
936 config.env.insert("A".into(), "{{secret:ONE}}".into());
937 config.env.insert("B".into(), "plain".into());
938 config
939 .env
940 .insert("C".into(), "{{name}}-{{secret:vercel/TWO}}".into());
941 config.args = vec!["--x={{secret:ONE}}".into()];
942
943 let found = references(&config);
944 assert_eq!(
945 found,
946 BTreeSet::from(["ONE".to_string(), "vercel/TWO".to_string()]),
947 "deduplicated, and no positional tokens"
948 );
949 }
950
951 #[test]
952 fn namespaces_of_a_config_is_the_seam_boot_ordering_will_want() {
953 let mut config = AppConfig::minimal("web", "./srv");
954 config.env.insert("A".into(), "{{secret:ONE}}".into());
955 config
956 .env
957 .insert("B".into(), "{{secret:vercel/TWO}}".into());
958 assert_eq!(
959 namespaces_of(&config),
960 BTreeSet::from(["vercel".to_string()])
961 );
962 }
963
964 #[test]
965 fn provider_cache_on_disk_reads_a_real_cache_file() {
966 let dir = tempfile::tempdir().unwrap();
967 let path = dir.path().join("secrets-cache.json");
968 std::fs::write(
969 &path,
970 r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
971 )
972 .unwrap();
973 let cache = provider_cache_on_disk(&path);
974 assert_eq!(cache.values["vercel"]["API_KEY"]["production"], "sk_live");
975 assert_eq!(
976 cache.pushed["vercel"],
977 BTreeSet::from(["production".to_string()])
978 );
979 }
980
981 #[test]
982 fn provider_cache_on_disk_is_empty_for_a_missing_or_broken_file() {
983 let dir = tempfile::tempdir().unwrap();
984 assert!(
985 provider_cache_on_disk(&dir.path().join("absent.json"))
986 .values
987 .is_empty()
988 );
989
990 let broken = dir.path().join("broken.json");
991 std::fs::write(&broken, "not json").unwrap();
992 assert!(provider_cache_on_disk(&broken).values.is_empty());
993
994 let future = dir.path().join("future.json");
995 std::fs::write(&future, r#"{"version":999,"namespaces":{}}"#).unwrap();
996 assert!(provider_cache_on_disk(&future).values.is_empty());
997 }
998
999 #[test]
1000 fn resolution_prefers_the_exact_environment_then_all_then_gives_up() {
1001 let mut store = BTreeMap::new();
1002 store.insert(
1003 "K".to_string(),
1004 BTreeMap::from([
1005 ("production".to_string(), "prod".to_string()),
1006 ("all".to_string(), "fallback".to_string()),
1007 ]),
1008 );
1009 store.insert(
1010 "ONLY_ALL".to_string(),
1011 BTreeMap::from([("all".to_string(), "everywhere".to_string())]),
1012 );
1013 store.insert(
1014 "ONLY_PROD".to_string(),
1015 BTreeMap::from([("production".to_string(), "prod".to_string())]),
1016 );
1017
1018 let view = SecretView::new("staging".to_string(), store, ProviderCache::default());
1019 assert!(matches!(
1020 view.resolve(&SecretRef {
1021 namespace: None,
1022 key: "K"
1023 }),
1024 Resolution::Found("fallback")
1025 ));
1026 assert!(matches!(
1027 view.resolve(&SecretRef {
1028 namespace: None,
1029 key: "ONLY_ALL"
1030 }),
1031 Resolution::Found("everywhere")
1032 ));
1033 assert!(matches!(
1035 view.resolve(&SecretRef {
1036 namespace: None,
1037 key: "ONLY_PROD"
1038 }),
1039 Resolution::MissingKey
1040 ));
1041 assert!(matches!(
1042 view.resolve(&SecretRef {
1043 namespace: None,
1044 key: "ABSENT"
1045 }),
1046 Resolution::MissingKey
1047 ));
1048 }
1049
1050 fn vercel_production() -> ProviderCache {
1053 ProviderCache {
1054 values: BTreeMap::from([(
1055 "vercel".to_string(),
1056 BTreeMap::from([(
1057 "PRESENT".to_string(),
1058 BTreeMap::from([("production".to_string(), "v".to_string())]),
1059 )]),
1060 )]),
1061 pushed: BTreeMap::from([(
1062 "vercel".to_string(),
1063 BTreeSet::from(["production".to_string()]),
1064 )]),
1065 }
1066 }
1067
1068 #[test]
1069 fn an_unpopulated_namespace_is_told_apart_from_a_missing_key() {
1070 let view = SecretView::new(
1071 "production".to_string(),
1072 BTreeMap::new(),
1073 vercel_production(),
1074 );
1075
1076 assert!(matches!(
1077 view.resolve(&SecretRef {
1078 namespace: Some("vercel"),
1079 key: "PRESENT"
1080 }),
1081 Resolution::Found("v")
1082 ));
1083 assert!(matches!(
1085 view.resolve(&SecretRef {
1086 namespace: Some("vercel"),
1087 key: "ABSENT"
1088 }),
1089 Resolution::MissingKey
1090 ));
1091 assert!(matches!(
1093 view.resolve(&SecretRef {
1094 namespace: Some("vault"),
1095 key: "ANY"
1096 }),
1097 Resolution::MissingNamespace
1098 ));
1099 }
1100
1101 #[test]
1107 fn a_namespace_pushed_for_another_environment_is_not_populated_for_this_one() {
1108 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_production());
1109
1110 assert!(
1111 matches!(
1112 view.resolve(&SecretRef {
1113 namespace: Some("vercel"),
1114 key: "PRESENT"
1115 }),
1116 Resolution::MissingNamespace
1117 ),
1118 "staging has had no push, so waiting is what fixes this"
1119 );
1120 }
1121
1122 #[test]
1126 fn a_pushed_pair_missing_a_key_stays_permanent() {
1127 let view = SecretView::new(
1128 "production".to_string(),
1129 BTreeMap::new(),
1130 vercel_production(),
1131 );
1132
1133 assert!(matches!(
1134 view.resolve(&SecretRef {
1135 namespace: Some("vercel"),
1136 key: "ABSENT"
1137 }),
1138 Resolution::MissingKey
1139 ));
1140 }
1141
1142 fn vercel_all() -> ProviderCache {
1145 ProviderCache {
1146 values: BTreeMap::from([(
1147 "vercel".to_string(),
1148 BTreeMap::from([(
1149 "PRESENT".to_string(),
1150 BTreeMap::from([(ALL_ENVIRONMENTS.to_string(), "v".to_string())]),
1151 )]),
1152 )]),
1153 pushed: BTreeMap::from([(
1154 "vercel".to_string(),
1155 BTreeSet::from([ALL_ENVIRONMENTS.to_string()]),
1156 )]),
1157 }
1158 }
1159
1160 #[test]
1165 fn an_all_slot_push_makes_a_genuinely_missing_key_permanent() {
1166 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1167
1168 assert!(matches!(
1169 view.resolve(&SecretRef {
1170 namespace: Some("vercel"),
1171 key: "ABSENT"
1172 }),
1173 Resolution::MissingKey
1174 ));
1175 }
1176
1177 #[test]
1181 fn an_all_slot_push_resolves_its_key_for_every_environment() {
1182 let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1183
1184 assert!(matches!(
1185 view.resolve(&SecretRef {
1186 namespace: Some("vercel"),
1187 key: "PRESENT"
1188 }),
1189 Resolution::Found("v")
1190 ));
1191 }
1192
1193 #[test]
1197 fn an_empty_push_populates_the_pair_it_carried() {
1198 let view = SecretView::new(
1199 "production".to_string(),
1200 BTreeMap::new(),
1201 ProviderCache {
1202 values: BTreeMap::from([("vercel".to_string(), BTreeMap::new())]),
1203 pushed: BTreeMap::from([(
1204 "vercel".to_string(),
1205 BTreeSet::from(["production".to_string()]),
1206 )]),
1207 },
1208 );
1209
1210 assert!(matches!(
1211 view.resolve(&SecretRef {
1212 namespace: Some("vercel"),
1213 key: "ANY"
1214 }),
1215 Resolution::MissingKey
1216 ));
1217 }
1218
1219 #[test]
1220 fn a_reference_displays_the_way_an_operator_wrote_it() {
1221 assert_eq!(
1222 SecretRef {
1223 namespace: None,
1224 key: "K"
1225 }
1226 .to_string(),
1227 "{{secret:K}}"
1228 );
1229 assert_eq!(
1230 SecretRef {
1231 namespace: Some("vercel"),
1232 key: "K"
1233 }
1234 .to_string(),
1235 "{{secret:vercel/K}}"
1236 );
1237 }
1238
1239 #[test]
1242 fn debug_never_prints_a_value() {
1243 let store = BTreeMap::from([(
1244 "K".to_string(),
1245 BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1246 )]);
1247 let view = SecretView::new("production".to_string(), store, ProviderCache::default());
1248 let rendered = format!("{view:?}");
1249 assert_eq!(
1250 rendered,
1251 "SecretView { environment: \"production\", keys: 1, namespaces: 0 }"
1252 );
1253 assert!(!rendered.contains("hunter2"));
1254 }
1255
1256 #[test]
1261 fn a_secret_file_debug_never_prints_a_value() {
1262 let file = SecretFile {
1263 version: SECRETS_VERSION,
1264 entries: BTreeMap::from([(
1265 "K".to_string(),
1266 BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1267 )]),
1268 };
1269 let rendered = format!("{file:?}");
1270 assert_eq!(rendered, "SecretFile { version: 1, keys: 1 }");
1271 assert!(!rendered.contains("hunter2"));
1272 }
1273
1274 #[test]
1278 fn a_provider_cache_file_debug_never_prints_a_value() {
1279 let file = ProviderCacheFile {
1280 version: PROVIDER_CACHE_VERSION,
1281 namespaces: BTreeMap::from([(
1282 "vercel".to_string(),
1283 BTreeMap::from([(
1284 "API_KEY".to_string(),
1285 BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
1286 )]),
1287 )]),
1288 pushed: BTreeMap::from([(
1289 "vercel".to_string(),
1290 BTreeSet::from(["production".to_string()]),
1291 )]),
1292 };
1293 let rendered = format!("{file:?}");
1294 assert_eq!(
1295 rendered,
1296 "ProviderCacheFile { version: 2, namespaces: 1, pushed: 1 }"
1297 );
1298 assert!(!rendered.contains("sk_live"));
1299 }
1300
1301 #[test]
1303 fn a_provider_cache_debug_never_prints_a_value() {
1304 let cache = vercel_production();
1305 assert_eq!(
1306 format!("{cache:?}"),
1307 "ProviderCache { namespaces: 1, pushed: 1 }"
1308 );
1309 }
1310
1311 #[test]
1313 fn a_resolution_debug_never_prints_the_value_it_found() {
1314 assert_eq!(format!("{:?}", Resolution::Found("hunter2")), "Found(..)");
1315 assert_eq!(format!("{:?}", Resolution::MissingKey), "MissingKey");
1316 assert_eq!(
1317 format!("{:?}", Resolution::MissingNamespace),
1318 "MissingNamespace"
1319 );
1320 }
1321
1322 #[test]
1326 fn error_messages_name_the_key_and_never_a_value() {
1327 let too_long = SecretError::ValueTooLong {
1328 key: "K".to_string(),
1329 len: 9999,
1330 };
1331 assert_eq!(
1332 too_long.to_string(),
1333 format!("value for `K` is 9999 bytes, over the {MAX_VALUE_BYTES}-byte limit")
1334 );
1335 assert_eq!(
1336 format!("{too_long:?}"),
1337 "ValueTooLong { key: \"K\", len: 9999 }"
1338 );
1339
1340 let bad_key = SecretError::InvalidKey("has space".to_string());
1341 assert_eq!(bad_key.to_string(), "`has space` is not a valid secret key");
1342 assert_eq!(format!("{bad_key:?}"), "InvalidKey(\"has space\")");
1343
1344 for rendered in [too_long.to_string(), bad_key.to_string()] {
1345 assert!(
1346 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1347 "no em or en dash in copy a user reads: {rendered}"
1348 );
1349 }
1350 }
1351}