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//! Resolution order (auth-shape-conditional as of quick/260920-iti —
6//! supersedes the old unconditional "LOCKED (CORE-02 must-have)" fallback):
7//!
8//! 1. `IGNITION_TOKEN_<PROFILE_UP>` — the universal explicit override,
9//!    EVERY auth shape.
10//! 2. the profile's own `token_env` var — `TokenEnv` profiles only.
11//! 3. bare `IGNITION_TOKEN` — ONLY a profile whose `auth` equals
12//!    `AuthRef::default()`, i.e. no `auth` key in the TOML at all.
13//! 4. keyring entry `ignition-cli` / `profile:<name>` — every profile,
14//!    UNCONDITIONALLY.
15//! 5. the profile's own `user_env`/`password_env` — `Basic` profiles only.
16//! 6. bare `IGNITION_USER` + `IGNITION_PASSWORD` — a default-auth profile,
17//!    or a `Basic` profile whose own named vars are unset.
18//!
19//! The SEQUENCE above is unchanged from the original design; what changed
20//! is that steps 3 and 6 — the two rungs naming NO profile — became
21//! conditional on the profile's declared auth shape, gated by
22//! [`is_generic_default`]. A `Keyring` or `Basic` profile (or a `TokenEnv`
23//! profile naming some OTHER var) no longer falls through to a foreign
24//! generic credential; it resolves `Ok(None)` from these two rungs and
25//! lets the next store answer. `KeyringStore` stays UNCONDITIONAL: its
26//! lookup is already profile-named (`profile:<name>`), so it cannot serve
27//! one profile's credential to another, and a token-env profile simply has
28//! no entry there (`Ok(None)`) — gating it would break the headless/adopt
29//! interplay for no safety gain.
30//!
31//! The order lives in the STORE CHAIN (see the unit tests and, from 01-04,
32//! the dispatch construction site) — which is why the basic env pair is a
33//! separate [`BasicEnvStore`] placed AFTER [`KeyringStore`]: env tokens
34//! first, keyring second, basic env last.
35//!
36//! The env-first order means default CI and tests never need a secret
37//! service at all; `KeyringStore` fails soft (warn + skip) wherever no OS
38//! keyring exists (headless Linux without D-Bus — keyring-rs fails fast at
39//! `Entry::new`, never hangs).
40
41use crate::config::AuthRef;
42use crate::error::CoreError;
43
44/// A value that must never render. NO `Serialize` impl exists on purpose
45/// (type-level redaction: it cannot appear in JSON output); `Debug`/`Display`
46/// render `***` so tracing logs are safe by construction (CORE-02).
47#[derive(Clone)]
48pub struct Secret(String);
49
50impl std::fmt::Debug for Secret {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str("Secret(***)")
53    }
54}
55
56impl std::fmt::Display for Secret {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str("***")
59    }
60}
61
62impl Secret {
63    pub fn new(value: impl Into<String>) -> Self {
64        Self(value.into())
65    }
66
67    /// The ONLY way to read the value — grep-auditable: the single future
68    /// read site is the reqwest header construction in 01-04.
69    pub fn expose(&self) -> &str {
70        &self.0
71    }
72}
73
74/// A resolved credential: token OR basic pair — never both (that rule is
75/// enforced at the header-construction site in 01-04).
76#[derive(Debug, Clone)]
77pub enum Credential {
78    /// Bearer-style API token.
79    Token(Secret),
80    /// Basic-auth user/password pair.
81    Basic(Secret, Secret),
82}
83
84/// One place to look for a credential. `Ok(None)` = not found here, try the
85/// next store; `Err` = found-but-unreadable (surface with hint).
86pub trait SecretStore: Send + Sync {
87    fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError>;
88}
89
90fn env_var(name: &str) -> Option<String> {
91    std::env::var(name).ok().filter(|value| !value.is_empty())
92}
93
94/// True when a profile declared no `auth` block at all — the ONLY shape
95/// entitled to the two generic (profile-less) env rungs. An equality test
96/// against `AuthRef::default()`, not a restated `"IGNITION_TOKEN"` literal:
97/// `profile.rs`'s `Default` impl keeps that string's single home.
98fn is_generic_default(auth: &AuthRef) -> bool {
99    *auth == AuthRef::default()
100}
101
102/// Token env lookups: `IGNITION_TOKEN_<PROFILE_UP>` (profile uppercased,
103/// non-alphanumeric → `_`, EVERY auth shape) → the profile's own
104/// `token_env` var (`TokenEnv` profiles only) → bare `IGNITION_TOKEN`
105/// (ONLY a profile whose `auth` is `AuthRef::default()` — see
106/// [`is_generic_default`]). A `Keyring` or `Basic` profile, or a
107/// `TokenEnv` profile naming any other variable, falls through to
108/// `Ok(None)` here and lets the next store in the chain answer.
109pub struct EnvStore;
110
111impl SecretStore for EnvStore {
112    fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
113        let specific = format!("IGNITION_TOKEN_{}", profile_env_suffix(profile));
114        if let Some(token) = env_var(&specific) {
115            return Ok(Some(Credential::Token(Secret::new(token))));
116        }
117        if let AuthRef::TokenEnv { token_env } = auth
118            && let Some(token) = env_var(token_env)
119        {
120            return Ok(Some(Credential::Token(Secret::new(token))));
121        }
122        if is_generic_default(auth)
123            && let Some(token) = env_var("IGNITION_TOKEN")
124        {
125            return Ok(Some(Credential::Token(Secret::new(token))));
126        }
127        Ok(None)
128    }
129}
130
131/// Profile name → env-var-safe uppercase suffix: non-alphanumeric
132/// characters become `_` (`my-rig` → `MY_RIG`). `pub(crate)` since
133/// ADOPT-02: the adopt action names the fallback env var with the
134/// SAME rule (one home — never restated).
135pub(crate) fn profile_env_suffix(profile: &str) -> String {
136    profile
137        .chars()
138        .map(|c| {
139            if c.is_ascii_alphanumeric() {
140                c.to_ascii_uppercase()
141            } else {
142                '_'
143            }
144        })
145        .collect()
146}
147
148/// Reads a two-var credential pair (user, password); `None` unless BOTH
149/// are set and non-empty — a lone user is never a credential. Shared by
150/// both the named-pair and generic-pair reads in [`BasicEnvStore`].
151fn env_pair_credential(user_var: &str, password_var: &str) -> Option<Credential> {
152    match (env_var(user_var), env_var(password_var)) {
153        (Some(user), Some(password)) => {
154            Some(Credential::Basic(Secret::new(user), Secret::new(password)))
155        }
156        _ => None,
157    }
158}
159
160/// `IGNITION_USER` + `IGNITION_PASSWORD` basic pair — LAST in the
161/// resolution order (after keyring), which is why it is a separate store
162/// from [`EnvStore`]: the chain, not the struct, encodes the order. The
163/// generic pair is auth-shape-conditional too: a `Basic` profile's own
164/// `user_env`/`password_env` are read first, falling back to the generic
165/// pair only when those named vars are unset; a default-auth profile
166/// (no `auth` block) reads the generic pair directly; any other shape
167/// (`Keyring`, or `TokenEnv` naming its own var) resolves `Ok(None)`
168/// without touching the environment at all.
169pub struct BasicEnvStore;
170
171impl SecretStore for BasicEnvStore {
172    fn resolve(&self, _profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
173        if let AuthRef::Basic {
174            user_env,
175            password_env,
176        } = auth
177        {
178            if let Some(credential) = env_pair_credential(user_env, password_env) {
179                return Ok(Some(credential));
180            }
181            return Ok(env_pair_credential("IGNITION_USER", "IGNITION_PASSWORD"));
182        }
183        if is_generic_default(auth) {
184            return Ok(env_pair_credential("IGNITION_USER", "IGNITION_PASSWORD"));
185        }
186        Ok(None)
187    }
188}
189
190/// OS keyring lookup: service `ignition-cli`, user `profile:<name>`.
191///
192/// ANY `Entry::new` failure → `tracing::debug!` + `Ok(None)` (store
193/// unavailable — headless Linux without D-Bus is an EXPECTED condition,
194/// not an anomaly: skip, never fatal, never hang; keyring-rs fails fast
195/// at construction. debug, not warn, so headless hosts don't get a
196/// non-JSON log line on stderr ahead of every JSON error envelope).
197/// A live entry that exists but cannot be read surfaces as `Err`
198/// (found-but-unreadable — THAT warns).
199pub struct KeyringStore;
200
201/// Keyring coordinates for a profile: fixed service, `profile:<name>` user.
202fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
203    keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
204}
205
206impl SecretStore for KeyringStore {
207    fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
208        let entry = match keyring_entry(profile) {
209            Ok(entry) => entry,
210            Err(err) => {
211                tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
212                return Ok(None);
213            }
214        };
215        match entry.get_password() {
216            Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
217            Err(keyring::Error::NoEntry) => Ok(None),
218            Err(err) => {
219                tracing::warn!(error = %err, profile, "keyring entry unreadable");
220                Err(CoreError::SecretUnavailable {
221                    profile: profile.to_string(),
222                })
223            }
224        }
225    }
226}
227
228impl KeyringStore {
229    /// Store a token for `profile` in the OS keyring. Unlike [`SecretStore::resolve`],
230    /// a SET failure is an error (writing requires a working store) — used by
231    /// future `profile add --keyring` flows and the keyring smoke test.
232    pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
233        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
234            profile: profile.to_string(),
235        })?;
236        entry
237            .set_password(secret.expose())
238            .map_err(|_| CoreError::SecretUnavailable {
239                profile: profile.to_string(),
240            })
241    }
242
243    /// Delete the keyring entry for `profile`; a missing entry is success
244    /// (idempotent).
245    pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
246        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
247            profile: profile.to_string(),
248        })?;
249        match entry.delete_credential() {
250            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
251            Err(_) => Err(CoreError::SecretUnavailable {
252                profile: profile.to_string(),
253            }),
254        }
255    }
256}
257
258/// First store to yield a credential wins; exhausted →
259/// [`CoreError::SecretUnavailable`] (exit 3) whose hint names the env-var
260/// path — the supported headless route.
261pub fn resolve_secret(
262    profile: &str,
263    auth: &AuthRef,
264    stores: &[Box<dyn SecretStore>],
265) -> Result<Credential, CoreError> {
266    for store in stores {
267        match store.resolve(profile, auth)? {
268            Some(credential) => return Ok(credential),
269            None => continue,
270        }
271    }
272    Err(CoreError::SecretUnavailable {
273        profile: profile.to_string(),
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::{
280        BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
281    };
282    use crate::config::AuthRef;
283    use crate::config::ENV_LOCK;
284    use crate::error::CoreError;
285
286    /// Test double for chain-order tests: yields a fixed answer.
287    struct FixedStore(Result<Option<Credential>, ()>);
288    impl SecretStore for FixedStore {
289        fn resolve(
290            &self,
291            _profile: &str,
292            _auth: &AuthRef,
293        ) -> Result<Option<Credential>, CoreError> {
294            self.0.clone().map_err(|()| CoreError::SecretUnavailable {
295                profile: "fixed".into(),
296            })
297        }
298    }
299
300    /// CORE-02 type-level redaction: Debug/Display never leak the value.
301    #[test]
302    fn secret_renders_redacted() {
303        let secret = Secret::new("CANARY-t0k3n");
304        assert_eq!(format!("{secret:?}"), "Secret(***)");
305        assert_eq!(format!("{secret}"), "***");
306        assert_eq!(
307            secret.expose(),
308            "CANARY-t0k3n",
309            "expose is the only read path"
310        );
311    }
312
313    /// Order step 1: `IGNITION_TOKEN_<PROFILE_UP>` beats both the
314    /// auth-ref var and the generic token.
315    #[test]
316    fn env_store_profile_specific_token_wins() {
317        let _lock = ENV_LOCK.lock().expect("env lock");
318        // SAFETY: single-threaded under ENV_LOCK; removed before return.
319        unsafe {
320            std::env::set_var("IGNITION_TOKEN_DEV", "specific");
321            std::env::set_var("IGNITION_TOKEN", "generic");
322        }
323        let auth = AuthRef::TokenEnv {
324            token_env: "MY_TOKEN".into(),
325        };
326        let credential = EnvStore
327            .resolve("dev", &auth)
328            .expect("resolve")
329            .expect("some");
330        let Credential::Token(token) = credential else {
331            panic!("expected token credential");
332        };
333        assert_eq!(token.expose(), "specific");
334        // SAFETY: single-threaded under ENV_LOCK.
335        unsafe {
336            std::env::remove_var("IGNITION_TOKEN_DEV");
337            std::env::remove_var("IGNITION_TOKEN");
338        }
339    }
340
341    /// Order step 2: the profile's `token_env` var beats the generic one;
342    /// non-alphanumeric profile chars map to `_` in the specific var name.
343    #[test]
344    fn env_store_token_env_ref_and_suffix_mapping() {
345        let _lock = ENV_LOCK.lock().expect("env lock");
346        // SAFETY: single-threaded under ENV_LOCK; removed before return.
347        unsafe {
348            std::env::set_var("MY_TOKEN", "from-ref");
349            std::env::set_var("IGNITION_TOKEN", "generic");
350            std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
351        }
352
353        let auth = AuthRef::TokenEnv {
354            token_env: "MY_TOKEN".into(),
355        };
356        let credential = EnvStore
357            .resolve("dev", &auth)
358            .expect("resolve")
359            .expect("some");
360        let Credential::Token(token) = credential else {
361            panic!("expected token credential");
362        };
363        assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
364
365        let credential = EnvStore
366            .resolve("my-rig", &auth)
367            .expect("resolve")
368            .expect("some");
369        let Credential::Token(token) = credential else {
370            panic!("expected token credential");
371        };
372        assert_eq!(
373            token.expose(),
374            "rig-specific",
375            "hyphen maps to _ then uppercases"
376        );
377
378        // SAFETY: single-threaded under ENV_LOCK.
379        unsafe {
380            std::env::remove_var("MY_TOKEN");
381            std::env::remove_var("IGNITION_TOKEN");
382            std::env::remove_var("IGNITION_TOKEN_MY_RIG");
383        }
384    }
385
386    /// Order step 5: the basic env pair needs BOTH vars; a lone user is not
387    /// a credential.
388    #[test]
389    fn basic_env_store_requires_both_vars() {
390        let _lock = ENV_LOCK.lock().expect("env lock");
391        // SAFETY: single-threaded under ENV_LOCK; removed before return.
392        unsafe {
393            std::env::set_var("IGNITION_USER", "admin");
394            std::env::remove_var("IGNITION_PASSWORD");
395        }
396        assert!(
397            BasicEnvStore
398                .resolve("dev", &AuthRef::default())
399                .expect("resolve")
400                .is_none()
401        );
402
403        // SAFETY: single-threaded under ENV_LOCK.
404        unsafe {
405            std::env::set_var("IGNITION_PASSWORD", "pw");
406        }
407        let credential = BasicEnvStore
408            .resolve("dev", &AuthRef::default())
409            .expect("resolve")
410            .expect("some with both vars");
411        let Credential::Basic(user, password) = credential else {
412            panic!("expected basic credential");
413        };
414        assert_eq!(user.expose(), "admin");
415        assert_eq!(password.expose(), "pw");
416
417        // SAFETY: single-threaded under ENV_LOCK.
418        unsafe {
419            std::env::remove_var("IGNITION_USER");
420            std::env::remove_var("IGNITION_PASSWORD");
421        }
422    }
423
424    /// The LOCKED chain order end-to-end (env tokens → keyring-shaped store
425    /// → basic env) via test doubles, plus first-Some-wins and exhaustion.
426    #[test]
427    fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
428        let _lock = ENV_LOCK.lock().expect("env lock");
429        // SAFETY: single-threaded under ENV_LOCK; removed before return.
430        unsafe {
431            std::env::set_var("IGNITION_TOKEN", "env-token");
432            std::env::set_var("IGNITION_USER", "admin");
433            std::env::set_var("IGNITION_PASSWORD", "pw");
434        }
435        let auth = AuthRef::default();
436
437        // A store shaped like a populated keyring sits BETWEEN EnvStore and
438        // BasicEnvStore in the chain; the env token must still win (env-first).
439        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
440        let chain: Vec<Box<dyn SecretStore>> = vec![
441            Box::new(EnvStore),
442            Box::new(keyring_like),
443            Box::new(BasicEnvStore),
444        ];
445        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
446        let Credential::Token(token) = credential else {
447            panic!("expected token credential");
448        };
449        assert_eq!(token.expose(), "env-token");
450
451        // Keyring-shaped store wins over basic env (order: keyring before USER/PASSWORD).
452        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
453        let chain: Vec<Box<dyn SecretStore>> = vec![
454            Box::new(EnvStore),
455            Box::new(keyring_like),
456            Box::new(BasicEnvStore),
457        ];
458        // SAFETY: single-threaded under ENV_LOCK.
459        unsafe { std::env::remove_var("IGNITION_TOKEN") };
460        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
461        let Credential::Token(token) = credential else {
462            panic!("expected token credential");
463        };
464        assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
465
466        // Basic env is the last resort.
467        let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
468        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
469        let Credential::Basic(user, _) = credential else {
470            panic!("expected basic credential");
471        };
472        assert_eq!(user.expose(), "admin");
473
474        // Exhausted → SecretUnavailable (exit 3) with the env-first hint.
475        // SAFETY: single-threaded under ENV_LOCK.
476        unsafe {
477            std::env::remove_var("IGNITION_USER");
478            std::env::remove_var("IGNITION_PASSWORD");
479        }
480        let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
481        assert!(matches!(err, CoreError::SecretUnavailable { .. }));
482        assert_eq!(err.exit_code(), 3);
483        assert!(
484            err.hint().expect("hint").contains("IGNITION_TOKEN"),
485            "hint names the env path: {}",
486            err.hint().unwrap(),
487        );
488    }
489
490    /// `KeyringStore` trait-level resolve is exercised ONLY by the
491    /// `#[ignore]`-gated smoke test (Pitfall 8: unit tests never touch a
492    /// real keychain). This test merely pins that the type exists at the
493    /// chain type level without calling into the OS.
494    #[test]
495    fn keyring_store_is_constructible_without_side_effects() {
496        let _store = KeyringStore;
497    }
498
499    /// T-iti-01 pin: the live bug, reproduced at the store level. A profile
500    /// whose auth is `Keyring` must not receive a foreign bare
501    /// `IGNITION_TOKEN` — the `ign adopt` → `ign --profile e2e status` →
502    /// `auth_rejected` failure this plan closes.
503    #[test]
504    fn env_store_keyring_profile_skips_bare_generic_token() {
505        let _lock = ENV_LOCK.lock().expect("env lock");
506        // SAFETY: single-threaded under ENV_LOCK; removed before return.
507        unsafe {
508            std::env::set_var("IGNITION_TOKEN", "foreign");
509            std::env::remove_var("IGNITION_TOKEN_E2E");
510        }
511        let auth = AuthRef::Keyring {
512            keyring: "profile:e2e".into(),
513        };
514        let result = EnvStore.resolve("e2e", &auth).expect("resolve");
515        assert!(
516            result.is_none(),
517            "a keyring profile must skip the bare generic token"
518        );
519
520        // SAFETY: single-threaded under ENV_LOCK.
521        unsafe {
522            std::env::remove_var("IGNITION_TOKEN");
523        }
524    }
525
526    /// Step 1 stays universal: `IGNITION_TOKEN_<PROFILE_UP>` still overrides
527    /// even a keyring-shaped profile.
528    #[test]
529    fn env_store_profile_specific_token_overrides_keyring_profile() {
530        let _lock = ENV_LOCK.lock().expect("env lock");
531        // SAFETY: single-threaded under ENV_LOCK; removed before return.
532        unsafe {
533            std::env::set_var("IGNITION_TOKEN", "foreign");
534            std::env::set_var("IGNITION_TOKEN_E2E", "specific");
535        }
536        let auth = AuthRef::Keyring {
537            keyring: "profile:e2e".into(),
538        };
539        let credential = EnvStore
540            .resolve("e2e", &auth)
541            .expect("resolve")
542            .expect("some");
543        let Credential::Token(token) = credential else {
544            panic!("expected token credential");
545        };
546        assert_eq!(token.expose(), "specific");
547
548        // SAFETY: single-threaded under ENV_LOCK.
549        unsafe {
550            std::env::remove_var("IGNITION_TOKEN");
551            std::env::remove_var("IGNITION_TOKEN_E2E");
552        }
553    }
554
555    /// The no-`auth`-block / `IGNITION_URL`-overlay path is pinned
556    /// unchanged: a default-auth profile still resolves the bare generic
557    /// token.
558    #[test]
559    fn env_store_default_auth_still_uses_bare_generic_token() {
560        let _lock = ENV_LOCK.lock().expect("env lock");
561        // SAFETY: single-threaded under ENV_LOCK; removed before return.
562        unsafe {
563            std::env::set_var("IGNITION_TOKEN", "generic");
564            std::env::remove_var("IGNITION_TOKEN_DEV");
565        }
566        let credential = EnvStore
567            .resolve("dev", &AuthRef::default())
568            .expect("resolve")
569            .expect("some");
570        let Credential::Token(token) = credential else {
571            panic!("expected token credential");
572        };
573        assert_eq!(token.expose(), "generic");
574
575        // SAFETY: single-threaded under ENV_LOCK.
576        unsafe {
577            std::env::remove_var("IGNITION_TOKEN");
578        }
579    }
580
581    /// A `TokenEnv` profile naming its OWN var must not fall back to the
582    /// unrelated bare `IGNITION_TOKEN` when that named var is absent.
583    #[test]
584    fn env_store_named_token_env_skips_bare_generic() {
585        let _lock = ENV_LOCK.lock().expect("env lock");
586        // SAFETY: single-threaded under ENV_LOCK; removed before return.
587        unsafe {
588            std::env::remove_var("MY_TOKEN");
589            std::env::set_var("IGNITION_TOKEN", "generic");
590            std::env::remove_var("IGNITION_TOKEN_DEV");
591        }
592        let auth = AuthRef::TokenEnv {
593            token_env: "MY_TOKEN".into(),
594        };
595        let result = EnvStore.resolve("dev", &auth).expect("resolve");
596        assert!(
597            result.is_none(),
598            "a named token_env profile must not fall back to the bare generic token"
599        );
600
601        // SAFETY: single-threaded under ENV_LOCK.
602        unsafe {
603            std::env::remove_var("IGNITION_TOKEN");
604        }
605    }
606
607    /// T-iti-02 pin: a keyring-shaped profile must not receive the generic
608    /// basic pair either.
609    #[test]
610    fn basic_env_store_skips_keyring_profile() {
611        let _lock = ENV_LOCK.lock().expect("env lock");
612        // SAFETY: single-threaded under ENV_LOCK; removed before return.
613        unsafe {
614            std::env::set_var("IGNITION_USER", "admin");
615            std::env::set_var("IGNITION_PASSWORD", "pw");
616        }
617        let auth = AuthRef::Keyring {
618            keyring: "profile:e2e".into(),
619        };
620        let result = BasicEnvStore.resolve("e2e", &auth).expect("resolve");
621        assert!(
622            result.is_none(),
623            "a keyring profile must skip the generic basic pair"
624        );
625
626        // SAFETY: single-threaded under ENV_LOCK.
627        unsafe {
628            std::env::remove_var("IGNITION_USER");
629            std::env::remove_var("IGNITION_PASSWORD");
630        }
631    }
632
633    /// A `Basic` profile reads its OWN named vars first, falling back to
634    /// the generic pair only when those named vars are unset.
635    #[test]
636    fn basic_env_store_prefers_profile_named_vars_then_generic_pair() {
637        let _lock = ENV_LOCK.lock().expect("env lock");
638        let auth = AuthRef::Basic {
639            user_env: "GW_USER".into(),
640            password_env: "GW_PASS".into(),
641        };
642
643        // SAFETY: single-threaded under ENV_LOCK; removed before return.
644        unsafe {
645            std::env::set_var("GW_USER", "named-user");
646            std::env::set_var("GW_PASS", "named-pass");
647            std::env::remove_var("IGNITION_USER");
648            std::env::remove_var("IGNITION_PASSWORD");
649        }
650        let credential = BasicEnvStore
651            .resolve("dev", &auth)
652            .expect("resolve")
653            .expect("some via named vars");
654        let Credential::Basic(user, password) = credential else {
655            panic!("expected basic credential");
656        };
657        assert_eq!(user.expose(), "named-user");
658        assert_eq!(password.expose(), "named-pass");
659
660        // SAFETY: single-threaded under ENV_LOCK; removed before return.
661        unsafe {
662            std::env::remove_var("GW_USER");
663            std::env::remove_var("GW_PASS");
664            std::env::set_var("IGNITION_USER", "generic-user");
665            std::env::set_var("IGNITION_PASSWORD", "generic-pass");
666        }
667        let credential = BasicEnvStore
668            .resolve("dev", &auth)
669            .expect("resolve")
670            .expect("some via generic fallback");
671        let Credential::Basic(user, password) = credential else {
672            panic!("expected basic credential");
673        };
674        assert_eq!(user.expose(), "generic-user");
675        assert_eq!(password.expose(), "generic-pass");
676
677        // SAFETY: single-threaded under ENV_LOCK.
678        unsafe {
679            std::env::remove_var("IGNITION_USER");
680            std::env::remove_var("IGNITION_PASSWORD");
681        }
682    }
683
684    /// The chain-level pin — the end-to-end shape of the live bug: a
685    /// keyring profile with a populated keyring wins over a foreign
686    /// generic env token AND a foreign generic basic pair.
687    #[test]
688    fn resolve_secret_keyring_profile_beats_foreign_generic_env() {
689        let _lock = ENV_LOCK.lock().expect("env lock");
690        // SAFETY: single-threaded under ENV_LOCK; removed before return.
691        unsafe {
692            std::env::set_var("IGNITION_TOKEN", "foreign-token");
693            std::env::set_var("IGNITION_USER", "foreign-user");
694            std::env::set_var("IGNITION_PASSWORD", "foreign-pass");
695            std::env::remove_var("IGNITION_TOKEN_E2E");
696        }
697        let auth = AuthRef::Keyring {
698            keyring: "profile:e2e".into(),
699        };
700        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
701        let chain: Vec<Box<dyn SecretStore>> = vec![
702            Box::new(EnvStore),
703            Box::new(keyring_like),
704            Box::new(BasicEnvStore),
705        ];
706        let credential = resolve_secret("e2e", &auth, &chain).expect("resolve");
707        let Credential::Token(token) = credential else {
708            panic!("expected token credential");
709        };
710        assert_eq!(token.expose(), "keyring-token");
711
712        // SAFETY: single-threaded under ENV_LOCK.
713        unsafe {
714            std::env::remove_var("IGNITION_TOKEN");
715            std::env::remove_var("IGNITION_USER");
716            std::env::remove_var("IGNITION_PASSWORD");
717        }
718    }
719}