Skip to main content

shep_core/
secrets.rs

1//! `secrets.json`: the values a config refers to and never carries.
2//!
3//! A key holds one value per environment, so `production` and `staging`
4//! differ without two config files. A `{{secret:NAME}}` reference resolves
5//! through [`SecretView`], which reads the sheep's own environment and then
6//! [`ALL_ENVIRONMENTS`], never another named environment.
7//!
8//! Same on-disk shape as [`crate::kv`]: a read-modify-rename under an
9//! exclusive lock on a sibling `secrets.json.lock`, copied rather than
10//! shared since `KvLock` is private to its module.
11
12use core::fmt;
13use std::collections::{BTreeMap, BTreeSet};
14use std::io::Write as _;
15use std::path::Path;
16// `PathBuf` backs `lock_path` below, gated the same way for both platform
17// arms of `SecretLock`.
18#[cfg(any(unix, windows))]
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23use crate::config::AppConfig;
24use crate::config::template;
25
26/// The on-disk format's version.
27///
28/// A store carrying a higher version is refused rather than read or
29/// replaced ([`SecretError::FutureVersion`]): there is no undo for a
30/// downgrade that overwrites an operator's credentials.
31pub const SECRETS_VERSION: u32 = 1;
32
33/// Longest key, namespace or environment name this store accepts, in bytes.
34pub const MAX_KEY_BYTES: usize = 128;
35
36/// Longest value this store accepts, in bytes.
37///
38/// The store is read whole on every access; a cap keeps it from becoming an
39/// unbounded blob store. A 4096-bit RSA private key in PEM is 3272 bytes.
40pub const MAX_VALUE_BYTES: usize = 4096;
41
42/// The environment name that covers every environment.
43///
44/// A value here is used when the sheep's own environment has no slot of its
45/// own. Cannot be a sheep's `environment`, which `AppConfig` refuses.
46pub const ALL_ENVIRONMENTS: &str = "all";
47
48/// The file's shape: a version and a key to environment to value map.
49///
50/// `BTreeMap` throughout so two writes of the same content produce
51/// byte-identical files.
52#[derive(Default, Serialize, Deserialize)]
53struct SecretFile {
54    version: u32,
55    entries: BTreeMap<String, BTreeMap<String, String>>,
56}
57
58/// Redacted (IR-41): `entries` is the whole point of this type.
59impl 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/// Error type returned by this module.
69///
70/// `#[non_exhaustive]`: shep-core is published, so a new failure variant
71/// must not break an out-of-tree `match`.
72///
73/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
74/// them, matching [`crate::kv::KvError`], so callers keep the underlying
75/// diagnostic through [`core::error::Error::source`]; this type does not
76/// derive `Clone`/`PartialEq`/`Eq` as a result.
77///
78/// No variant carries a secret's value: a message names the key, the
79/// namespace or the environment, and nothing else.
80#[non_exhaustive]
81#[derive(Debug)]
82pub enum SecretError {
83    /// The store could not be read, written, or replaced.
84    Io(std::io::Error),
85    /// The store's JSON could not be parsed.
86    ///
87    /// Refused rather than repaired: a partial read would silently drop
88    /// credentials still on disk, and a later write would erase them.
89    Decode(serde_json::Error),
90    /// A key outside the grammar; carries it verbatim so the message can
91    /// quote what was typed.
92    InvalidKey(String),
93    /// An environment name outside the grammar; carries it verbatim.
94    InvalidEnvironment(String),
95    /// A value over [`MAX_VALUE_BYTES`].
96    ValueTooLong {
97        /// The key it was being stored under.
98        key: String,
99        /// Its length in bytes.
100        len: usize,
101    },
102    /// The store on disk is a version this build does not understand; carries
103    /// that version. Nothing was written.
104    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/// The grammar shared by keys, namespaces and environment names.
154///
155/// Non-empty, at most [`MAX_KEY_BYTES`], not starting with `.`, and drawn
156/// from `[A-Za-z0-9._-]`. Excludes `/`, so a name can never contain the
157/// separator [`SecretRef::parse`] splits a namespace from a key on.
158///
159/// Public because the daemon checks a peer's namespace and environment
160/// against it before storing anything under either: a name outside this
161/// grammar is one no `{{secret:...}}` reference could ever name, so
162/// accepting it would be a silent no-op.
163#[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
173/// Checks one key against the grammar.
174///
175/// # Errors
176/// [`SecretError::InvalidKey`]: empty, over [`MAX_KEY_BYTES`], starting with
177/// `.`, or containing anything outside `[A-Za-z0-9._-]`.
178fn 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
186/// Checks one environment name against the grammar.
187///
188/// # Errors
189/// [`SecretError::InvalidEnvironment`]: the same conditions [`check_key`]
190/// refuses, so a name can never contain a `/`.
191fn 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/// The lock file that guards `path`: its own name with `.lock` appended, so
200/// it sits in `$SHEP_HOME` next to the store and inherits that directory's
201/// `0700`.
202///
203/// `cfg(any(unix, windows))` alongside its two callers: [`SecretLock::acquire`]
204/// names a real lock file on both platforms, unix through `flock(2)` and
205/// windows through an exclusive `share_mode(0)` open.
206#[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
216/// An exclusive advisory lock over one secret store, released when it drops,
217/// including by the kernel if the process dies holding it.
218///
219/// On a sibling `secrets.json.lock`, never on the store itself: `rename`
220/// replaces the store's inode, which would orphan a lock held on it.
221struct SecretLock {
222    /// `flock(2)` is released by this handle's `Drop`. Named with a leading
223    /// underscore because it is held, never read.
224    #[cfg(unix)]
225    _flock: nix::fcntl::Flock<std::fs::File>,
226    /// The lock file, opened with `share_mode(0)` so no other handle can
227    /// open it while this one is live; released by `Drop`, the same role
228    /// `_flock` plays on unix. Named with a leading underscore because it
229    /// is held, never read.
230    #[cfg(windows)]
231    _handle: std::fs::File,
232}
233
234impl SecretLock {
235    /// Blocks until this process holds the store's lock exclusively.
236    ///
237    /// # Errors
238    /// The lock file could not be created beside `path`, or `flock` failed
239    /// for a reason other than contention (contention blocks rather than
240    /// failing).
241    #[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    /// Blocks until this process holds the store's lock exclusively.
259    ///
260    /// `share_mode(0)` denies every other open, in this process or another,
261    /// giving the same exclusivity as unix `flock`. A contended open fails
262    /// immediately with `ERROR_SHARING_VIOLATION` rather than blocking, so
263    /// this polls on a short sleep until it succeeds.
264    ///
265    /// # Errors
266    /// The lock file could not be created beside `path`, or the open failed
267    /// for a reason other than sharing contention (contention retries rather
268    /// than failing).
269    #[cfg(windows)]
270    fn acquire(path: &Path) -> std::io::Result<Self> {
271        use std::os::windows::fs::OpenOptionsExt as _;
272
273        /// Windows' `ERROR_SHARING_VIOLATION`: another handle already holds
274        /// share access this open's `share_mode(0)` denies. Hardcoded rather
275        /// than pulled from `windows-sys`, since this crate has no other
276        /// Windows-only dependency.
277        const ERROR_SHARING_VIOLATION: i32 = 32;
278
279        /// How long a contended retry sleeps before trying again. Short
280        /// enough that a lock held for a normal `set`/`unset`'s duration (a
281        /// handful of small file operations) costs this loop only a few
282        /// iterations, long enough not to spin the CPU while it waits.
283        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
304/// Reads whichever version of the store `path` currently names.
305///
306/// A missing file reads as an empty, current-version store: reading against
307/// a fresh `$SHEP_HOME` should not fail with `ENOENT`. Any other
308/// `io::Error` propagates.
309fn 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
322/// Rewrites `path` to hold exactly `file`, atomically: staged through a
323/// temp file, then renamed over the original.
324fn 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    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
334    // inside the error and its `Drop` removes the staging file, so a failed
335    // replace does not leave one behind.
336    tmp.persist(path)
337        .map_err(|err| SecretError::Io(err.error))?;
338
339    // `sync_all` above made the contents durable; this makes the rename
340    // that published them durable too.
341    crate::atomic_file::sync_dir(parent)?;
342    Ok(())
343}
344
345/// Every key in the store with its per-environment values, in key order.
346///
347/// Takes no lock, so a caller that must not block never does: the daemon
348/// reads this from inside its actor loop, once per spawn, once per app at
349/// preflight, and once more each time a sheep's extras arm on the way to
350/// `Online`. That is safe because a writer publishes by renaming a fully
351/// written file over this one, so a reader sees the whole store either
352/// before or after a `set`/`unset`, never a fragment of one. The lock
353/// [`set`] and [`unset`] take is what orders those read-modify-writes
354/// against each other.
355///
356/// # Errors
357///
358/// - [`SecretError::Io`]: the store could not be opened or read. A store
359///   that is simply absent is not an error: it reads as empty.
360/// - [`SecretError::Decode`]: the file is not the JSON this module writes.
361/// - [`SecretError::FutureVersion`]: the file's `version` is newer than
362///   [`SECRETS_VERSION`]. Nothing is read and nothing is written.
363pub fn all(path: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, SecretError> {
364    Ok(read_file(path)?.entries)
365}
366
367/// The value stored under `key` for exactly `environment`, if there is one.
368///
369/// The stored slot, not the resolved value: there is no fallback to
370/// [`ALL_ENVIRONMENTS`] here. [`SecretView::resolve`] is what a config
371/// reference goes through.
372///
373/// # Errors
374///
375/// [`SecretError::InvalidKey`] and [`SecretError::InvalidEnvironment`] for
376/// names outside the grammar (refused before the file is opened, so a
377/// malformed name never creates one), plus `Io`, `Decode` and
378/// `FutureVersion` exactly as [`all`] returns them.
379pub 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
387/// Stores `value` under `key` for `environment`, replacing any previous
388/// value in that slot and leaving every other environment alone.
389///
390/// # Errors
391///
392/// - [`SecretError::InvalidKey`]: the key is outside the grammar.
393/// - [`SecretError::InvalidEnvironment`]: the environment name is outside
394///   the grammar.
395/// - [`SecretError::ValueTooLong`]: the value exceeds [`MAX_VALUE_BYTES`].
396/// - [`SecretError::FutureVersion`]: the store on disk is newer than this
397///   build understands. Nothing is written.
398/// - [`SecretError::Decode`]: the existing file could not be parsed.
399/// - [`SecretError::Io`]: the lock, the temp file, the `fsync` or the
400///   `rename` failed.
401pub 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
421/// Removes `key`'s value for `environment`, returning whether it was there.
422///
423/// A key whose last environment this removes goes with it, so the store
424/// never accumulates keys that hold nothing.
425///
426/// # Errors
427///
428/// The same set [`set`] returns, minus [`SecretError::ValueTooLong`]:
429/// `InvalidKey`, `InvalidEnvironment`, `FutureVersion`, `Decode`, `Io`.
430pub 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/// One `{{secret:...}}` reference: a key, and the namespace it came from.
451///
452/// A bare reference reads the operator's own store; a namespaced one reads
453/// what the provider dog of that name pushed.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub struct SecretRef<'a> {
456    /// The provider dog's name, or `None` for the operator's own store.
457    pub namespace: Option<&'a str>,
458    /// The key within that store.
459    pub key: &'a str,
460}
461
462impl<'a> SecretRef<'a> {
463    /// Parses the body of a `{{secret:...}}` token, braces and prefix
464    /// already stripped.
465    ///
466    /// Returns `None` for anything outside the grammar, which is how a
467    /// config refuses a bad reference before a sheep ever starts.
468    #[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/// Every `{{secret:...}}` reference `config` names, exactly as the operator
497/// wrote it (`KEY` or `namespace/KEY`, no braces), deduplicated.
498///
499/// Walks `env`'s values, `args`, `out_file` and `err_file` through
500/// `template`'s own tokenizer, the same one [`template::render`] resolves
501/// against at spawn: a value this misses is one `render` would not touch
502/// either, and a positional token (`{{instance}}`, `{{name}}`) contributes
503/// nothing.
504#[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/// The provider namespaces [`references`] names, derived with
536/// [`SecretRef::parse`] and kept to the namespaced half.
537///
538/// No I/O of its own: the seam boot-dependency ordering asks "which
539/// provider namespaces does this sheep depend on" through.
540#[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
549/// `namespace -> key -> environment -> value`, every provider dog's pushed
550/// values.
551pub type NamespaceValues = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
552
553/// `namespace -> environments`, the pairs a provider dog has actually
554/// pushed for.
555///
556/// A push carries one namespace and one environment
557/// (`Request::PutSecrets`), so a provider that has pushed `production` and
558/// not yet `staging` has one entry here, not two. That distinction is what
559/// [`Resolution::MissingNamespace`] is keyed on.
560pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
561
562/// What provider dogs have pushed: the values, and which
563/// `(namespace, environment)` pairs carry a push at all.
564///
565/// The two travel together because they are read together and must come
566/// from one moment: a values map from before a push read beside a pair set
567/// from after it would call a key permanently missing that the push had
568/// just supplied.
569///
570/// An empty push is why the pair set is not derivable from the values. A
571/// dog saying "I have nothing for staging" registers the pair and holds no
572/// keys, which is a different answer from a dog that has not pushed.
573///
574/// Debug does not leak a value: it prints two counts.
575#[derive(Default, Clone)]
576pub struct ProviderCache {
577    /// Every namespace's values.
578    pub values: NamespaceValues,
579    /// Every `(namespace, environment)` pair a push has landed for.
580    pub pushed: PushedPairs,
581}
582
583/// Redacted (IR-41): `values` holds provider values in the clear.
584impl 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/// The on-disk shape of `secrets-cache.json`, mirrored from
594/// `shep-daemon`'s own private writer so a reader on this side of the
595/// crate boundary can stay in step with it without importing a published
596/// binary crate's internals.
597#[derive(Default, Deserialize)]
598struct ProviderCacheFile {
599    version: u32,
600    #[serde(default)]
601    namespaces: NamespaceValues,
602    #[serde(default)]
603    pushed: PushedPairs,
604}
605
606/// Redacted (IR-41), matching `shep-daemon`'s own `CacheFile`: `namespaces`
607/// holds provider values in the clear.
608impl 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
618/// The `secrets-cache.json` format version this build reads and writes.
619///
620/// One constant for both sides: shep-daemon writes the file and stamps it
621/// with this, and [`provider_cache_on_disk`] refuses anything else. Two
622/// literals of the same value would let a bump on one side turn every read
623/// on the other into an empty cache, with nothing to say why.
624pub const PROVIDER_CACHE_VERSION: u32 = 2;
625
626/// The provider cache as `secrets-cache.json` currently holds it on disk,
627/// or nothing when the file is missing, will not parse, or is a version
628/// this build does not understand.
629///
630/// Best-effort, more so than [`all`]: a namespace whose provider pushed
631/// with `persist = false` never reaches this file at all, so a caller here
632/// can under-report `MissingNamespace` for a pair the running shepherd
633/// currently holds in memory. A caller that needs the shepherd's live
634/// answer has to ask it directly rather than read this file.
635#[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
649/// What one environment can see: the operator's store plus every provider
650/// dog's, resolved against a single environment name.
651///
652/// Built once per resolution pass and read many times, so the maps are owned
653/// rather than borrowed.
654///
655/// Debug does not leak a value: it prints the environment and two counts.
656pub struct SecretView {
657    environment: String,
658    store: BTreeMap<String, BTreeMap<String, String>>,
659    providers: ProviderCache,
660}
661
662/// Redacted (IR-41): `store` and the provider cache hold secret values.
663impl 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    /// A view over `store` and `providers`, resolved for `environment`.
675    #[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    /// A view holding nothing, so a bare reference resolves to
689    /// [`Resolution::MissingKey`] and a namespaced one to
690    /// [`Resolution::MissingNamespace`].
691    #[must_use]
692    pub fn empty(environment: String) -> Self {
693        Self::new(environment, BTreeMap::new(), ProviderCache::default())
694    }
695
696    /// The environment this view resolves against.
697    #[must_use]
698    pub fn environment(&self) -> &str {
699        &self.environment
700    }
701
702    /// The value `reference` resolves to in this view's environment.
703    ///
704    /// Exact environment, then [`ALL_ENVIRONMENTS`], then nothing. There is
705    /// deliberately no fallback to another named environment: filling an
706    /// empty `staging` slot from `production` would hand a live credential
707    /// to staging the first time somebody forgot to set one.
708    ///
709    /// A miss on a namespaced reference is [`Resolution::MissingNamespace`]
710    /// unless a provider has pushed this view's own environment for that
711    /// namespace: a push carries one `(namespace, environment)` pair, so a
712    /// dog part way through `production` then `staging` has said nothing
713    /// about staging yet, and calling that a missing key would `Errored` a
714    /// staging sheep permanently for a value arriving a second later.
715    #[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    /// Whether a provider has pushed `namespace` for this view's own
740    /// environment or for [`ALL_ENVIRONMENTS`].
741    ///
742    /// A push to [`ALL_ENVIRONMENTS`] populates the namespace for every
743    /// environment, [`resolve`](Self::resolve)'s value lookup included, so
744    /// the pair check has to agree: a namespace pushed only under `all`
745    /// counts as pushed here too, or a key genuinely absent from that push
746    /// would read as the transient `MissingNamespace` instead of the
747    /// permanent `MissingKey` it actually is.
748    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
758/// The outcome of resolving one [`SecretRef`].
759///
760/// [`Self::MissingKey`] and [`Self::MissingNamespace`] are kept apart
761/// because they need different remedies: a pair no dog has pushed is a dog
762/// that has not reported yet, which a retry fixes, while a missing key is a
763/// person's to set.
764///
765/// Debug does not leak a value: [`Self::Found`] prints as `Found(..)`.
766pub enum Resolution<'a> {
767    /// The resolved value.
768    Found(&'a str),
769    /// The operator's store holds nothing for this key, or a provider has
770    /// pushed this namespace for this environment and that push lacks the
771    /// key.
772    MissingKey,
773    /// No provider dog has pushed this namespace for this environment yet.
774    MissingNamespace,
775}
776
777/// Redacted (IR-41): [`Resolution::Found`] carries a secret's value.
778impl 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        // Writing the `all` slot is how a value covers every environment,
850        // so `set` accepts it. Nothing else about the name is special.
851        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        // `get` returns the stored slot only; `SecretView::resolve` is the
863        // one place the `all` fallback lives. Nothing here should fail if
864        // that boundary moves, which is exactly the point of pinning it.
865        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        // The whole point: staging never falls back to production's value.
1034        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    /// A cache holding `vercel/PRESENT` for `production`, pushed as that
1051    /// one pair.
1052    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        // The dog is up and simply does not have this key: a person's problem.
1084        assert!(matches!(
1085            view.resolve(&SecretRef {
1086                namespace: Some("vercel"),
1087                key: "ABSENT"
1088            }),
1089            Resolution::MissingKey
1090        ));
1091        // No dog has ever pushed under this name: transient, retry.
1092        assert!(matches!(
1093            view.resolve(&SecretRef {
1094                namespace: Some("vault"),
1095                key: "ANY"
1096            }),
1097            Resolution::MissingNamespace
1098        ));
1099    }
1100
1101    /// The case this pair set exists for. A provider pushes `production`
1102    /// and then `staging`, which is the ordinary shape, and a staging sheep
1103    /// spawning between the two must wait on the restart ladder rather than
1104    /// `Errored` for good. Keying on the namespace alone calls the second
1105    /// push's keys permanently missing the moment the first push lands.
1106    #[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    /// The other half, and the one that must stay permanent: the pair has
1123    /// been pushed and the key is not in it, so the provider genuinely does
1124    /// not have it and no amount of waiting will produce one.
1125    #[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    /// A cache holding `vercel/PRESENT` for [`ALL_ENVIRONMENTS`], pushed as
1143    /// that one pair and no other.
1144    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    /// A push to [`ALL_ENVIRONMENTS`] populates the namespace for every
1161    /// environment, not only the literal string `"all"`, so a key that push
1162    /// genuinely lacks is permanent for a `staging` view exactly as it
1163    /// would be for a `production` one.
1164    #[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    /// The value half of the same all-only push, from a pair-aware view:
1178    /// the namespace resolves the key it does carry for an environment that
1179    /// never received its own push.
1180    #[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    /// An empty push is a dog saying it holds nothing for that pair, which
1194    /// is an answer. Deriving the pairs from the values would lose it and
1195    /// leave such a sheep retrying against a dog that has already spoken.
1196    #[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    /// IR-41. Fails the moment somebody replaces the hand-written impl with
1240    /// a derive, which is the only way this leak comes back.
1241    #[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    /// IR-41, the same guard for the on-disk shape everything else is built
1257    /// from. `SecretFile` is private, so `missing_debug_implementations`
1258    /// never forces it to keep a `Debug` impl at all; this is what stops a
1259    /// later edit from deriving one over the hand-written redaction.
1260    #[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    /// IR-41, the same guard for the on-disk shape [`provider_cache_on_disk`]
1275    /// reads: it mirrors `shep-daemon`'s `CacheFile`, values in the clear
1276    /// included.
1277    #[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    /// IR-41 for the type the two halves travel in together.
1302    #[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    /// IR-41, the same guard for the type a resolved value travels in.
1312    #[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    /// Exact strings for both renderings (IR-41): every variant is meant to
1323    /// carry a name and never a value, and a substring check cannot see a
1324    /// field it was never told to look for.
1325    #[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}