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
148pub 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
160pub(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
170pub 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
179fn 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
191pub 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
221pub struct KeyringStore;
231
232fn 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 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 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
289pub 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 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 #[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 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 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 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 #[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 #[test]
382 fn env_store_profile_specific_token_wins() {
383 let _lock = ENV_LOCK.lock().expect("env lock");
384 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 unsafe {
402 std::env::remove_var("IGNITION_TOKEN_DEV");
403 std::env::remove_var("IGNITION_TOKEN");
404 }
405 }
406
407 #[test]
410 fn env_store_token_env_ref_and_suffix_mapping() {
411 let _lock = ENV_LOCK.lock().expect("env lock");
412 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 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 #[test]
455 fn basic_env_store_requires_both_vars() {
456 let _lock = ENV_LOCK.lock().expect("env lock");
457 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 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 unsafe {
485 std::env::remove_var("IGNITION_USER");
486 std::env::remove_var("IGNITION_PASSWORD");
487 }
488 }
489
490 #[test]
493 fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
494 let _lock = ENV_LOCK.lock().expect("env lock");
495 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 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 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 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 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 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 #[test]
561 fn keyring_store_is_constructible_without_side_effects() {
562 let _store = KeyringStore;
563 }
564
565 #[test]
570 fn env_store_keyring_profile_skips_bare_generic_token() {
571 let _lock = ENV_LOCK.lock().expect("env lock");
572 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 unsafe {
588 std::env::remove_var("IGNITION_TOKEN");
589 }
590 }
591
592 #[test]
595 fn env_store_profile_specific_token_overrides_keyring_profile() {
596 let _lock = ENV_LOCK.lock().expect("env lock");
597 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 unsafe {
616 std::env::remove_var("IGNITION_TOKEN");
617 std::env::remove_var("IGNITION_TOKEN_E2E");
618 }
619 }
620
621 #[test]
625 fn env_store_default_auth_still_uses_bare_generic_token() {
626 let _lock = ENV_LOCK.lock().expect("env lock");
627 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 unsafe {
643 std::env::remove_var("IGNITION_TOKEN");
644 }
645 }
646
647 #[test]
650 fn env_store_named_token_env_skips_bare_generic() {
651 let _lock = ENV_LOCK.lock().expect("env lock");
652 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 unsafe {
669 std::env::remove_var("IGNITION_TOKEN");
670 }
671 }
672
673 #[test]
676 fn basic_env_store_skips_keyring_profile() {
677 let _lock = ENV_LOCK.lock().expect("env lock");
678 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 unsafe {
694 std::env::remove_var("IGNITION_USER");
695 std::env::remove_var("IGNITION_PASSWORD");
696 }
697 }
698
699 #[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 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 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 unsafe {
745 std::env::remove_var("IGNITION_USER");
746 std::env::remove_var("IGNITION_PASSWORD");
747 }
748 }
749
750 #[test]
754 fn resolve_secret_keyring_profile_beats_foreign_generic_env() {
755 let _lock = ENV_LOCK.lock().expect("env lock");
756 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 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}