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`). `pub(crate)` since
96/// ADOPT-02: the adopt action names the fallback env var with the
97/// SAME rule (one home — never restated).
98pub(crate) fn profile_env_suffix(profile: &str) -> String {
99    profile
100        .chars()
101        .map(|c| {
102            if c.is_ascii_alphanumeric() {
103                c.to_ascii_uppercase()
104            } else {
105                '_'
106            }
107        })
108        .collect()
109}
110
111/// `IGNITION_USER` + `IGNITION_PASSWORD` basic pair — LAST in the LOCKED
112/// order (after keyring), which is why it is a separate store from
113/// [`EnvStore`]: the chain, not the struct, encodes the order.
114pub struct BasicEnvStore;
115
116impl SecretStore for BasicEnvStore {
117    fn resolve(&self, _profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
118        match (env_var("IGNITION_USER"), env_var("IGNITION_PASSWORD")) {
119            (Some(user), Some(password)) => Ok(Some(Credential::Basic(
120                Secret::new(user),
121                Secret::new(password),
122            ))),
123            _ => Ok(None),
124        }
125    }
126}
127
128/// OS keyring lookup: service `ignition-cli`, user `profile:<name>`.
129///
130/// ANY `Entry::new` failure → `tracing::debug!` + `Ok(None)` (store
131/// unavailable — headless Linux without D-Bus is an EXPECTED condition,
132/// not an anomaly: skip, never fatal, never hang; keyring-rs fails fast
133/// at construction. debug, not warn, so headless hosts don't get a
134/// non-JSON log line on stderr ahead of every JSON error envelope).
135/// A live entry that exists but cannot be read surfaces as `Err`
136/// (found-but-unreadable — THAT warns).
137pub struct KeyringStore;
138
139/// Keyring coordinates for a profile: fixed service, `profile:<name>` user.
140fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
141    keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
142}
143
144impl SecretStore for KeyringStore {
145    fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
146        let entry = match keyring_entry(profile) {
147            Ok(entry) => entry,
148            Err(err) => {
149                tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
150                return Ok(None);
151            }
152        };
153        match entry.get_password() {
154            Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
155            Err(keyring::Error::NoEntry) => Ok(None),
156            Err(err) => {
157                tracing::warn!(error = %err, profile, "keyring entry unreadable");
158                Err(CoreError::SecretUnavailable {
159                    profile: profile.to_string(),
160                })
161            }
162        }
163    }
164}
165
166impl KeyringStore {
167    /// Store a token for `profile` in the OS keyring. Unlike [`SecretStore::resolve`],
168    /// a SET failure is an error (writing requires a working store) — used by
169    /// future `profile add --keyring` flows and the keyring smoke test.
170    pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
171        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
172            profile: profile.to_string(),
173        })?;
174        entry
175            .set_password(secret.expose())
176            .map_err(|_| CoreError::SecretUnavailable {
177                profile: profile.to_string(),
178            })
179    }
180
181    /// Delete the keyring entry for `profile`; a missing entry is success
182    /// (idempotent).
183    pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
184        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
185            profile: profile.to_string(),
186        })?;
187        match entry.delete_credential() {
188            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
189            Err(_) => Err(CoreError::SecretUnavailable {
190                profile: profile.to_string(),
191            }),
192        }
193    }
194}
195
196/// First store to yield a credential wins; exhausted →
197/// [`CoreError::SecretUnavailable`] (exit 3) whose hint names the env-var
198/// path — the supported headless route.
199pub fn resolve_secret(
200    profile: &str,
201    auth: &AuthRef,
202    stores: &[Box<dyn SecretStore>],
203) -> Result<Credential, CoreError> {
204    for store in stores {
205        match store.resolve(profile, auth)? {
206            Some(credential) => return Ok(credential),
207            None => continue,
208        }
209    }
210    Err(CoreError::SecretUnavailable {
211        profile: profile.to_string(),
212    })
213}
214
215#[cfg(test)]
216mod tests {
217    use super::{
218        BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
219    };
220    use crate::config::AuthRef;
221    use crate::config::ENV_LOCK;
222    use crate::error::CoreError;
223
224    /// Test double for chain-order tests: yields a fixed answer.
225    struct FixedStore(Result<Option<Credential>, ()>);
226    impl SecretStore for FixedStore {
227        fn resolve(
228            &self,
229            _profile: &str,
230            _auth: &AuthRef,
231        ) -> Result<Option<Credential>, CoreError> {
232            self.0.clone().map_err(|()| CoreError::SecretUnavailable {
233                profile: "fixed".into(),
234            })
235        }
236    }
237
238    /// CORE-02 type-level redaction: Debug/Display never leak the value.
239    #[test]
240    fn secret_renders_redacted() {
241        let secret = Secret::new("CANARY-t0k3n");
242        assert_eq!(format!("{secret:?}"), "Secret(***)");
243        assert_eq!(format!("{secret}"), "***");
244        assert_eq!(
245            secret.expose(),
246            "CANARY-t0k3n",
247            "expose is the only read path"
248        );
249    }
250
251    /// Order step 1: `IGNITION_TOKEN_<PROFILE_UP>` beats both the
252    /// auth-ref var and the generic token.
253    #[test]
254    fn env_store_profile_specific_token_wins() {
255        let _lock = ENV_LOCK.lock().expect("env lock");
256        // SAFETY: single-threaded under ENV_LOCK; removed before return.
257        unsafe {
258            std::env::set_var("IGNITION_TOKEN_DEV", "specific");
259            std::env::set_var("IGNITION_TOKEN", "generic");
260        }
261        let auth = AuthRef::TokenEnv {
262            token_env: "MY_TOKEN".into(),
263        };
264        let credential = EnvStore
265            .resolve("dev", &auth)
266            .expect("resolve")
267            .expect("some");
268        let Credential::Token(token) = credential else {
269            panic!("expected token credential");
270        };
271        assert_eq!(token.expose(), "specific");
272        // SAFETY: single-threaded under ENV_LOCK.
273        unsafe {
274            std::env::remove_var("IGNITION_TOKEN_DEV");
275            std::env::remove_var("IGNITION_TOKEN");
276        }
277    }
278
279    /// Order step 2: the profile's `token_env` var beats the generic one;
280    /// non-alphanumeric profile chars map to `_` in the specific var name.
281    #[test]
282    fn env_store_token_env_ref_and_suffix_mapping() {
283        let _lock = ENV_LOCK.lock().expect("env lock");
284        // SAFETY: single-threaded under ENV_LOCK; removed before return.
285        unsafe {
286            std::env::set_var("MY_TOKEN", "from-ref");
287            std::env::set_var("IGNITION_TOKEN", "generic");
288            std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
289        }
290
291        let auth = AuthRef::TokenEnv {
292            token_env: "MY_TOKEN".into(),
293        };
294        let credential = EnvStore
295            .resolve("dev", &auth)
296            .expect("resolve")
297            .expect("some");
298        let Credential::Token(token) = credential else {
299            panic!("expected token credential");
300        };
301        assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
302
303        let credential = EnvStore
304            .resolve("my-rig", &auth)
305            .expect("resolve")
306            .expect("some");
307        let Credential::Token(token) = credential else {
308            panic!("expected token credential");
309        };
310        assert_eq!(
311            token.expose(),
312            "rig-specific",
313            "hyphen maps to _ then uppercases"
314        );
315
316        // SAFETY: single-threaded under ENV_LOCK.
317        unsafe {
318            std::env::remove_var("MY_TOKEN");
319            std::env::remove_var("IGNITION_TOKEN");
320            std::env::remove_var("IGNITION_TOKEN_MY_RIG");
321        }
322    }
323
324    /// Order step 5: the basic env pair needs BOTH vars; a lone user is not
325    /// a credential.
326    #[test]
327    fn basic_env_store_requires_both_vars() {
328        let _lock = ENV_LOCK.lock().expect("env lock");
329        // SAFETY: single-threaded under ENV_LOCK; removed before return.
330        unsafe {
331            std::env::set_var("IGNITION_USER", "admin");
332            std::env::remove_var("IGNITION_PASSWORD");
333        }
334        assert!(
335            BasicEnvStore
336                .resolve("dev", &AuthRef::default())
337                .expect("resolve")
338                .is_none()
339        );
340
341        // SAFETY: single-threaded under ENV_LOCK.
342        unsafe {
343            std::env::set_var("IGNITION_PASSWORD", "pw");
344        }
345        let credential = BasicEnvStore
346            .resolve("dev", &AuthRef::default())
347            .expect("resolve")
348            .expect("some with both vars");
349        let Credential::Basic(user, password) = credential else {
350            panic!("expected basic credential");
351        };
352        assert_eq!(user.expose(), "admin");
353        assert_eq!(password.expose(), "pw");
354
355        // SAFETY: single-threaded under ENV_LOCK.
356        unsafe {
357            std::env::remove_var("IGNITION_USER");
358            std::env::remove_var("IGNITION_PASSWORD");
359        }
360    }
361
362    /// The LOCKED chain order end-to-end (env tokens → keyring-shaped store
363    /// → basic env) via test doubles, plus first-Some-wins and exhaustion.
364    #[test]
365    fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
366        let _lock = ENV_LOCK.lock().expect("env lock");
367        // SAFETY: single-threaded under ENV_LOCK; removed before return.
368        unsafe {
369            std::env::set_var("IGNITION_TOKEN", "env-token");
370            std::env::set_var("IGNITION_USER", "admin");
371            std::env::set_var("IGNITION_PASSWORD", "pw");
372        }
373        let auth = AuthRef::default();
374
375        // A store shaped like a populated keyring sits BETWEEN EnvStore and
376        // BasicEnvStore in the chain; the env token must still win (env-first).
377        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
378        let chain: Vec<Box<dyn SecretStore>> = vec![
379            Box::new(EnvStore),
380            Box::new(keyring_like),
381            Box::new(BasicEnvStore),
382        ];
383        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
384        let Credential::Token(token) = credential else {
385            panic!("expected token credential");
386        };
387        assert_eq!(token.expose(), "env-token");
388
389        // Keyring-shaped store wins over basic env (order: keyring before USER/PASSWORD).
390        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
391        let chain: Vec<Box<dyn SecretStore>> = vec![
392            Box::new(EnvStore),
393            Box::new(keyring_like),
394            Box::new(BasicEnvStore),
395        ];
396        // SAFETY: single-threaded under ENV_LOCK.
397        unsafe { std::env::remove_var("IGNITION_TOKEN") };
398        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
399        let Credential::Token(token) = credential else {
400            panic!("expected token credential");
401        };
402        assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
403
404        // Basic env is the last resort.
405        let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
406        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
407        let Credential::Basic(user, _) = credential else {
408            panic!("expected basic credential");
409        };
410        assert_eq!(user.expose(), "admin");
411
412        // Exhausted → SecretUnavailable (exit 3) with the env-first hint.
413        // SAFETY: single-threaded under ENV_LOCK.
414        unsafe {
415            std::env::remove_var("IGNITION_USER");
416            std::env::remove_var("IGNITION_PASSWORD");
417        }
418        let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
419        assert!(matches!(err, CoreError::SecretUnavailable { .. }));
420        assert_eq!(err.exit_code(), 3);
421        assert!(
422            err.hint().expect("hint").contains("IGNITION_TOKEN"),
423            "hint names the env path: {}",
424            err.hint().unwrap(),
425        );
426    }
427
428    /// `KeyringStore` trait-level resolve is exercised ONLY by the
429    /// `#[ignore]`-gated smoke test (Pitfall 8: unit tests never touch a
430    /// real keychain). This test merely pins that the type exists at the
431    /// chain type level without calling into the OS.
432    #[test]
433    fn keyring_store_is_constructible_without_side_effects() {
434        let _store = KeyringStore;
435    }
436}