Skip to main content

ignition_core/config/
secret.rs

1//! Secrets: the `Secret` newtype (type-level redaction), the `SecretStore`
2//! seam, and env-first resolution (research Pattern 3 — the STATE.md keyring
3//! blocker resolution).
4//!
5//! LOCKED resolution order (CORE-02 must-have):
6//! `IGNITION_TOKEN_<PROFILE>` → profile `token_env` name → `IGNITION_TOKEN`
7//! → keyring entry → `IGNITION_USER`+`IGNITION_PASSWORD`.
8//! The order lives in the STORE CHAIN (see the unit tests and, from 01-04,
9//! the dispatch construction site) — which is why the basic env pair is a
10//! separate [`BasicEnvStore`] placed AFTER [`KeyringStore`]: env tokens
11//! first, keyring second, basic env last, exactly as locked.
12//!
13//! The env-first order means default CI and tests never need a secret
14//! service at all; `KeyringStore` fails soft (warn + skip) wherever no OS
15//! keyring exists (headless Linux without D-Bus — keyring-rs fails fast at
16//! `Entry::new`, never hangs).
17
18use crate::config::AuthRef;
19use crate::error::CoreError;
20
21/// A value that must never render. NO `Serialize` impl exists on purpose
22/// (type-level redaction: it cannot appear in JSON output); `Debug`/`Display`
23/// render `***` so tracing logs are safe by construction (CORE-02).
24#[derive(Clone)]
25pub struct Secret(String);
26
27impl std::fmt::Debug for Secret {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.write_str("Secret(***)")
30    }
31}
32
33impl std::fmt::Display for Secret {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.write_str("***")
36    }
37}
38
39impl Secret {
40    pub fn new(value: impl Into<String>) -> Self {
41        Self(value.into())
42    }
43
44    /// The ONLY way to read the value — grep-auditable: the single future
45    /// read site is the reqwest header construction in 01-04.
46    pub fn expose(&self) -> &str {
47        &self.0
48    }
49}
50
51/// A resolved credential: token OR basic pair — never both (that rule is
52/// enforced at the header-construction site in 01-04).
53#[derive(Debug, Clone)]
54pub enum Credential {
55    /// Bearer-style API token.
56    Token(Secret),
57    /// Basic-auth user/password pair.
58    Basic(Secret, Secret),
59}
60
61/// One place to look for a credential. `Ok(None)` = not found here, try the
62/// next store; `Err` = found-but-unreadable (surface with hint).
63pub trait SecretStore: Send + Sync {
64    fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError>;
65}
66
67fn env_var(name: &str) -> Option<String> {
68    std::env::var(name).ok().filter(|value| !value.is_empty())
69}
70
71/// Token env lookups: `IGNITION_TOKEN_<PROFILE_UP>` (profile uppercased,
72/// non-alphanumeric → `_`) → the profile's `token_env` var name → generic
73/// `IGNITION_TOKEN`.
74pub struct EnvStore;
75
76impl SecretStore for EnvStore {
77    fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
78        let specific = format!("IGNITION_TOKEN_{}", profile_env_suffix(profile));
79        if let Some(token) = env_var(&specific) {
80            return Ok(Some(Credential::Token(Secret::new(token))));
81        }
82        if let AuthRef::TokenEnv { token_env } = auth
83            && let Some(token) = env_var(token_env)
84        {
85            return Ok(Some(Credential::Token(Secret::new(token))));
86        }
87        if let Some(token) = env_var("IGNITION_TOKEN") {
88            return Ok(Some(Credential::Token(Secret::new(token))));
89        }
90        Ok(None)
91    }
92}
93
94/// Profile name → env-var-safe uppercase suffix: non-alphanumeric
95/// characters become `_` (`my-rig` → `MY_RIG`).
96fn profile_env_suffix(profile: &str) -> String {
97    profile
98        .chars()
99        .map(|c| {
100            if c.is_ascii_alphanumeric() {
101                c.to_ascii_uppercase()
102            } else {
103                '_'
104            }
105        })
106        .collect()
107}
108
109/// `IGNITION_USER` + `IGNITION_PASSWORD` basic pair — LAST in the LOCKED
110/// order (after keyring), which is why it is a separate store from
111/// [`EnvStore`]: the chain, not the struct, encodes the order.
112pub struct BasicEnvStore;
113
114impl SecretStore for BasicEnvStore {
115    fn resolve(&self, _profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
116        match (env_var("IGNITION_USER"), env_var("IGNITION_PASSWORD")) {
117            (Some(user), Some(password)) => Ok(Some(Credential::Basic(
118                Secret::new(user),
119                Secret::new(password),
120            ))),
121            _ => Ok(None),
122        }
123    }
124}
125
126/// OS keyring lookup: service `ignition-cli`, user `profile:<name>`.
127///
128/// ANY `Entry::new` failure → `tracing::debug!` + `Ok(None)` (store
129/// unavailable — headless Linux without D-Bus is an EXPECTED condition,
130/// not an anomaly: skip, never fatal, never hang; keyring-rs fails fast
131/// at construction. debug, not warn, so headless hosts don't get a
132/// non-JSON log line on stderr ahead of every JSON error envelope).
133/// A live entry that exists but cannot be read surfaces as `Err`
134/// (found-but-unreadable — THAT warns).
135pub struct KeyringStore;
136
137/// Keyring coordinates for a profile: fixed service, `profile:<name>` user.
138fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
139    keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
140}
141
142impl SecretStore for KeyringStore {
143    fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
144        let entry = match keyring_entry(profile) {
145            Ok(entry) => entry,
146            Err(err) => {
147                tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
148                return Ok(None);
149            }
150        };
151        match entry.get_password() {
152            Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
153            Err(keyring::Error::NoEntry) => Ok(None),
154            Err(err) => {
155                tracing::warn!(error = %err, profile, "keyring entry unreadable");
156                Err(CoreError::SecretUnavailable {
157                    profile: profile.to_string(),
158                })
159            }
160        }
161    }
162}
163
164impl KeyringStore {
165    /// Store a token for `profile` in the OS keyring. Unlike [`SecretStore::resolve`],
166    /// a SET failure is an error (writing requires a working store) — used by
167    /// future `profile add --keyring` flows and the keyring smoke test.
168    pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
169        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
170            profile: profile.to_string(),
171        })?;
172        entry
173            .set_password(secret.expose())
174            .map_err(|_| CoreError::SecretUnavailable {
175                profile: profile.to_string(),
176            })
177    }
178
179    /// Delete the keyring entry for `profile`; a missing entry is success
180    /// (idempotent).
181    pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
182        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
183            profile: profile.to_string(),
184        })?;
185        match entry.delete_credential() {
186            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
187            Err(_) => Err(CoreError::SecretUnavailable {
188                profile: profile.to_string(),
189            }),
190        }
191    }
192}
193
194/// First store to yield a credential wins; exhausted →
195/// [`CoreError::SecretUnavailable`] (exit 3) whose hint names the env-var
196/// path — the supported headless route.
197pub fn resolve_secret(
198    profile: &str,
199    auth: &AuthRef,
200    stores: &[Box<dyn SecretStore>],
201) -> Result<Credential, CoreError> {
202    for store in stores {
203        match store.resolve(profile, auth)? {
204            Some(credential) => return Ok(credential),
205            None => continue,
206        }
207    }
208    Err(CoreError::SecretUnavailable {
209        profile: profile.to_string(),
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use super::{
216        BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
217    };
218    use crate::config::AuthRef;
219    use crate::config::ENV_LOCK;
220    use crate::error::CoreError;
221
222    /// Test double for chain-order tests: yields a fixed answer.
223    struct FixedStore(Result<Option<Credential>, ()>);
224    impl SecretStore for FixedStore {
225        fn resolve(
226            &self,
227            _profile: &str,
228            _auth: &AuthRef,
229        ) -> Result<Option<Credential>, CoreError> {
230            self.0.clone().map_err(|()| CoreError::SecretUnavailable {
231                profile: "fixed".into(),
232            })
233        }
234    }
235
236    /// CORE-02 type-level redaction: Debug/Display never leak the value.
237    #[test]
238    fn secret_renders_redacted() {
239        let secret = Secret::new("CANARY-t0k3n");
240        assert_eq!(format!("{secret:?}"), "Secret(***)");
241        assert_eq!(format!("{secret}"), "***");
242        assert_eq!(
243            secret.expose(),
244            "CANARY-t0k3n",
245            "expose is the only read path"
246        );
247    }
248
249    /// Order step 1: `IGNITION_TOKEN_<PROFILE_UP>` beats both the
250    /// auth-ref var and the generic token.
251    #[test]
252    fn env_store_profile_specific_token_wins() {
253        let _lock = ENV_LOCK.lock().expect("env lock");
254        // SAFETY: single-threaded under ENV_LOCK; removed before return.
255        unsafe {
256            std::env::set_var("IGNITION_TOKEN_DEV", "specific");
257            std::env::set_var("IGNITION_TOKEN", "generic");
258        }
259        let auth = AuthRef::TokenEnv {
260            token_env: "MY_TOKEN".into(),
261        };
262        let credential = EnvStore
263            .resolve("dev", &auth)
264            .expect("resolve")
265            .expect("some");
266        let Credential::Token(token) = credential else {
267            panic!("expected token credential");
268        };
269        assert_eq!(token.expose(), "specific");
270        // SAFETY: single-threaded under ENV_LOCK.
271        unsafe {
272            std::env::remove_var("IGNITION_TOKEN_DEV");
273            std::env::remove_var("IGNITION_TOKEN");
274        }
275    }
276
277    /// Order step 2: the profile's `token_env` var beats the generic one;
278    /// non-alphanumeric profile chars map to `_` in the specific var name.
279    #[test]
280    fn env_store_token_env_ref_and_suffix_mapping() {
281        let _lock = ENV_LOCK.lock().expect("env lock");
282        // SAFETY: single-threaded under ENV_LOCK; removed before return.
283        unsafe {
284            std::env::set_var("MY_TOKEN", "from-ref");
285            std::env::set_var("IGNITION_TOKEN", "generic");
286            std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
287        }
288
289        let auth = AuthRef::TokenEnv {
290            token_env: "MY_TOKEN".into(),
291        };
292        let credential = EnvStore
293            .resolve("dev", &auth)
294            .expect("resolve")
295            .expect("some");
296        let Credential::Token(token) = credential else {
297            panic!("expected token credential");
298        };
299        assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
300
301        let credential = EnvStore
302            .resolve("my-rig", &auth)
303            .expect("resolve")
304            .expect("some");
305        let Credential::Token(token) = credential else {
306            panic!("expected token credential");
307        };
308        assert_eq!(
309            token.expose(),
310            "rig-specific",
311            "hyphen maps to _ then uppercases"
312        );
313
314        // SAFETY: single-threaded under ENV_LOCK.
315        unsafe {
316            std::env::remove_var("MY_TOKEN");
317            std::env::remove_var("IGNITION_TOKEN");
318            std::env::remove_var("IGNITION_TOKEN_MY_RIG");
319        }
320    }
321
322    /// Order step 5: the basic env pair needs BOTH vars; a lone user is not
323    /// a credential.
324    #[test]
325    fn basic_env_store_requires_both_vars() {
326        let _lock = ENV_LOCK.lock().expect("env lock");
327        // SAFETY: single-threaded under ENV_LOCK; removed before return.
328        unsafe {
329            std::env::set_var("IGNITION_USER", "admin");
330            std::env::remove_var("IGNITION_PASSWORD");
331        }
332        assert!(
333            BasicEnvStore
334                .resolve("dev", &AuthRef::default())
335                .expect("resolve")
336                .is_none()
337        );
338
339        // SAFETY: single-threaded under ENV_LOCK.
340        unsafe {
341            std::env::set_var("IGNITION_PASSWORD", "pw");
342        }
343        let credential = BasicEnvStore
344            .resolve("dev", &AuthRef::default())
345            .expect("resolve")
346            .expect("some with both vars");
347        let Credential::Basic(user, password) = credential else {
348            panic!("expected basic credential");
349        };
350        assert_eq!(user.expose(), "admin");
351        assert_eq!(password.expose(), "pw");
352
353        // SAFETY: single-threaded under ENV_LOCK.
354        unsafe {
355            std::env::remove_var("IGNITION_USER");
356            std::env::remove_var("IGNITION_PASSWORD");
357        }
358    }
359
360    /// The LOCKED chain order end-to-end (env tokens → keyring-shaped store
361    /// → basic env) via test doubles, plus first-Some-wins and exhaustion.
362    #[test]
363    fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
364        let _lock = ENV_LOCK.lock().expect("env lock");
365        // SAFETY: single-threaded under ENV_LOCK; removed before return.
366        unsafe {
367            std::env::set_var("IGNITION_TOKEN", "env-token");
368            std::env::set_var("IGNITION_USER", "admin");
369            std::env::set_var("IGNITION_PASSWORD", "pw");
370        }
371        let auth = AuthRef::default();
372
373        // A store shaped like a populated keyring sits BETWEEN EnvStore and
374        // BasicEnvStore in the chain; the env token must still win (env-first).
375        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
376        let chain: Vec<Box<dyn SecretStore>> = vec![
377            Box::new(EnvStore),
378            Box::new(keyring_like),
379            Box::new(BasicEnvStore),
380        ];
381        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
382        let Credential::Token(token) = credential else {
383            panic!("expected token credential");
384        };
385        assert_eq!(token.expose(), "env-token");
386
387        // Keyring-shaped store wins over basic env (order: keyring before USER/PASSWORD).
388        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
389        let chain: Vec<Box<dyn SecretStore>> = vec![
390            Box::new(EnvStore),
391            Box::new(keyring_like),
392            Box::new(BasicEnvStore),
393        ];
394        // SAFETY: single-threaded under ENV_LOCK.
395        unsafe { std::env::remove_var("IGNITION_TOKEN") };
396        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
397        let Credential::Token(token) = credential else {
398            panic!("expected token credential");
399        };
400        assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
401
402        // Basic env is the last resort.
403        let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
404        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
405        let Credential::Basic(user, _) = credential else {
406            panic!("expected basic credential");
407        };
408        assert_eq!(user.expose(), "admin");
409
410        // Exhausted → SecretUnavailable (exit 3) with the env-first hint.
411        // SAFETY: single-threaded under ENV_LOCK.
412        unsafe {
413            std::env::remove_var("IGNITION_USER");
414            std::env::remove_var("IGNITION_PASSWORD");
415        }
416        let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
417        assert!(matches!(err, CoreError::SecretUnavailable { .. }));
418        assert_eq!(err.exit_code(), 3);
419        assert!(
420            err.hint().expect("hint").contains("IGNITION_TOKEN"),
421            "hint names the env path: {}",
422            err.hint().unwrap(),
423        );
424    }
425
426    /// `KeyringStore` trait-level resolve is exercised ONLY by the
427    /// `#[ignore]`-gated smoke test (Pitfall 8: unit tests never touch a
428    /// real keychain). This test merely pins that the type exists at the
429    /// chain type level without calling into the OS.
430    #[test]
431    fn keyring_store_is_constructible_without_side_effects() {
432        let _store = KeyringStore;
433    }
434}