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