Skip to main content

shep_core/secrets/
error.rs

1use super::format::MAX_VALUE_BYTES;
2use core::fmt;
3
4/// Error type returned by this module.
5///
6/// `#[non_exhaustive]`: shep-core is published, so a new failure variant
7/// must not break an out-of-tree `match`.
8///
9/// Wraps `io::Error`/`serde_json::Error` directly rather than stringifying
10/// them, matching [`crate::kv::KvError`], so callers keep the underlying
11/// diagnostic through [`core::error::Error::source`]; this type does not
12/// derive `Clone`/`PartialEq`/`Eq` as a result.
13///
14/// No variant carries a secret's value: a message names the key, the
15/// namespace or the environment, and nothing else.
16#[non_exhaustive]
17#[derive(Debug)]
18pub enum SecretError {
19    /// The store could not be read, written, or replaced.
20    Io(std::io::Error),
21    /// The store's JSON could not be parsed.
22    ///
23    /// Refused rather than repaired: a partial read would silently drop
24    /// credentials still on disk, and a later write would erase them.
25    Decode(serde_json::Error),
26    /// A key outside the grammar; carries it verbatim so the message can
27    /// quote what was typed.
28    InvalidKey(String),
29    /// An environment name outside the grammar; carries it verbatim.
30    InvalidEnvironment(String),
31    /// A value over [`MAX_VALUE_BYTES`].
32    ValueTooLong {
33        /// The key it was being stored under.
34        key: String,
35        /// Its length in bytes.
36        len: usize,
37    },
38    /// The store on disk is a version this build does not understand; carries
39    /// that version. Nothing was written.
40    FutureVersion(u32),
41}
42
43impl fmt::Display for SecretError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::Io(err) => write!(f, "secret store I/O failed: {err}"),
47            Self::Decode(err) => write!(f, "secret store failed to parse: {err}"),
48            Self::InvalidKey(key) => write!(f, "`{key}` is not a valid secret key"),
49            Self::InvalidEnvironment(environment) => {
50                write!(f, "`{environment}` is not a valid environment name")
51            }
52            Self::ValueTooLong { key, len } => write!(
53                f,
54                "value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
55            ),
56            Self::FutureVersion(version) => write!(
57                f,
58                "secret store is version {version}, newer than this build understands"
59            ),
60        }
61    }
62}
63
64impl core::error::Error for SecretError {
65    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
66        match self {
67            Self::Io(err) => Some(err),
68            Self::Decode(err) => Some(err),
69            Self::InvalidKey(_)
70            | Self::InvalidEnvironment(_)
71            | Self::ValueTooLong { .. }
72            | Self::FutureVersion(_) => None,
73        }
74    }
75}
76
77impl From<std::io::Error> for SecretError {
78    fn from(source: std::io::Error) -> Self {
79        Self::Io(source)
80    }
81}
82
83impl From<serde_json::Error> for SecretError {
84    fn from(source: serde_json::Error) -> Self {
85        Self::Decode(source)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::super::format::{MAX_VALUE_BYTES, SECRETS_VERSION, SecretFile};
92    use super::super::provider::{PROVIDER_CACHE_VERSION, ProviderCacheFile};
93    use super::super::refs::sealed_keys;
94
95    use std::collections::{BTreeMap, BTreeSet};
96
97    use crate::config::AppConfig;
98
99    use super::*;
100
101    /// A value that only embeds a reference still comes from the store.
102    #[test]
103    fn an_embedded_reference_seals_its_key() {
104        let mut config = AppConfig::minimal("web", "./srv");
105        config.env.insert(
106            "DB_URL".into(),
107            "postgres://u:{{secret:pg/PASSWORD}}@h".into(),
108        );
109        assert_eq!(sealed_keys(&config), vec!["DB_URL".to_string()]);
110    }
111
112    /// A positional token is not a secret: `{{name}}` and `{{instance}}` are
113    /// filled from the sheep, not from the store.
114    #[test]
115    fn a_positional_token_does_not_seal_a_key() {
116        let mut config = AppConfig::minimal("web", "./srv");
117        config.env.insert("LOG".into(), "{{name}}.log".into());
118        assert!(sealed_keys(&config).is_empty());
119    }
120
121    /// IR-41, the same guard for the on-disk shape everything else is built
122    /// from. `SecretFile` is private, so `missing_debug_implementations`
123    /// never forces it to keep a `Debug` impl at all; this is what stops a
124    /// later edit from deriving one over the hand-written redaction.
125    #[test]
126    fn a_secret_file_debug_never_prints_a_value() {
127        let file = SecretFile {
128            version: SECRETS_VERSION,
129            entries: BTreeMap::from([(
130                "K".to_string(),
131                BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
132            )]),
133        };
134        let rendered = format!("{file:?}");
135        assert_eq!(rendered, "SecretFile { version: 1, keys: 1 }");
136        assert!(!rendered.contains("hunter2"));
137    }
138
139    /// IR-41, the same guard for the on-disk shape [`provider_cache_on_disk`]
140    /// reads: it mirrors `shep-daemon`'s `CacheFile`, values in the clear
141    /// included.
142    #[test]
143    fn a_provider_cache_file_debug_never_prints_a_value() {
144        let file = ProviderCacheFile {
145            version: PROVIDER_CACHE_VERSION,
146            namespaces: BTreeMap::from([(
147                "vercel".to_string(),
148                BTreeMap::from([(
149                    "API_KEY".to_string(),
150                    BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
151                )]),
152            )]),
153            pushed: BTreeMap::from([(
154                "vercel".to_string(),
155                BTreeSet::from(["production".to_string()]),
156            )]),
157        };
158        let rendered = format!("{file:?}");
159        assert_eq!(
160            rendered,
161            "ProviderCacheFile { version: 2, namespaces: 1, pushed: 1 }"
162        );
163        assert!(!rendered.contains("sk_live"));
164    }
165
166    /// Exact strings for both renderings (IR-41): every variant is meant to
167    /// carry a name and never a value, and a substring check cannot see a
168    /// field it was never told to look for.
169    #[test]
170    fn error_messages_name_the_key_and_never_a_value() {
171        let too_long = SecretError::ValueTooLong {
172            key: "K".to_string(),
173            len: 9999,
174        };
175        assert_eq!(
176            too_long.to_string(),
177            format!("value for `K` is 9999 bytes, over the {MAX_VALUE_BYTES}-byte limit")
178        );
179        assert_eq!(
180            format!("{too_long:?}"),
181            "ValueTooLong { key: \"K\", len: 9999 }"
182        );
183
184        let bad_key = SecretError::InvalidKey("has space".to_string());
185        assert_eq!(bad_key.to_string(), "`has space` is not a valid secret key");
186        assert_eq!(format!("{bad_key:?}"), "InvalidKey(\"has space\")");
187
188        for rendered in [too_long.to_string(), bad_key.to_string()] {
189            assert!(
190                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
191                "no em or en dash in copy a user reads: {rendered}"
192            );
193        }
194    }
195}