1use crate::config::AuthRef;
42use crate::error::CoreError;
43
44#[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 pub fn expose(&self) -> &str {
70 &self.0
71 }
72}
73
74#[derive(Debug, Clone)]
77pub enum Credential {
78 Token(Secret),
80 Basic(Secret, Secret),
82}
83
84pub 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
94fn is_generic_default(auth: &AuthRef) -> bool {
99 *auth == AuthRef::default()
100}
101
102pub 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
131pub(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
148fn 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
160pub 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
190pub struct KeyringStore;
200
201fn 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 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 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
258pub 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 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 #[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 #[test]
316 fn env_store_profile_specific_token_wins() {
317 let _lock = ENV_LOCK.lock().expect("env lock");
318 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 unsafe {
336 std::env::remove_var("IGNITION_TOKEN_DEV");
337 std::env::remove_var("IGNITION_TOKEN");
338 }
339 }
340
341 #[test]
344 fn env_store_token_env_ref_and_suffix_mapping() {
345 let _lock = ENV_LOCK.lock().expect("env lock");
346 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 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 #[test]
389 fn basic_env_store_requires_both_vars() {
390 let _lock = ENV_LOCK.lock().expect("env lock");
391 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 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 unsafe {
419 std::env::remove_var("IGNITION_USER");
420 std::env::remove_var("IGNITION_PASSWORD");
421 }
422 }
423
424 #[test]
427 fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
428 let _lock = ENV_LOCK.lock().expect("env lock");
429 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 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 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 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 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 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 #[test]
495 fn keyring_store_is_constructible_without_side_effects() {
496 let _store = KeyringStore;
497 }
498
499 #[test]
504 fn env_store_keyring_profile_skips_bare_generic_token() {
505 let _lock = ENV_LOCK.lock().expect("env lock");
506 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 unsafe {
522 std::env::remove_var("IGNITION_TOKEN");
523 }
524 }
525
526 #[test]
529 fn env_store_profile_specific_token_overrides_keyring_profile() {
530 let _lock = ENV_LOCK.lock().expect("env lock");
531 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 unsafe {
550 std::env::remove_var("IGNITION_TOKEN");
551 std::env::remove_var("IGNITION_TOKEN_E2E");
552 }
553 }
554
555 #[test]
559 fn env_store_default_auth_still_uses_bare_generic_token() {
560 let _lock = ENV_LOCK.lock().expect("env lock");
561 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 unsafe {
577 std::env::remove_var("IGNITION_TOKEN");
578 }
579 }
580
581 #[test]
584 fn env_store_named_token_env_skips_bare_generic() {
585 let _lock = ENV_LOCK.lock().expect("env lock");
586 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 unsafe {
603 std::env::remove_var("IGNITION_TOKEN");
604 }
605 }
606
607 #[test]
610 fn basic_env_store_skips_keyring_profile() {
611 let _lock = ENV_LOCK.lock().expect("env lock");
612 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 unsafe {
628 std::env::remove_var("IGNITION_USER");
629 std::env::remove_var("IGNITION_PASSWORD");
630 }
631 }
632
633 #[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 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 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 unsafe {
679 std::env::remove_var("IGNITION_USER");
680 std::env::remove_var("IGNITION_PASSWORD");
681 }
682 }
683
684 #[test]
688 fn resolve_secret_keyring_profile_beats_foreign_generic_env() {
689 let _lock = ENV_LOCK.lock().expect("env lock");
690 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 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}