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 a
9//! [`crate::file_lock`] on a sibling `secrets.json.lock`.
10
11use core::fmt;
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17use crate::config::AppConfig;
18use crate::config::template;
19use crate::file_lock::FileLock;
20
21/// The on-disk format's version.
22///
23/// A store carrying a higher version is refused rather than read or
24/// replaced ([`SecretError::FutureVersion`]): there is no undo for a
25/// downgrade that overwrites an operator's credentials.
26pub const SECRETS_VERSION: u32 = 1;
27
28/// Longest key, namespace or environment name this store accepts, in bytes.
29pub const MAX_KEY_BYTES: usize = 128;
30
31/// Longest value this store accepts, in bytes.
32///
33/// The store is read whole on every access; a cap keeps it from becoming an
34/// unbounded blob store. A 4096-bit RSA private key in PEM is 3272 bytes.
35pub const MAX_VALUE_BYTES: usize = 4096;
36
37/// The environment name that covers every environment.
38///
39/// A value here is used when the sheep's own environment has no slot of its
40/// own. Cannot be a sheep's `environment`, which `AppConfig` refuses.
41pub const ALL_ENVIRONMENTS: &str = "all";
42
43/// The file's shape: a version and a key to environment to value map.
44///
45/// `BTreeMap` throughout so two writes of the same content produce
46/// byte-identical files.
47#[derive(Default, Serialize, Deserialize)]
48struct SecretFile {
49    version: u32,
50    entries: BTreeMap<String, BTreeMap<String, String>>,
51}
52
53/// Redacted (IR-41): `entries` is the whole point of this type.
54impl fmt::Debug for SecretFile {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.debug_struct("SecretFile")
57            .field("version", &self.version)
58            .field("keys", &self.entries.len())
59            .finish()
60    }
61}
62
63/// Error type returned by this module.
64///
65/// `#[non_exhaustive]`: shep-core is published, so a new failure variant
66/// must not break an out-of-tree `match`.
67///
68/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
69/// them, matching [`crate::kv::KvError`], so callers keep the underlying
70/// diagnostic through [`core::error::Error::source`]; this type does not
71/// derive `Clone`/`PartialEq`/`Eq` as a result.
72///
73/// No variant carries a secret's value: a message names the key, the
74/// namespace or the environment, and nothing else.
75#[non_exhaustive]
76#[derive(Debug)]
77pub enum SecretError {
78    /// The store could not be read, written, or replaced.
79    Io(std::io::Error),
80    /// The store's JSON could not be parsed.
81    ///
82    /// Refused rather than repaired: a partial read would silently drop
83    /// credentials still on disk, and a later write would erase them.
84    Decode(serde_json::Error),
85    /// A key outside the grammar; carries it verbatim so the message can
86    /// quote what was typed.
87    InvalidKey(String),
88    /// An environment name outside the grammar; carries it verbatim.
89    InvalidEnvironment(String),
90    /// A value over [`MAX_VALUE_BYTES`].
91    ValueTooLong {
92        /// The key it was being stored under.
93        key: String,
94        /// Its length in bytes.
95        len: usize,
96    },
97    /// The store on disk is a version this build does not understand; carries
98    /// that version. Nothing was written.
99    FutureVersion(u32),
100}
101
102impl fmt::Display for SecretError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::Io(err) => write!(f, "secret store I/O failed: {err}"),
106            Self::Decode(err) => write!(f, "secret store failed to parse: {err}"),
107            Self::InvalidKey(key) => write!(f, "`{key}` is not a valid secret key"),
108            Self::InvalidEnvironment(environment) => {
109                write!(f, "`{environment}` is not a valid environment name")
110            }
111            Self::ValueTooLong { key, len } => write!(
112                f,
113                "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
114            ),
115            Self::FutureVersion(version) => write!(
116                f,
117                "secret store is version {version}, newer than this build understands"
118            ),
119        }
120    }
121}
122
123impl core::error::Error for SecretError {
124    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
125        match self {
126            Self::Io(err) => Some(err),
127            Self::Decode(err) => Some(err),
128            Self::InvalidKey(_)
129            | Self::InvalidEnvironment(_)
130            | Self::ValueTooLong { .. }
131            | Self::FutureVersion(_) => None,
132        }
133    }
134}
135
136impl From<std::io::Error> for SecretError {
137    fn from(source: std::io::Error) -> Self {
138        Self::Io(source)
139    }
140}
141
142impl From<serde_json::Error> for SecretError {
143    fn from(source: serde_json::Error) -> Self {
144        Self::Decode(source)
145    }
146}
147
148/// The grammar shared by keys, namespaces and environment names.
149///
150/// Non-empty, at most [`MAX_KEY_BYTES`], not starting with `.`, and drawn
151/// from `[A-Za-z0-9._-]`. Excludes `/`, so a name can never contain the
152/// separator [`SecretRef::parse`] splits a namespace from a key on.
153///
154/// Public because the daemon checks a peer's namespace and environment
155/// against it before storing anything under either: a name outside this
156/// grammar is one no `{{secret:...}}` reference could ever name, so
157/// accepting it would be a silent no-op.
158#[must_use]
159pub fn is_name(value: &str) -> bool {
160    !value.is_empty()
161        && value.len() <= MAX_KEY_BYTES
162        && !value.starts_with('.')
163        && value
164            .bytes()
165            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
166}
167
168/// Checks one key against the grammar.
169///
170/// # Errors
171/// [`SecretError::InvalidKey`]: empty, over [`MAX_KEY_BYTES`], starting with
172/// `.`, or containing anything outside `[A-Za-z0-9._-]`.
173fn check_key(key: &str) -> Result<(), SecretError> {
174    if is_name(key) {
175        Ok(())
176    } else {
177        Err(SecretError::InvalidKey(key.to_string()))
178    }
179}
180
181/// Checks one environment name against the grammar.
182///
183/// # Errors
184/// [`SecretError::InvalidEnvironment`]: the same conditions [`check_key`]
185/// refuses, so a name can never contain a `/`.
186fn check_environment(environment: &str) -> Result<(), SecretError> {
187    if is_name(environment) {
188        Ok(())
189    } else {
190        Err(SecretError::InvalidEnvironment(environment.to_string()))
191    }
192}
193
194/// Reads whichever version of the store `path` currently names.
195///
196/// A missing file reads as an empty, current-version store: reading against
197/// a fresh `$SHEP_HOME` should not fail with `ENOENT`. Any other
198/// `io::Error` propagates.
199fn read_file(path: &Path) -> Result<SecretFile, SecretError> {
200    let raw = match std::fs::read_to_string(path) {
201        Ok(raw) => raw,
202        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(SecretFile::default()),
203        Err(err) => return Err(SecretError::Io(err)),
204    };
205    let file: SecretFile = serde_json::from_str(&raw)?;
206    if file.version > SECRETS_VERSION {
207        return Err(SecretError::FutureVersion(file.version));
208    }
209    Ok(file)
210}
211
212/// Rewrites `path` to hold exactly `file`.
213fn write_file(path: &Path, file: &SecretFile) -> Result<(), SecretError> {
214    crate::atomic_file::write_json(path, "secrets", file).map_err(SecretError::Io)
215}
216
217/// Every key in the store with its per-environment values, in key order.
218///
219/// Takes no lock, so a caller that must not block never does: the daemon
220/// reads this from inside its actor loop, once per spawn, once per app at
221/// preflight, and once more each time a sheep's extras arm on the way to
222/// `Online`. That is safe because a writer publishes by renaming a fully
223/// written file over this one, so a reader sees the whole store either
224/// before or after a `set`/`unset`, never a fragment of one. The lock
225/// [`set`] and [`unset`] take is what orders those read-modify-writes
226/// against each other.
227///
228/// # Errors
229///
230/// - [`SecretError::Io`]: the store could not be opened or read. A store
231///   that is simply absent is not an error: it reads as empty.
232/// - [`SecretError::Decode`]: the file is not the JSON this module writes.
233/// - [`SecretError::FutureVersion`]: the file's `version` is newer than
234///   [`SECRETS_VERSION`]. Nothing is read and nothing is written.
235pub fn all(path: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, SecretError> {
236    Ok(read_file(path)?.entries)
237}
238
239/// The value stored under `key` for exactly `environment`, if there is one.
240///
241/// The stored slot, not the resolved value: there is no fallback to
242/// [`ALL_ENVIRONMENTS`] here. [`SecretView::resolve`] is what a config
243/// reference goes through.
244///
245/// # Errors
246///
247/// [`SecretError::InvalidKey`] and [`SecretError::InvalidEnvironment`] for
248/// names outside the grammar (refused before the file is opened, so a
249/// malformed name never creates one), plus `Io`, `Decode` and
250/// `FutureVersion` exactly as [`all`] returns them.
251pub fn get(path: &Path, key: &str, environment: &str) -> Result<Option<String>, SecretError> {
252    check_key(key)?;
253    check_environment(environment)?;
254    Ok(all(path)?
255        .remove(key)
256        .and_then(|mut by_environment| by_environment.remove(environment)))
257}
258
259/// Stores `value` under `key` for `environment`, replacing any previous
260/// value in that slot and leaving every other environment alone.
261///
262/// # Errors
263///
264/// - [`SecretError::InvalidKey`]: the key is outside the grammar.
265/// - [`SecretError::InvalidEnvironment`]: the environment name is outside
266///   the grammar.
267/// - [`SecretError::ValueTooLong`]: the value exceeds [`MAX_VALUE_BYTES`].
268/// - [`SecretError::FutureVersion`]: the store on disk is newer than this
269///   build understands. Nothing is written.
270/// - [`SecretError::Decode`]: the existing file could not be parsed.
271/// - [`SecretError::Io`]: the lock, the temp file, the `fsync` or the
272///   `rename` failed.
273pub fn set(path: &Path, key: &str, environment: &str, value: &str) -> Result<(), SecretError> {
274    check_key(key)?;
275    check_environment(environment)?;
276    if value.len() > MAX_VALUE_BYTES {
277        return Err(SecretError::ValueTooLong {
278            key: key.to_string(),
279            len: value.len(),
280        });
281    }
282
283    let _lock = FileLock::acquire(path)?;
284    let mut file = read_file(path)?;
285    file.version = SECRETS_VERSION;
286    file.entries
287        .entry(key.to_string())
288        .or_default()
289        .insert(environment.to_string(), value.to_string());
290    write_file(path, &file)
291}
292
293/// Removes `key`'s value for `environment`, returning whether it was there.
294///
295/// A key whose last environment this removes goes with it, so the store
296/// never accumulates keys that hold nothing.
297///
298/// # Errors
299///
300/// The same set [`set`] returns, minus [`SecretError::ValueTooLong`]:
301/// `InvalidKey`, `InvalidEnvironment`, `FutureVersion`, `Decode`, `Io`.
302pub fn unset(path: &Path, key: &str, environment: &str) -> Result<bool, SecretError> {
303    check_key(key)?;
304    check_environment(environment)?;
305
306    let _lock = FileLock::acquire(path)?;
307    let mut file = read_file(path)?;
308    let Some(by_environment) = file.entries.get_mut(key) else {
309        return Ok(false);
310    };
311    let was_present = by_environment.remove(environment).is_some();
312    if was_present {
313        if by_environment.is_empty() {
314            file.entries.remove(key);
315        }
316        file.version = SECRETS_VERSION;
317        write_file(path, &file)?;
318    }
319    Ok(was_present)
320}
321
322/// One `{{secret:...}}` reference: a key, and the namespace it came from.
323///
324/// A bare reference reads the operator's own store; a namespaced one reads
325/// what the provider dog of that name pushed.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub struct SecretRef<'a> {
328    /// The provider dog's name, or `None` for the operator's own store.
329    pub namespace: Option<&'a str>,
330    /// The key within that store.
331    pub key: &'a str,
332}
333
334impl<'a> SecretRef<'a> {
335    /// Parses the body of a `{{secret:...}}` token, braces and prefix
336    /// already stripped.
337    ///
338    /// Returns `None` for anything outside the grammar, which is how a
339    /// config refuses a bad reference before a sheep ever starts.
340    #[must_use]
341    pub fn parse(body: &'a str) -> Option<Self> {
342        match body.split_once('/') {
343            None if is_name(body) => Some(Self {
344                namespace: None,
345                key: body,
346            }),
347            Some((namespace, key)) if is_name(namespace) && is_name(key) => Some(Self {
348                namespace: Some(namespace),
349                key,
350            }),
351            _ => None,
352        }
353    }
354}
355
356impl fmt::Display for SecretRef<'_> {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        f.write_str("{{secret:")?;
359        if let Some(namespace) = self.namespace {
360            f.write_str(namespace)?;
361            f.write_str("/")?;
362        }
363        f.write_str(self.key)?;
364        f.write_str("}}")
365    }
366}
367
368/// Every `{{secret:...}}` reference `config` names, exactly as the operator
369/// wrote it (`KEY` or `namespace/KEY`, no braces), deduplicated.
370///
371/// Walks `env`'s values, `args`, `out_file` and `err_file` through
372/// `template`'s own tokenizer, the same one [`template::render`] resolves
373/// against at spawn: a value this misses is one `render` would not touch
374/// either, and a positional token (`{{instance}}`, `{{name}}`) contributes
375/// nothing.
376#[must_use]
377pub fn references(config: &AppConfig) -> BTreeSet<String> {
378    let mut found = BTreeSet::new();
379    let mut scan = |value: &str| {
380        let _ = template::walk::<core::convert::Infallible>(value, |segment| {
381            if let template::Segment::Token(token) = segment
382                && let Some(reference) = template::secret_reference(token)
383            {
384                found.insert(match reference.namespace {
385                    Some(namespace) => format!("{namespace}/{}", reference.key),
386                    None => reference.key.to_string(),
387                });
388            }
389            Ok(())
390        });
391    };
392    for value in config.env.values() {
393        scan(value);
394    }
395    for value in &config.args {
396        scan(value);
397    }
398    if let Some(value) = &config.out_file {
399        scan(value);
400    }
401    if let Some(value) = &config.err_file {
402        scan(value);
403    }
404    found
405}
406
407/// The provider namespaces [`references`] names, derived with
408/// [`SecretRef::parse`] and kept to the namespaced half.
409///
410/// No I/O of its own: the seam boot-dependency ordering asks "which
411/// provider namespaces does this sheep depend on" through.
412#[must_use]
413pub fn namespaces_of(config: &AppConfig) -> BTreeSet<String> {
414    references(config)
415        .iter()
416        .filter_map(|reference| SecretRef::parse(reference))
417        .filter_map(|reference| reference.namespace.map(str::to_string))
418        .collect()
419}
420
421/// `namespace -> key -> environment -> value`, every provider dog's pushed
422/// values.
423pub type NamespaceValues = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
424
425/// `namespace -> environments`, the pairs a provider dog has actually
426/// pushed for.
427///
428/// A push carries one namespace and one environment
429/// (`Request::PutSecrets`), so a provider that has pushed `production` and
430/// not yet `staging` has one entry here, not two. That distinction is what
431/// [`Resolution::MissingNamespace`] is keyed on.
432pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
433
434/// What provider dogs have pushed: the values, and which
435/// `(namespace, environment)` pairs carry a push at all.
436///
437/// The two travel together because they are read together and must come
438/// from one moment: a values map from before a push read beside a pair set
439/// from after it would call a key permanently missing that the push had
440/// just supplied.
441///
442/// An empty push is why the pair set is not derivable from the values. A
443/// dog saying "I have nothing for staging" registers the pair and holds no
444/// keys, which is a different answer from a dog that has not pushed.
445///
446/// Debug does not leak a value: it prints two counts.
447#[derive(Default, Clone)]
448pub struct ProviderCache {
449    /// Every namespace's values.
450    pub values: NamespaceValues,
451    /// Every `(namespace, environment)` pair a push has landed for.
452    pub pushed: PushedPairs,
453}
454
455/// Redacted (IR-41): `values` holds provider values in the clear.
456impl fmt::Debug for ProviderCache {
457    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458        f.debug_struct("ProviderCache")
459            .field("namespaces", &self.values.len())
460            .field("pushed", &self.pushed.len())
461            .finish()
462    }
463}
464
465/// The on-disk shape of `secrets-cache.json`, mirrored from
466/// `shep-daemon`'s own private writer so a reader on this side of the
467/// crate boundary can stay in step with it without importing a published
468/// binary crate's internals.
469#[derive(Default, Deserialize)]
470struct ProviderCacheFile {
471    version: u32,
472    #[serde(default)]
473    namespaces: NamespaceValues,
474    #[serde(default)]
475    pushed: PushedPairs,
476}
477
478/// Redacted (IR-41), matching `shep-daemon`'s own `CacheFile`: `namespaces`
479/// holds provider values in the clear.
480impl fmt::Debug for ProviderCacheFile {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        f.debug_struct("ProviderCacheFile")
483            .field("version", &self.version)
484            .field("namespaces", &self.namespaces.len())
485            .field("pushed", &self.pushed.len())
486            .finish()
487    }
488}
489
490/// The `secrets-cache.json` format version this build reads and writes.
491///
492/// One constant for both sides: shep-daemon writes the file and stamps it
493/// with this, and [`provider_cache_on_disk`] refuses anything else. Two
494/// literals of the same value would let a bump on one side turn every read
495/// on the other into an empty cache, with nothing to say why.
496pub const PROVIDER_CACHE_VERSION: u32 = 2;
497
498/// The provider cache as `secrets-cache.json` currently holds it on disk,
499/// or nothing when the file is missing, will not parse, or is a version
500/// this build does not understand.
501///
502/// Best-effort, more so than [`all`]: a namespace whose provider pushed
503/// with `persist = false` never reaches this file at all, so a caller here
504/// can under-report `MissingNamespace` for a pair the running shepherd
505/// currently holds in memory. A caller that needs the shepherd's live
506/// answer has to ask it directly rather than read this file.
507#[must_use]
508pub fn provider_cache_on_disk(path: &Path) -> ProviderCache {
509    let Ok(raw) = std::fs::read_to_string(path) else {
510        return ProviderCache::default();
511    };
512    match serde_json::from_str::<ProviderCacheFile>(&raw) {
513        Ok(file) if file.version == PROVIDER_CACHE_VERSION => ProviderCache {
514            values: file.namespaces,
515            pushed: file.pushed,
516        },
517        _ => ProviderCache::default(),
518    }
519}
520
521/// What one environment can see: the operator's store plus every provider
522/// dog's, resolved against a single environment name.
523///
524/// Built once per resolution pass and read many times, so the maps are owned
525/// rather than borrowed.
526///
527/// Debug does not leak a value: it prints the environment and two counts.
528pub struct SecretView {
529    environment: String,
530    store: BTreeMap<String, BTreeMap<String, String>>,
531    providers: ProviderCache,
532}
533
534/// Redacted (IR-41): `store` and the provider cache hold secret values.
535impl fmt::Debug for SecretView {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        f.debug_struct("SecretView")
538            .field("environment", &self.environment)
539            .field("keys", &self.store.len())
540            .field("namespaces", &self.providers.values.len())
541            .finish()
542    }
543}
544
545impl SecretView {
546    /// A view over `store` and `providers`, resolved for `environment`.
547    #[must_use]
548    pub fn new(
549        environment: String,
550        store: BTreeMap<String, BTreeMap<String, String>>,
551        providers: ProviderCache,
552    ) -> Self {
553        Self {
554            environment,
555            store,
556            providers,
557        }
558    }
559
560    /// A view holding nothing, so a bare reference resolves to
561    /// [`Resolution::MissingKey`] and a namespaced one to
562    /// [`Resolution::MissingNamespace`].
563    #[must_use]
564    pub fn empty(environment: String) -> Self {
565        Self::new(environment, BTreeMap::new(), ProviderCache::default())
566    }
567
568    /// The environment this view resolves against.
569    #[must_use]
570    pub fn environment(&self) -> &str {
571        &self.environment
572    }
573
574    /// The value `reference` resolves to in this view's environment.
575    ///
576    /// Exact environment, then [`ALL_ENVIRONMENTS`], then nothing. There is
577    /// deliberately no fallback to another named environment: filling an
578    /// empty `staging` slot from `production` would hand a live credential
579    /// to staging the first time somebody forgot to set one.
580    ///
581    /// A miss on a namespaced reference is [`Resolution::MissingNamespace`]
582    /// unless a provider has pushed this view's own environment for that
583    /// namespace: a push carries one `(namespace, environment)` pair, so a
584    /// dog part way through `production` then `staging` has said nothing
585    /// about staging yet, and calling that a missing key would `Errored` a
586    /// staging sheep permanently for a value arriving a second later.
587    #[must_use]
588    pub fn resolve(&self, reference: &SecretRef<'_>) -> Resolution<'_> {
589        let table = match reference.namespace {
590            None => Some(&self.store),
591            Some(namespace) => self.providers.values.get(namespace),
592        };
593        if let Some(value) =
594            table
595                .and_then(|table| table.get(reference.key))
596                .and_then(|by_environment| {
597                    by_environment
598                        .get(&self.environment)
599                        .or_else(|| by_environment.get(ALL_ENVIRONMENTS))
600                })
601        {
602            return Resolution::Found(value.as_str());
603        }
604        match reference.namespace {
605            None => Resolution::MissingKey,
606            Some(namespace) if self.is_pushed(namespace) => Resolution::MissingKey,
607            Some(_) => Resolution::MissingNamespace,
608        }
609    }
610
611    /// Whether a provider has pushed `namespace` for this view's own
612    /// environment or for [`ALL_ENVIRONMENTS`].
613    ///
614    /// A push to [`ALL_ENVIRONMENTS`] populates the namespace for every
615    /// environment, [`resolve`](Self::resolve)'s value lookup included, so
616    /// the pair check has to agree: a namespace pushed only under `all`
617    /// counts as pushed here too, or a key genuinely absent from that push
618    /// would read as the transient `MissingNamespace` instead of the
619    /// permanent `MissingKey` it actually is.
620    fn is_pushed(&self, namespace: &str) -> bool {
621        self.providers
622            .pushed
623            .get(namespace)
624            .is_some_and(|environments| {
625                environments.contains(&self.environment) || environments.contains(ALL_ENVIRONMENTS)
626            })
627    }
628}
629
630/// The outcome of resolving one [`SecretRef`].
631///
632/// [`Self::MissingKey`] and [`Self::MissingNamespace`] are kept apart
633/// because they need different remedies: a pair no dog has pushed is a dog
634/// that has not reported yet, which a retry fixes, while a missing key is a
635/// person's to set.
636///
637/// Debug does not leak a value: [`Self::Found`] prints as `Found(..)`.
638pub enum Resolution<'a> {
639    /// The resolved value.
640    Found(&'a str),
641    /// The operator's store holds nothing for this key, or a provider has
642    /// pushed this namespace for this environment and that push lacks the
643    /// key.
644    MissingKey,
645    /// No provider dog has pushed this namespace for this environment yet.
646    MissingNamespace,
647}
648
649/// Redacted (IR-41): [`Resolution::Found`] carries a secret's value.
650impl fmt::Debug for Resolution<'_> {
651    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652        f.write_str(match self {
653            Self::Found(_) => "Found(..)",
654            Self::MissingKey => "MissingKey",
655            Self::MissingNamespace => "MissingNamespace",
656        })
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn a_value_round_trips_through_one_environment() {
666        let dir = tempfile::tempdir().unwrap();
667        let path = dir.path().join("secrets.json");
668        set(&path, "DB_PASSWORD", "production", "hunter2").unwrap();
669        assert_eq!(
670            get(&path, "DB_PASSWORD", "production").unwrap().as_deref(),
671            Some("hunter2")
672        );
673        assert_eq!(get(&path, "DB_PASSWORD", "staging").unwrap(), None);
674    }
675
676    #[test]
677    fn a_missing_store_reads_as_empty_rather_than_enoent() {
678        let dir = tempfile::tempdir().unwrap();
679        let path = dir.path().join("secrets.json");
680        assert!(all(&path).unwrap().is_empty());
681        assert_eq!(get(&path, "ANY", "production").unwrap(), None);
682    }
683
684    #[test]
685    fn unset_removes_one_environment_and_leaves_the_others() {
686        let dir = tempfile::tempdir().unwrap();
687        let path = dir.path().join("secrets.json");
688        set(&path, "K", "production", "p").unwrap();
689        set(&path, "K", "staging", "s").unwrap();
690        assert!(unset(&path, "K", "staging").unwrap());
691        assert_eq!(get(&path, "K", "production").unwrap().as_deref(), Some("p"));
692        assert_eq!(get(&path, "K", "staging").unwrap(), None);
693        assert!(!unset(&path, "K", "staging").unwrap(), "already gone");
694    }
695
696    #[test]
697    fn a_key_that_empties_is_removed_rather_than_left_as_an_empty_map() {
698        let dir = tempfile::tempdir().unwrap();
699        let path = dir.path().join("secrets.json");
700        set(&path, "K", "production", "p").unwrap();
701        assert!(unset(&path, "K", "production").unwrap());
702        assert!(all(&path).unwrap().is_empty(), "no empty husk left behind");
703    }
704
705    #[test]
706    fn a_bad_key_is_refused_by_name_and_writes_nothing() {
707        let dir = tempfile::tempdir().unwrap();
708        let path = dir.path().join("secrets.json");
709        for key in ["", ".hidden", "has space", "has/slash", "has:colon"] {
710            let err = set(&path, key, "production", "v").unwrap_err();
711            assert!(
712                matches!(&err, SecretError::InvalidKey(k) if k == key),
713                "{key:?}: {err:?}"
714            );
715        }
716        assert!(!path.exists(), "a refused set must not create the store");
717    }
718
719    #[test]
720    fn the_all_slot_is_writable_like_any_other_environment() {
721        // Writing the `all` slot is how a value covers every environment,
722        // so `set` accepts it. Nothing else about the name is special.
723        let dir = tempfile::tempdir().unwrap();
724        let path = dir.path().join("secrets.json");
725        set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
726        assert_eq!(
727            get(&path, "K", "all").unwrap().as_deref(),
728            Some("everywhere")
729        );
730    }
731
732    #[test]
733    fn get_does_not_fall_back_to_the_all_slot() {
734        // `get` returns the stored slot only; `SecretView::resolve` is the
735        // one place the `all` fallback lives. Nothing here should fail if
736        // that boundary moves, which is exactly the point of pinning it.
737        let dir = tempfile::tempdir().unwrap();
738        let path = dir.path().join("secrets.json");
739        set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
740        assert_eq!(get(&path, "K", "staging").unwrap(), None);
741    }
742
743    #[test]
744    fn an_environment_outside_the_grammar_is_refused() {
745        let dir = tempfile::tempdir().unwrap();
746        let path = dir.path().join("secrets.json");
747        for env in ["", "has space", "has/slash"] {
748            let err = set(&path, "K", env, "v").unwrap_err();
749            assert!(
750                matches!(&err, SecretError::InvalidEnvironment(e) if e == env),
751                "{env:?}: {err:?}"
752            );
753        }
754    }
755
756    #[test]
757    fn an_oversized_value_is_refused_by_length() {
758        let dir = tempfile::tempdir().unwrap();
759        let path = dir.path().join("secrets.json");
760        let big = "x".repeat(MAX_VALUE_BYTES + 1);
761        let err = set(&path, "K", "production", &big).unwrap_err();
762        assert!(matches!(err, SecretError::ValueTooLong { len, .. } if len == big.len()));
763    }
764
765    #[test]
766    fn a_future_version_is_refused_rather_than_overwritten() {
767        let dir = tempfile::tempdir().unwrap();
768        let path = dir.path().join("secrets.json");
769        std::fs::write(&path, r#"{"version":999,"entries":{}}"#).unwrap();
770        assert!(matches!(all(&path), Err(SecretError::FutureVersion(999))));
771        assert!(matches!(
772            set(&path, "K", "production", "v"),
773            Err(SecretError::FutureVersion(999))
774        ));
775        let raw = std::fs::read_to_string(&path).unwrap();
776        assert!(raw.contains("999"), "the refused store is untouched");
777    }
778
779    #[test]
780    #[cfg(unix)]
781    fn the_store_is_owner_only() {
782        use std::os::unix::fs::PermissionsExt as _;
783        let dir = tempfile::tempdir().unwrap();
784        let path = dir.path().join("secrets.json");
785        set(&path, "K", "production", "v").unwrap();
786        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
787        assert_eq!(mode, 0o600);
788    }
789
790    #[test]
791    fn a_reference_parses_with_and_without_a_namespace() {
792        let bare = SecretRef::parse("DB_PASSWORD").unwrap();
793        assert_eq!(bare.namespace, None);
794        assert_eq!(bare.key, "DB_PASSWORD");
795
796        let scoped = SecretRef::parse("vercel/DB_PASSWORD").unwrap();
797        assert_eq!(scoped.namespace, Some("vercel"));
798        assert_eq!(scoped.key, "DB_PASSWORD");
799
800        for bad in ["", "/KEY", "ns/", "a/b/c", "ns/bad key", "bad ns/KEY"] {
801            assert!(SecretRef::parse(bad).is_none(), "{bad:?} must not parse");
802        }
803    }
804
805    #[test]
806    fn references_finds_every_secret_in_a_config_and_nothing_else() {
807        let mut config = AppConfig::minimal("web", "./srv");
808        config.env.insert("A".into(), "{{secret:ONE}}".into());
809        config.env.insert("B".into(), "plain".into());
810        config
811            .env
812            .insert("C".into(), "{{name}}-{{secret:vercel/TWO}}".into());
813        config.args = vec!["--x={{secret:ONE}}".into()];
814
815        let found = references(&config);
816        assert_eq!(
817            found,
818            BTreeSet::from(["ONE".to_string(), "vercel/TWO".to_string()]),
819            "deduplicated, and no positional tokens"
820        );
821    }
822
823    #[test]
824    fn namespaces_of_a_config_is_the_seam_boot_ordering_will_want() {
825        let mut config = AppConfig::minimal("web", "./srv");
826        config.env.insert("A".into(), "{{secret:ONE}}".into());
827        config
828            .env
829            .insert("B".into(), "{{secret:vercel/TWO}}".into());
830        assert_eq!(
831            namespaces_of(&config),
832            BTreeSet::from(["vercel".to_string()])
833        );
834    }
835
836    #[test]
837    fn provider_cache_on_disk_reads_a_real_cache_file() {
838        let dir = tempfile::tempdir().unwrap();
839        let path = dir.path().join("secrets-cache.json");
840        std::fs::write(
841            &path,
842            r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
843        )
844        .unwrap();
845        let cache = provider_cache_on_disk(&path);
846        assert_eq!(cache.values["vercel"]["API_KEY"]["production"], "sk_live");
847        assert_eq!(
848            cache.pushed["vercel"],
849            BTreeSet::from(["production".to_string()])
850        );
851    }
852
853    #[test]
854    fn provider_cache_on_disk_is_empty_for_a_missing_or_broken_file() {
855        let dir = tempfile::tempdir().unwrap();
856        assert!(
857            provider_cache_on_disk(&dir.path().join("absent.json"))
858                .values
859                .is_empty()
860        );
861
862        let broken = dir.path().join("broken.json");
863        std::fs::write(&broken, "not json").unwrap();
864        assert!(provider_cache_on_disk(&broken).values.is_empty());
865
866        let future = dir.path().join("future.json");
867        std::fs::write(&future, r#"{"version":999,"namespaces":{}}"#).unwrap();
868        assert!(provider_cache_on_disk(&future).values.is_empty());
869    }
870
871    #[test]
872    fn resolution_prefers_the_exact_environment_then_all_then_gives_up() {
873        let mut store = BTreeMap::new();
874        store.insert(
875            "K".to_string(),
876            BTreeMap::from([
877                ("production".to_string(), "prod".to_string()),
878                ("all".to_string(), "fallback".to_string()),
879            ]),
880        );
881        store.insert(
882            "ONLY_ALL".to_string(),
883            BTreeMap::from([("all".to_string(), "everywhere".to_string())]),
884        );
885        store.insert(
886            "ONLY_PROD".to_string(),
887            BTreeMap::from([("production".to_string(), "prod".to_string())]),
888        );
889
890        let view = SecretView::new("staging".to_string(), store, ProviderCache::default());
891        assert!(matches!(
892            view.resolve(&SecretRef {
893                namespace: None,
894                key: "K"
895            }),
896            Resolution::Found("fallback")
897        ));
898        assert!(matches!(
899            view.resolve(&SecretRef {
900                namespace: None,
901                key: "ONLY_ALL"
902            }),
903            Resolution::Found("everywhere")
904        ));
905        // The whole point: staging never falls back to production's value.
906        assert!(matches!(
907            view.resolve(&SecretRef {
908                namespace: None,
909                key: "ONLY_PROD"
910            }),
911            Resolution::MissingKey
912        ));
913        assert!(matches!(
914            view.resolve(&SecretRef {
915                namespace: None,
916                key: "ABSENT"
917            }),
918            Resolution::MissingKey
919        ));
920    }
921
922    /// A cache holding `vercel/PRESENT` for `production`, pushed as that
923    /// one pair.
924    fn vercel_production() -> ProviderCache {
925        ProviderCache {
926            values: BTreeMap::from([(
927                "vercel".to_string(),
928                BTreeMap::from([(
929                    "PRESENT".to_string(),
930                    BTreeMap::from([("production".to_string(), "v".to_string())]),
931                )]),
932            )]),
933            pushed: BTreeMap::from([(
934                "vercel".to_string(),
935                BTreeSet::from(["production".to_string()]),
936            )]),
937        }
938    }
939
940    #[test]
941    fn an_unpopulated_namespace_is_told_apart_from_a_missing_key() {
942        let view = SecretView::new(
943            "production".to_string(),
944            BTreeMap::new(),
945            vercel_production(),
946        );
947
948        assert!(matches!(
949            view.resolve(&SecretRef {
950                namespace: Some("vercel"),
951                key: "PRESENT"
952            }),
953            Resolution::Found("v")
954        ));
955        // The dog is up and simply does not have this key: a person's problem.
956        assert!(matches!(
957            view.resolve(&SecretRef {
958                namespace: Some("vercel"),
959                key: "ABSENT"
960            }),
961            Resolution::MissingKey
962        ));
963        // No dog has ever pushed under this name: transient, retry.
964        assert!(matches!(
965            view.resolve(&SecretRef {
966                namespace: Some("vault"),
967                key: "ANY"
968            }),
969            Resolution::MissingNamespace
970        ));
971    }
972
973    /// The case this pair set exists for. A provider pushes `production`
974    /// and then `staging`, which is the ordinary shape, and a staging sheep
975    /// spawning between the two must wait on the restart ladder rather than
976    /// `Errored` for good. Keying on the namespace alone calls the second
977    /// push's keys permanently missing the moment the first push lands.
978    #[test]
979    fn a_namespace_pushed_for_another_environment_is_not_populated_for_this_one() {
980        let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_production());
981
982        assert!(
983            matches!(
984                view.resolve(&SecretRef {
985                    namespace: Some("vercel"),
986                    key: "PRESENT"
987                }),
988                Resolution::MissingNamespace
989            ),
990            "staging has had no push, so waiting is what fixes this"
991        );
992    }
993
994    /// The other half, and the one that must stay permanent: the pair has
995    /// been pushed and the key is not in it, so the provider genuinely does
996    /// not have it and no amount of waiting will produce one.
997    #[test]
998    fn a_pushed_pair_missing_a_key_stays_permanent() {
999        let view = SecretView::new(
1000            "production".to_string(),
1001            BTreeMap::new(),
1002            vercel_production(),
1003        );
1004
1005        assert!(matches!(
1006            view.resolve(&SecretRef {
1007                namespace: Some("vercel"),
1008                key: "ABSENT"
1009            }),
1010            Resolution::MissingKey
1011        ));
1012    }
1013
1014    /// A cache holding `vercel/PRESENT` for [`ALL_ENVIRONMENTS`], pushed as
1015    /// that one pair and no other.
1016    fn vercel_all() -> ProviderCache {
1017        ProviderCache {
1018            values: BTreeMap::from([(
1019                "vercel".to_string(),
1020                BTreeMap::from([(
1021                    "PRESENT".to_string(),
1022                    BTreeMap::from([(ALL_ENVIRONMENTS.to_string(), "v".to_string())]),
1023                )]),
1024            )]),
1025            pushed: BTreeMap::from([(
1026                "vercel".to_string(),
1027                BTreeSet::from([ALL_ENVIRONMENTS.to_string()]),
1028            )]),
1029        }
1030    }
1031
1032    /// A push to [`ALL_ENVIRONMENTS`] populates the namespace for every
1033    /// environment, not only the literal string `"all"`, so a key that push
1034    /// genuinely lacks is permanent for a `staging` view exactly as it
1035    /// would be for a `production` one.
1036    #[test]
1037    fn an_all_slot_push_makes_a_genuinely_missing_key_permanent() {
1038        let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1039
1040        assert!(matches!(
1041            view.resolve(&SecretRef {
1042                namespace: Some("vercel"),
1043                key: "ABSENT"
1044            }),
1045            Resolution::MissingKey
1046        ));
1047    }
1048
1049    /// The value half of the same all-only push, from a pair-aware view:
1050    /// the namespace resolves the key it does carry for an environment that
1051    /// never received its own push.
1052    #[test]
1053    fn an_all_slot_push_resolves_its_key_for_every_environment() {
1054        let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
1055
1056        assert!(matches!(
1057            view.resolve(&SecretRef {
1058                namespace: Some("vercel"),
1059                key: "PRESENT"
1060            }),
1061            Resolution::Found("v")
1062        ));
1063    }
1064
1065    /// An empty push is a dog saying it holds nothing for that pair, which
1066    /// is an answer. Deriving the pairs from the values would lose it and
1067    /// leave such a sheep retrying against a dog that has already spoken.
1068    #[test]
1069    fn an_empty_push_populates_the_pair_it_carried() {
1070        let view = SecretView::new(
1071            "production".to_string(),
1072            BTreeMap::new(),
1073            ProviderCache {
1074                values: BTreeMap::from([("vercel".to_string(), BTreeMap::new())]),
1075                pushed: BTreeMap::from([(
1076                    "vercel".to_string(),
1077                    BTreeSet::from(["production".to_string()]),
1078                )]),
1079            },
1080        );
1081
1082        assert!(matches!(
1083            view.resolve(&SecretRef {
1084                namespace: Some("vercel"),
1085                key: "ANY"
1086            }),
1087            Resolution::MissingKey
1088        ));
1089    }
1090
1091    #[test]
1092    fn a_reference_displays_the_way_an_operator_wrote_it() {
1093        assert_eq!(
1094            SecretRef {
1095                namespace: None,
1096                key: "K"
1097            }
1098            .to_string(),
1099            "{{secret:K}}"
1100        );
1101        assert_eq!(
1102            SecretRef {
1103                namespace: Some("vercel"),
1104                key: "K"
1105            }
1106            .to_string(),
1107            "{{secret:vercel/K}}"
1108        );
1109    }
1110
1111    /// IR-41. Fails the moment somebody replaces the hand-written impl with
1112    /// a derive, which is the only way this leak comes back.
1113    #[test]
1114    fn debug_never_prints_a_value() {
1115        let store = BTreeMap::from([(
1116            "K".to_string(),
1117            BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1118        )]);
1119        let view = SecretView::new("production".to_string(), store, ProviderCache::default());
1120        let rendered = format!("{view:?}");
1121        assert_eq!(
1122            rendered,
1123            "SecretView { environment: \"production\", keys: 1, namespaces: 0 }"
1124        );
1125        assert!(!rendered.contains("hunter2"));
1126    }
1127
1128    /// IR-41, the same guard for the on-disk shape everything else is built
1129    /// from. `SecretFile` is private, so `missing_debug_implementations`
1130    /// never forces it to keep a `Debug` impl at all; this is what stops a
1131    /// later edit from deriving one over the hand-written redaction.
1132    #[test]
1133    fn a_secret_file_debug_never_prints_a_value() {
1134        let file = SecretFile {
1135            version: SECRETS_VERSION,
1136            entries: BTreeMap::from([(
1137                "K".to_string(),
1138                BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
1139            )]),
1140        };
1141        let rendered = format!("{file:?}");
1142        assert_eq!(rendered, "SecretFile { version: 1, keys: 1 }");
1143        assert!(!rendered.contains("hunter2"));
1144    }
1145
1146    /// IR-41, the same guard for the on-disk shape [`provider_cache_on_disk`]
1147    /// reads: it mirrors `shep-daemon`'s `CacheFile`, values in the clear
1148    /// included.
1149    #[test]
1150    fn a_provider_cache_file_debug_never_prints_a_value() {
1151        let file = ProviderCacheFile {
1152            version: PROVIDER_CACHE_VERSION,
1153            namespaces: BTreeMap::from([(
1154                "vercel".to_string(),
1155                BTreeMap::from([(
1156                    "API_KEY".to_string(),
1157                    BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
1158                )]),
1159            )]),
1160            pushed: BTreeMap::from([(
1161                "vercel".to_string(),
1162                BTreeSet::from(["production".to_string()]),
1163            )]),
1164        };
1165        let rendered = format!("{file:?}");
1166        assert_eq!(
1167            rendered,
1168            "ProviderCacheFile { version: 2, namespaces: 1, pushed: 1 }"
1169        );
1170        assert!(!rendered.contains("sk_live"));
1171    }
1172
1173    /// IR-41 for the type the two halves travel in together.
1174    #[test]
1175    fn a_provider_cache_debug_never_prints_a_value() {
1176        let cache = vercel_production();
1177        assert_eq!(
1178            format!("{cache:?}"),
1179            "ProviderCache { namespaces: 1, pushed: 1 }"
1180        );
1181    }
1182
1183    /// IR-41, the same guard for the type a resolved value travels in.
1184    #[test]
1185    fn a_resolution_debug_never_prints_the_value_it_found() {
1186        assert_eq!(format!("{:?}", Resolution::Found("hunter2")), "Found(..)");
1187        assert_eq!(format!("{:?}", Resolution::MissingKey), "MissingKey");
1188        assert_eq!(
1189            format!("{:?}", Resolution::MissingNamespace),
1190            "MissingNamespace"
1191        );
1192    }
1193
1194    /// Exact strings for both renderings (IR-41): every variant is meant to
1195    /// carry a name and never a value, and a substring check cannot see a
1196    /// field it was never told to look for.
1197    #[test]
1198    fn error_messages_name_the_key_and_never_a_value() {
1199        let too_long = SecretError::ValueTooLong {
1200            key: "K".to_string(),
1201            len: 9999,
1202        };
1203        assert_eq!(
1204            too_long.to_string(),
1205            format!("value for `K` is 9999 bytes, over the {MAX_VALUE_BYTES}-byte limit")
1206        );
1207        assert_eq!(
1208            format!("{too_long:?}"),
1209            "ValueTooLong { key: \"K\", len: 9999 }"
1210        );
1211
1212        let bad_key = SecretError::InvalidKey("has space".to_string());
1213        assert_eq!(bad_key.to_string(), "`has space` is not a valid secret key");
1214        assert_eq!(format!("{bad_key:?}"), "InvalidKey(\"has space\")");
1215
1216        for rendered in [too_long.to_string(), bad_key.to_string()] {
1217            assert!(
1218                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1219                "no em or en dash in copy a user reads: {rendered}"
1220            );
1221        }
1222    }
1223}