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/// The two env var names of a profile's tagDelete caller credential
149/// (26-18, D-13): `IGNITION_TAG_DELETE_USER_<PROFILE>` and
150/// `IGNITION_TAG_DELETE_PASSWORD_<PROFILE>`, the suffix by the token rule
151/// ([`profile_env_suffix`]: `plant-a` gives `PLANT_A`).
152pub fn tag_delete_env_names(profile: &str) -> (String, String) {
153    let suffix = profile_env_suffix(profile);
154    (
155        format!("IGNITION_TAG_DELETE_USER_{suffix}"),
156        format!("IGNITION_TAG_DELETE_PASSWORD_{suffix}"),
157    )
158}
159
160/// Each half of a profile's tagDelete caller credential, read from the env
161/// only (no keyring, no config file); an empty value is absent.
162pub(crate) fn tag_delete_env_parts(profile: &str) -> (Option<Secret>, Option<Secret>) {
163    let (user, password) = tag_delete_env_names(profile);
164    (
165        env_var(&user).map(Secret::new),
166        env_var(&password).map(Secret::new),
167    )
168}
169
170/// A profile's tagDelete caller credential (user, password) when both env
171/// vars of [`tag_delete_env_names`] are set and non-empty.
172pub fn tag_delete_credential(profile: &str) -> Option<(Secret, Secret)> {
173    match tag_delete_env_parts(profile) {
174        (Some(user), Some(password)) => Some((user, password)),
175        _ => None,
176    }
177}
178
179/// Reads a two-var credential pair (user, password); `None` unless BOTH
180/// are set and non-empty — a lone user is never a credential. Shared by
181/// both the named-pair and generic-pair reads in [`BasicEnvStore`].
182fn env_pair_credential(user_var: &str, password_var: &str) -> Option<Credential> {
183    match (env_var(user_var), env_var(password_var)) {
184        (Some(user), Some(password)) => {
185            Some(Credential::Basic(Secret::new(user), Secret::new(password)))
186        }
187        _ => None,
188    }
189}
190
191/// `IGNITION_USER` + `IGNITION_PASSWORD` basic pair — LAST in the
192/// resolution order (after keyring), which is why it is a separate store
193/// from [`EnvStore`]: the chain, not the struct, encodes the order. The
194/// generic pair is auth-shape-conditional too: a `Basic` profile's own
195/// `user_env`/`password_env` are read first, falling back to the generic
196/// pair only when those named vars are unset; a default-auth profile
197/// (no `auth` block) reads the generic pair directly; any other shape
198/// (`Keyring`, or `TokenEnv` naming its own var) resolves `Ok(None)`
199/// without touching the environment at all.
200pub struct BasicEnvStore;
201
202impl SecretStore for BasicEnvStore {
203    fn resolve(&self, _profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
204        if let AuthRef::Basic {
205            user_env,
206            password_env,
207        } = auth
208        {
209            if let Some(credential) = env_pair_credential(user_env, password_env) {
210                return Ok(Some(credential));
211            }
212            return Ok(env_pair_credential("IGNITION_USER", "IGNITION_PASSWORD"));
213        }
214        if is_generic_default(auth) {
215            return Ok(env_pair_credential("IGNITION_USER", "IGNITION_PASSWORD"));
216        }
217        Ok(None)
218    }
219}
220
221/// OS keyring lookup: service `ignition-cli`, user `profile:<name>`.
222///
223/// ANY `Entry::new` failure → `tracing::debug!` + `Ok(None)` (store
224/// unavailable — headless Linux without D-Bus is an EXPECTED condition,
225/// not an anomaly: skip, never fatal, never hang; keyring-rs fails fast
226/// at construction. debug, not warn, so headless hosts don't get a
227/// non-JSON log line on stderr ahead of every JSON error envelope).
228/// A live entry that exists but cannot be read surfaces as `Err`
229/// (found-but-unreadable — THAT warns).
230pub struct KeyringStore;
231
232/// Keyring coordinates for a profile: fixed service, `profile:<name>` user.
233fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
234    keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
235}
236
237impl SecretStore for KeyringStore {
238    fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
239        let entry = match keyring_entry(profile) {
240            Ok(entry) => entry,
241            Err(err) => {
242                tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
243                return Ok(None);
244            }
245        };
246        match entry.get_password() {
247            Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
248            Err(keyring::Error::NoEntry) => Ok(None),
249            Err(err) => {
250                tracing::warn!(error = %err, profile, "keyring entry unreadable");
251                Err(CoreError::SecretUnavailable {
252                    profile: profile.to_string(),
253                })
254            }
255        }
256    }
257}
258
259impl KeyringStore {
260    /// Store a token for `profile` in the OS keyring. Unlike [`SecretStore::resolve`],
261    /// a SET failure is an error (writing requires a working store) — used by
262    /// future `profile add --keyring` flows and the keyring smoke test.
263    pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
264        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
265            profile: profile.to_string(),
266        })?;
267        entry
268            .set_password(secret.expose())
269            .map_err(|_| CoreError::SecretUnavailable {
270                profile: profile.to_string(),
271            })
272    }
273
274    /// Delete the keyring entry for `profile`; a missing entry is success
275    /// (idempotent).
276    pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
277        let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
278            profile: profile.to_string(),
279        })?;
280        match entry.delete_credential() {
281            Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
282            Err(_) => Err(CoreError::SecretUnavailable {
283                profile: profile.to_string(),
284            }),
285        }
286    }
287}
288
289/// First store to yield a credential wins; exhausted →
290/// [`CoreError::SecretUnavailable`] (exit 3) whose hint names the env-var
291/// path — the supported headless route.
292pub fn resolve_secret(
293    profile: &str,
294    auth: &AuthRef,
295    stores: &[Box<dyn SecretStore>],
296) -> Result<Credential, CoreError> {
297    for store in stores {
298        match store.resolve(profile, auth)? {
299            Some(credential) => return Ok(credential),
300            None => continue,
301        }
302    }
303    Err(CoreError::SecretUnavailable {
304        profile: profile.to_string(),
305    })
306}
307
308#[cfg(test)]
309mod tests {
310    use super::{
311        BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
312    };
313    use crate::config::AuthRef;
314    use crate::config::ENV_LOCK;
315    use crate::error::CoreError;
316
317    /// Test double for chain-order tests: yields a fixed answer.
318    struct FixedStore(Result<Option<Credential>, ()>);
319    impl SecretStore for FixedStore {
320        fn resolve(
321            &self,
322            _profile: &str,
323            _auth: &AuthRef,
324        ) -> Result<Option<Credential>, CoreError> {
325            self.0.clone().map_err(|()| CoreError::SecretUnavailable {
326                profile: "fixed".into(),
327            })
328        }
329    }
330
331    /// 26-18: the tagDelete pair reads env only, both halves required,
332    /// an empty value absent.
333    #[test]
334    fn tag_delete_credential_needs_both_env_vars() {
335        let _lock = ENV_LOCK.lock().expect("env lock");
336        assert_eq!(
337            super::tag_delete_env_names("plant-a"),
338            (
339                "IGNITION_TAG_DELETE_USER_PLANT_A".to_string(),
340                "IGNITION_TAG_DELETE_PASSWORD_PLANT_A".to_string()
341            )
342        );
343        assert!(super::tag_delete_credential("td-sec").is_none());
344        // SAFETY: single-threaded under ENV_LOCK; removed before return.
345        unsafe {
346            std::env::set_var("IGNITION_TAG_DELETE_USER_TD_SEC", "u");
347            std::env::set_var("IGNITION_TAG_DELETE_PASSWORD_TD_SEC", "");
348        }
349        assert!(
350            super::tag_delete_credential("td-sec").is_none(),
351            "empty is absent"
352        );
353        // SAFETY: as above.
354        unsafe {
355            std::env::set_var("IGNITION_TAG_DELETE_PASSWORD_TD_SEC", "p");
356        }
357        let (user, password) = super::tag_delete_credential("td-sec").expect("both set");
358        assert_eq!((user.expose(), password.expose()), ("u", "p"));
359        // SAFETY: as above.
360        unsafe {
361            std::env::remove_var("IGNITION_TAG_DELETE_USER_TD_SEC");
362            std::env::remove_var("IGNITION_TAG_DELETE_PASSWORD_TD_SEC");
363        }
364    }
365
366    /// CORE-02 type-level redaction: Debug/Display never leak the value.
367    #[test]
368    fn secret_renders_redacted() {
369        let secret = Secret::new("CANARY-t0k3n");
370        assert_eq!(format!("{secret:?}"), "Secret(***)");
371        assert_eq!(format!("{secret}"), "***");
372        assert_eq!(
373            secret.expose(),
374            "CANARY-t0k3n",
375            "expose is the only read path"
376        );
377    }
378
379    /// Order step 1: `IGNITION_TOKEN_<PROFILE_UP>` beats both the
380    /// auth-ref var and the generic token.
381    #[test]
382    fn env_store_profile_specific_token_wins() {
383        let _lock = ENV_LOCK.lock().expect("env lock");
384        // SAFETY: single-threaded under ENV_LOCK; removed before return.
385        unsafe {
386            std::env::set_var("IGNITION_TOKEN_DEV", "specific");
387            std::env::set_var("IGNITION_TOKEN", "generic");
388        }
389        let auth = AuthRef::TokenEnv {
390            token_env: "MY_TOKEN".into(),
391        };
392        let credential = EnvStore
393            .resolve("dev", &auth)
394            .expect("resolve")
395            .expect("some");
396        let Credential::Token(token) = credential else {
397            panic!("expected token credential");
398        };
399        assert_eq!(token.expose(), "specific");
400        // SAFETY: single-threaded under ENV_LOCK.
401        unsafe {
402            std::env::remove_var("IGNITION_TOKEN_DEV");
403            std::env::remove_var("IGNITION_TOKEN");
404        }
405    }
406
407    /// Order step 2: the profile's `token_env` var beats the generic one;
408    /// non-alphanumeric profile chars map to `_` in the specific var name.
409    #[test]
410    fn env_store_token_env_ref_and_suffix_mapping() {
411        let _lock = ENV_LOCK.lock().expect("env lock");
412        // SAFETY: single-threaded under ENV_LOCK; removed before return.
413        unsafe {
414            std::env::set_var("MY_TOKEN", "from-ref");
415            std::env::set_var("IGNITION_TOKEN", "generic");
416            std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
417        }
418
419        let auth = AuthRef::TokenEnv {
420            token_env: "MY_TOKEN".into(),
421        };
422        let credential = EnvStore
423            .resolve("dev", &auth)
424            .expect("resolve")
425            .expect("some");
426        let Credential::Token(token) = credential else {
427            panic!("expected token credential");
428        };
429        assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
430
431        let credential = EnvStore
432            .resolve("my-rig", &auth)
433            .expect("resolve")
434            .expect("some");
435        let Credential::Token(token) = credential else {
436            panic!("expected token credential");
437        };
438        assert_eq!(
439            token.expose(),
440            "rig-specific",
441            "hyphen maps to _ then uppercases"
442        );
443
444        // SAFETY: single-threaded under ENV_LOCK.
445        unsafe {
446            std::env::remove_var("MY_TOKEN");
447            std::env::remove_var("IGNITION_TOKEN");
448            std::env::remove_var("IGNITION_TOKEN_MY_RIG");
449        }
450    }
451
452    /// Order step 5: the basic env pair needs BOTH vars; a lone user is not
453    /// a credential.
454    #[test]
455    fn basic_env_store_requires_both_vars() {
456        let _lock = ENV_LOCK.lock().expect("env lock");
457        // SAFETY: single-threaded under ENV_LOCK; removed before return.
458        unsafe {
459            std::env::set_var("IGNITION_USER", "admin");
460            std::env::remove_var("IGNITION_PASSWORD");
461        }
462        assert!(
463            BasicEnvStore
464                .resolve("dev", &AuthRef::default())
465                .expect("resolve")
466                .is_none()
467        );
468
469        // SAFETY: single-threaded under ENV_LOCK.
470        unsafe {
471            std::env::set_var("IGNITION_PASSWORD", "pw");
472        }
473        let credential = BasicEnvStore
474            .resolve("dev", &AuthRef::default())
475            .expect("resolve")
476            .expect("some with both vars");
477        let Credential::Basic(user, password) = credential else {
478            panic!("expected basic credential");
479        };
480        assert_eq!(user.expose(), "admin");
481        assert_eq!(password.expose(), "pw");
482
483        // SAFETY: single-threaded under ENV_LOCK.
484        unsafe {
485            std::env::remove_var("IGNITION_USER");
486            std::env::remove_var("IGNITION_PASSWORD");
487        }
488    }
489
490    /// The LOCKED chain order end-to-end (env tokens → keyring-shaped store
491    /// → basic env) via test doubles, plus first-Some-wins and exhaustion.
492    #[test]
493    fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
494        let _lock = ENV_LOCK.lock().expect("env lock");
495        // SAFETY: single-threaded under ENV_LOCK; removed before return.
496        unsafe {
497            std::env::set_var("IGNITION_TOKEN", "env-token");
498            std::env::set_var("IGNITION_USER", "admin");
499            std::env::set_var("IGNITION_PASSWORD", "pw");
500        }
501        let auth = AuthRef::default();
502
503        // A store shaped like a populated keyring sits BETWEEN EnvStore and
504        // BasicEnvStore in the chain; the env token must still win (env-first).
505        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
506        let chain: Vec<Box<dyn SecretStore>> = vec![
507            Box::new(EnvStore),
508            Box::new(keyring_like),
509            Box::new(BasicEnvStore),
510        ];
511        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
512        let Credential::Token(token) = credential else {
513            panic!("expected token credential");
514        };
515        assert_eq!(token.expose(), "env-token");
516
517        // Keyring-shaped store wins over basic env (order: keyring before USER/PASSWORD).
518        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
519        let chain: Vec<Box<dyn SecretStore>> = vec![
520            Box::new(EnvStore),
521            Box::new(keyring_like),
522            Box::new(BasicEnvStore),
523        ];
524        // SAFETY: single-threaded under ENV_LOCK.
525        unsafe { std::env::remove_var("IGNITION_TOKEN") };
526        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
527        let Credential::Token(token) = credential else {
528            panic!("expected token credential");
529        };
530        assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
531
532        // Basic env is the last resort.
533        let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
534        let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
535        let Credential::Basic(user, _) = credential else {
536            panic!("expected basic credential");
537        };
538        assert_eq!(user.expose(), "admin");
539
540        // Exhausted → SecretUnavailable (exit 3) with the env-first hint.
541        // SAFETY: single-threaded under ENV_LOCK.
542        unsafe {
543            std::env::remove_var("IGNITION_USER");
544            std::env::remove_var("IGNITION_PASSWORD");
545        }
546        let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
547        assert!(matches!(err, CoreError::SecretUnavailable { .. }));
548        assert_eq!(err.exit_code(), 3);
549        assert!(
550            err.hint().expect("hint").contains("IGNITION_TOKEN"),
551            "hint names the env path: {}",
552            err.hint().unwrap(),
553        );
554    }
555
556    /// `KeyringStore` trait-level resolve is exercised ONLY by the
557    /// `#[ignore]`-gated smoke test (Pitfall 8: unit tests never touch a
558    /// real keychain). This test merely pins that the type exists at the
559    /// chain type level without calling into the OS.
560    #[test]
561    fn keyring_store_is_constructible_without_side_effects() {
562        let _store = KeyringStore;
563    }
564
565    /// T-iti-01 pin: the live bug, reproduced at the store level. A profile
566    /// whose auth is `Keyring` must not receive a foreign bare
567    /// `IGNITION_TOKEN` — the `ign adopt` → `ign --profile e2e status` →
568    /// `auth_rejected` failure this plan closes.
569    #[test]
570    fn env_store_keyring_profile_skips_bare_generic_token() {
571        let _lock = ENV_LOCK.lock().expect("env lock");
572        // SAFETY: single-threaded under ENV_LOCK; removed before return.
573        unsafe {
574            std::env::set_var("IGNITION_TOKEN", "foreign");
575            std::env::remove_var("IGNITION_TOKEN_E2E");
576        }
577        let auth = AuthRef::Keyring {
578            keyring: "profile:e2e".into(),
579        };
580        let result = EnvStore.resolve("e2e", &auth).expect("resolve");
581        assert!(
582            result.is_none(),
583            "a keyring profile must skip the bare generic token"
584        );
585
586        // SAFETY: single-threaded under ENV_LOCK.
587        unsafe {
588            std::env::remove_var("IGNITION_TOKEN");
589        }
590    }
591
592    /// Step 1 stays universal: `IGNITION_TOKEN_<PROFILE_UP>` still overrides
593    /// even a keyring-shaped profile.
594    #[test]
595    fn env_store_profile_specific_token_overrides_keyring_profile() {
596        let _lock = ENV_LOCK.lock().expect("env lock");
597        // SAFETY: single-threaded under ENV_LOCK; removed before return.
598        unsafe {
599            std::env::set_var("IGNITION_TOKEN", "foreign");
600            std::env::set_var("IGNITION_TOKEN_E2E", "specific");
601        }
602        let auth = AuthRef::Keyring {
603            keyring: "profile:e2e".into(),
604        };
605        let credential = EnvStore
606            .resolve("e2e", &auth)
607            .expect("resolve")
608            .expect("some");
609        let Credential::Token(token) = credential else {
610            panic!("expected token credential");
611        };
612        assert_eq!(token.expose(), "specific");
613
614        // SAFETY: single-threaded under ENV_LOCK.
615        unsafe {
616            std::env::remove_var("IGNITION_TOKEN");
617            std::env::remove_var("IGNITION_TOKEN_E2E");
618        }
619    }
620
621    /// The no-`auth`-block / `IGNITION_URL`-overlay path is pinned
622    /// unchanged: a default-auth profile still resolves the bare generic
623    /// token.
624    #[test]
625    fn env_store_default_auth_still_uses_bare_generic_token() {
626        let _lock = ENV_LOCK.lock().expect("env lock");
627        // SAFETY: single-threaded under ENV_LOCK; removed before return.
628        unsafe {
629            std::env::set_var("IGNITION_TOKEN", "generic");
630            std::env::remove_var("IGNITION_TOKEN_DEV");
631        }
632        let credential = EnvStore
633            .resolve("dev", &AuthRef::default())
634            .expect("resolve")
635            .expect("some");
636        let Credential::Token(token) = credential else {
637            panic!("expected token credential");
638        };
639        assert_eq!(token.expose(), "generic");
640
641        // SAFETY: single-threaded under ENV_LOCK.
642        unsafe {
643            std::env::remove_var("IGNITION_TOKEN");
644        }
645    }
646
647    /// A `TokenEnv` profile naming its OWN var must not fall back to the
648    /// unrelated bare `IGNITION_TOKEN` when that named var is absent.
649    #[test]
650    fn env_store_named_token_env_skips_bare_generic() {
651        let _lock = ENV_LOCK.lock().expect("env lock");
652        // SAFETY: single-threaded under ENV_LOCK; removed before return.
653        unsafe {
654            std::env::remove_var("MY_TOKEN");
655            std::env::set_var("IGNITION_TOKEN", "generic");
656            std::env::remove_var("IGNITION_TOKEN_DEV");
657        }
658        let auth = AuthRef::TokenEnv {
659            token_env: "MY_TOKEN".into(),
660        };
661        let result = EnvStore.resolve("dev", &auth).expect("resolve");
662        assert!(
663            result.is_none(),
664            "a named token_env profile must not fall back to the bare generic token"
665        );
666
667        // SAFETY: single-threaded under ENV_LOCK.
668        unsafe {
669            std::env::remove_var("IGNITION_TOKEN");
670        }
671    }
672
673    /// T-iti-02 pin: a keyring-shaped profile must not receive the generic
674    /// basic pair either.
675    #[test]
676    fn basic_env_store_skips_keyring_profile() {
677        let _lock = ENV_LOCK.lock().expect("env lock");
678        // SAFETY: single-threaded under ENV_LOCK; removed before return.
679        unsafe {
680            std::env::set_var("IGNITION_USER", "admin");
681            std::env::set_var("IGNITION_PASSWORD", "pw");
682        }
683        let auth = AuthRef::Keyring {
684            keyring: "profile:e2e".into(),
685        };
686        let result = BasicEnvStore.resolve("e2e", &auth).expect("resolve");
687        assert!(
688            result.is_none(),
689            "a keyring profile must skip the generic basic pair"
690        );
691
692        // SAFETY: single-threaded under ENV_LOCK.
693        unsafe {
694            std::env::remove_var("IGNITION_USER");
695            std::env::remove_var("IGNITION_PASSWORD");
696        }
697    }
698
699    /// A `Basic` profile reads its OWN named vars first, falling back to
700    /// the generic pair only when those named vars are unset.
701    #[test]
702    fn basic_env_store_prefers_profile_named_vars_then_generic_pair() {
703        let _lock = ENV_LOCK.lock().expect("env lock");
704        let auth = AuthRef::Basic {
705            user_env: "GW_USER".into(),
706            password_env: "GW_PASS".into(),
707        };
708
709        // SAFETY: single-threaded under ENV_LOCK; removed before return.
710        unsafe {
711            std::env::set_var("GW_USER", "named-user");
712            std::env::set_var("GW_PASS", "named-pass");
713            std::env::remove_var("IGNITION_USER");
714            std::env::remove_var("IGNITION_PASSWORD");
715        }
716        let credential = BasicEnvStore
717            .resolve("dev", &auth)
718            .expect("resolve")
719            .expect("some via named vars");
720        let Credential::Basic(user, password) = credential else {
721            panic!("expected basic credential");
722        };
723        assert_eq!(user.expose(), "named-user");
724        assert_eq!(password.expose(), "named-pass");
725
726        // SAFETY: single-threaded under ENV_LOCK; removed before return.
727        unsafe {
728            std::env::remove_var("GW_USER");
729            std::env::remove_var("GW_PASS");
730            std::env::set_var("IGNITION_USER", "generic-user");
731            std::env::set_var("IGNITION_PASSWORD", "generic-pass");
732        }
733        let credential = BasicEnvStore
734            .resolve("dev", &auth)
735            .expect("resolve")
736            .expect("some via generic fallback");
737        let Credential::Basic(user, password) = credential else {
738            panic!("expected basic credential");
739        };
740        assert_eq!(user.expose(), "generic-user");
741        assert_eq!(password.expose(), "generic-pass");
742
743        // SAFETY: single-threaded under ENV_LOCK.
744        unsafe {
745            std::env::remove_var("IGNITION_USER");
746            std::env::remove_var("IGNITION_PASSWORD");
747        }
748    }
749
750    /// The chain-level pin — the end-to-end shape of the live bug: a
751    /// keyring profile with a populated keyring wins over a foreign
752    /// generic env token AND a foreign generic basic pair.
753    #[test]
754    fn resolve_secret_keyring_profile_beats_foreign_generic_env() {
755        let _lock = ENV_LOCK.lock().expect("env lock");
756        // SAFETY: single-threaded under ENV_LOCK; removed before return.
757        unsafe {
758            std::env::set_var("IGNITION_TOKEN", "foreign-token");
759            std::env::set_var("IGNITION_USER", "foreign-user");
760            std::env::set_var("IGNITION_PASSWORD", "foreign-pass");
761            std::env::remove_var("IGNITION_TOKEN_E2E");
762        }
763        let auth = AuthRef::Keyring {
764            keyring: "profile:e2e".into(),
765        };
766        let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
767        let chain: Vec<Box<dyn SecretStore>> = vec![
768            Box::new(EnvStore),
769            Box::new(keyring_like),
770            Box::new(BasicEnvStore),
771        ];
772        let credential = resolve_secret("e2e", &auth, &chain).expect("resolve");
773        let Credential::Token(token) = credential else {
774            panic!("expected token credential");
775        };
776        assert_eq!(token.expose(), "keyring-token");
777
778        // SAFETY: single-threaded under ENV_LOCK.
779        unsafe {
780            std::env::remove_var("IGNITION_TOKEN");
781            std::env::remove_var("IGNITION_USER");
782            std::env::remove_var("IGNITION_PASSWORD");
783        }
784    }
785}