use crate::config::AuthRef;
use crate::error::CoreError;
#[derive(Clone)]
pub struct Secret(String);
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(***)")
}
}
impl std::fmt::Display for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("***")
}
}
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub enum Credential {
Token(Secret),
Basic(Secret, Secret),
}
pub trait SecretStore: Send + Sync {
fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError>;
}
fn env_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn is_generic_default(auth: &AuthRef) -> bool {
*auth == AuthRef::default()
}
pub struct EnvStore;
impl SecretStore for EnvStore {
fn resolve(&self, profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
let specific = format!("IGNITION_TOKEN_{}", profile_env_suffix(profile));
if let Some(token) = env_var(&specific) {
return Ok(Some(Credential::Token(Secret::new(token))));
}
if let AuthRef::TokenEnv { token_env } = auth
&& let Some(token) = env_var(token_env)
{
return Ok(Some(Credential::Token(Secret::new(token))));
}
if is_generic_default(auth)
&& let Some(token) = env_var("IGNITION_TOKEN")
{
return Ok(Some(Credential::Token(Secret::new(token))));
}
Ok(None)
}
}
pub(crate) fn profile_env_suffix(profile: &str) -> String {
profile
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
fn env_pair_credential(user_var: &str, password_var: &str) -> Option<Credential> {
match (env_var(user_var), env_var(password_var)) {
(Some(user), Some(password)) => {
Some(Credential::Basic(Secret::new(user), Secret::new(password)))
}
_ => None,
}
}
pub struct BasicEnvStore;
impl SecretStore for BasicEnvStore {
fn resolve(&self, _profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
if let AuthRef::Basic {
user_env,
password_env,
} = auth
{
if let Some(credential) = env_pair_credential(user_env, password_env) {
return Ok(Some(credential));
}
return Ok(env_pair_credential("IGNITION_USER", "IGNITION_PASSWORD"));
}
if is_generic_default(auth) {
return Ok(env_pair_credential("IGNITION_USER", "IGNITION_PASSWORD"));
}
Ok(None)
}
}
pub struct KeyringStore;
fn keyring_entry(profile: &str) -> Result<keyring::Entry, keyring::Error> {
keyring::Entry::new("ignition-cli", &format!("profile:{profile}"))
}
impl SecretStore for KeyringStore {
fn resolve(&self, profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
let entry = match keyring_entry(profile) {
Ok(entry) => entry,
Err(err) => {
tracing::debug!(error = %err, profile, "keyring unavailable; skipping");
return Ok(None);
}
};
match entry.get_password() {
Ok(password) => Ok(Some(Credential::Token(Secret::new(password)))),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => {
tracing::warn!(error = %err, profile, "keyring entry unreadable");
Err(CoreError::SecretUnavailable {
profile: profile.to_string(),
})
}
}
}
}
impl KeyringStore {
pub fn set(&self, profile: &str, secret: &Secret) -> Result<(), CoreError> {
let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
profile: profile.to_string(),
})?;
entry
.set_password(secret.expose())
.map_err(|_| CoreError::SecretUnavailable {
profile: profile.to_string(),
})
}
pub fn delete(&self, profile: &str) -> Result<(), CoreError> {
let entry = keyring_entry(profile).map_err(|_| CoreError::SecretUnavailable {
profile: profile.to_string(),
})?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(_) => Err(CoreError::SecretUnavailable {
profile: profile.to_string(),
}),
}
}
}
pub fn resolve_secret(
profile: &str,
auth: &AuthRef,
stores: &[Box<dyn SecretStore>],
) -> Result<Credential, CoreError> {
for store in stores {
match store.resolve(profile, auth)? {
Some(credential) => return Ok(credential),
None => continue,
}
}
Err(CoreError::SecretUnavailable {
profile: profile.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::{
BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
};
use crate::config::AuthRef;
use crate::config::ENV_LOCK;
use crate::error::CoreError;
struct FixedStore(Result<Option<Credential>, ()>);
impl SecretStore for FixedStore {
fn resolve(
&self,
_profile: &str,
_auth: &AuthRef,
) -> Result<Option<Credential>, CoreError> {
self.0.clone().map_err(|()| CoreError::SecretUnavailable {
profile: "fixed".into(),
})
}
}
#[test]
fn secret_renders_redacted() {
let secret = Secret::new("CANARY-t0k3n");
assert_eq!(format!("{secret:?}"), "Secret(***)");
assert_eq!(format!("{secret}"), "***");
assert_eq!(
secret.expose(),
"CANARY-t0k3n",
"expose is the only read path"
);
}
#[test]
fn env_store_profile_specific_token_wins() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_TOKEN_DEV", "specific");
std::env::set_var("IGNITION_TOKEN", "generic");
}
let auth = AuthRef::TokenEnv {
token_env: "MY_TOKEN".into(),
};
let credential = EnvStore
.resolve("dev", &auth)
.expect("resolve")
.expect("some");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "specific");
unsafe {
std::env::remove_var("IGNITION_TOKEN_DEV");
std::env::remove_var("IGNITION_TOKEN");
}
}
#[test]
fn env_store_token_env_ref_and_suffix_mapping() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("MY_TOKEN", "from-ref");
std::env::set_var("IGNITION_TOKEN", "generic");
std::env::set_var("IGNITION_TOKEN_MY_RIG", "rig-specific");
}
let auth = AuthRef::TokenEnv {
token_env: "MY_TOKEN".into(),
};
let credential = EnvStore
.resolve("dev", &auth)
.expect("resolve")
.expect("some");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "from-ref", "token_env ref beats generic");
let credential = EnvStore
.resolve("my-rig", &auth)
.expect("resolve")
.expect("some");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(
token.expose(),
"rig-specific",
"hyphen maps to _ then uppercases"
);
unsafe {
std::env::remove_var("MY_TOKEN");
std::env::remove_var("IGNITION_TOKEN");
std::env::remove_var("IGNITION_TOKEN_MY_RIG");
}
}
#[test]
fn basic_env_store_requires_both_vars() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_USER", "admin");
std::env::remove_var("IGNITION_PASSWORD");
}
assert!(
BasicEnvStore
.resolve("dev", &AuthRef::default())
.expect("resolve")
.is_none()
);
unsafe {
std::env::set_var("IGNITION_PASSWORD", "pw");
}
let credential = BasicEnvStore
.resolve("dev", &AuthRef::default())
.expect("resolve")
.expect("some with both vars");
let Credential::Basic(user, password) = credential else {
panic!("expected basic credential");
};
assert_eq!(user.expose(), "admin");
assert_eq!(password.expose(), "pw");
unsafe {
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
}
#[test]
fn resolve_secret_chain_order_first_some_wins_and_exhaustion() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_TOKEN", "env-token");
std::env::set_var("IGNITION_USER", "admin");
std::env::set_var("IGNITION_PASSWORD", "pw");
}
let auth = AuthRef::default();
let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
let chain: Vec<Box<dyn SecretStore>> = vec![
Box::new(EnvStore),
Box::new(keyring_like),
Box::new(BasicEnvStore),
];
let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "env-token");
let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
let chain: Vec<Box<dyn SecretStore>> = vec![
Box::new(EnvStore),
Box::new(keyring_like),
Box::new(BasicEnvStore),
];
unsafe { std::env::remove_var("IGNITION_TOKEN") };
let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "keyring-token", "keyring beats basic env");
let chain: Vec<Box<dyn SecretStore>> = vec![Box::new(EnvStore), Box::new(BasicEnvStore)];
let credential = resolve_secret("dev", &auth, &chain).expect("resolve");
let Credential::Basic(user, _) = credential else {
panic!("expected basic credential");
};
assert_eq!(user.expose(), "admin");
unsafe {
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
let err = resolve_secret("dev", &auth, &[]).expect_err("empty chain exhausts");
assert!(matches!(err, CoreError::SecretUnavailable { .. }));
assert_eq!(err.exit_code(), 3);
assert!(
err.hint().expect("hint").contains("IGNITION_TOKEN"),
"hint names the env path: {}",
err.hint().unwrap(),
);
}
#[test]
fn keyring_store_is_constructible_without_side_effects() {
let _store = KeyringStore;
}
#[test]
fn env_store_keyring_profile_skips_bare_generic_token() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_TOKEN", "foreign");
std::env::remove_var("IGNITION_TOKEN_E2E");
}
let auth = AuthRef::Keyring {
keyring: "profile:e2e".into(),
};
let result = EnvStore.resolve("e2e", &auth).expect("resolve");
assert!(
result.is_none(),
"a keyring profile must skip the bare generic token"
);
unsafe {
std::env::remove_var("IGNITION_TOKEN");
}
}
#[test]
fn env_store_profile_specific_token_overrides_keyring_profile() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_TOKEN", "foreign");
std::env::set_var("IGNITION_TOKEN_E2E", "specific");
}
let auth = AuthRef::Keyring {
keyring: "profile:e2e".into(),
};
let credential = EnvStore
.resolve("e2e", &auth)
.expect("resolve")
.expect("some");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "specific");
unsafe {
std::env::remove_var("IGNITION_TOKEN");
std::env::remove_var("IGNITION_TOKEN_E2E");
}
}
#[test]
fn env_store_default_auth_still_uses_bare_generic_token() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_TOKEN", "generic");
std::env::remove_var("IGNITION_TOKEN_DEV");
}
let credential = EnvStore
.resolve("dev", &AuthRef::default())
.expect("resolve")
.expect("some");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "generic");
unsafe {
std::env::remove_var("IGNITION_TOKEN");
}
}
#[test]
fn env_store_named_token_env_skips_bare_generic() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::remove_var("MY_TOKEN");
std::env::set_var("IGNITION_TOKEN", "generic");
std::env::remove_var("IGNITION_TOKEN_DEV");
}
let auth = AuthRef::TokenEnv {
token_env: "MY_TOKEN".into(),
};
let result = EnvStore.resolve("dev", &auth).expect("resolve");
assert!(
result.is_none(),
"a named token_env profile must not fall back to the bare generic token"
);
unsafe {
std::env::remove_var("IGNITION_TOKEN");
}
}
#[test]
fn basic_env_store_skips_keyring_profile() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_USER", "admin");
std::env::set_var("IGNITION_PASSWORD", "pw");
}
let auth = AuthRef::Keyring {
keyring: "profile:e2e".into(),
};
let result = BasicEnvStore.resolve("e2e", &auth).expect("resolve");
assert!(
result.is_none(),
"a keyring profile must skip the generic basic pair"
);
unsafe {
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
}
#[test]
fn basic_env_store_prefers_profile_named_vars_then_generic_pair() {
let _lock = ENV_LOCK.lock().expect("env lock");
let auth = AuthRef::Basic {
user_env: "GW_USER".into(),
password_env: "GW_PASS".into(),
};
unsafe {
std::env::set_var("GW_USER", "named-user");
std::env::set_var("GW_PASS", "named-pass");
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
let credential = BasicEnvStore
.resolve("dev", &auth)
.expect("resolve")
.expect("some via named vars");
let Credential::Basic(user, password) = credential else {
panic!("expected basic credential");
};
assert_eq!(user.expose(), "named-user");
assert_eq!(password.expose(), "named-pass");
unsafe {
std::env::remove_var("GW_USER");
std::env::remove_var("GW_PASS");
std::env::set_var("IGNITION_USER", "generic-user");
std::env::set_var("IGNITION_PASSWORD", "generic-pass");
}
let credential = BasicEnvStore
.resolve("dev", &auth)
.expect("resolve")
.expect("some via generic fallback");
let Credential::Basic(user, password) = credential else {
panic!("expected basic credential");
};
assert_eq!(user.expose(), "generic-user");
assert_eq!(password.expose(), "generic-pass");
unsafe {
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
}
#[test]
fn resolve_secret_keyring_profile_beats_foreign_generic_env() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe {
std::env::set_var("IGNITION_TOKEN", "foreign-token");
std::env::set_var("IGNITION_USER", "foreign-user");
std::env::set_var("IGNITION_PASSWORD", "foreign-pass");
std::env::remove_var("IGNITION_TOKEN_E2E");
}
let auth = AuthRef::Keyring {
keyring: "profile:e2e".into(),
};
let keyring_like = FixedStore(Ok(Some(Credential::Token(Secret::new("keyring-token")))));
let chain: Vec<Box<dyn SecretStore>> = vec![
Box::new(EnvStore),
Box::new(keyring_like),
Box::new(BasicEnvStore),
];
let credential = resolve_secret("e2e", &auth, &chain).expect("resolve");
let Credential::Token(token) = credential else {
panic!("expected token credential");
};
assert_eq!(token.expose(), "keyring-token");
unsafe {
std::env::remove_var("IGNITION_TOKEN");
std::env::remove_var("IGNITION_USER");
std::env::remove_var("IGNITION_PASSWORD");
}
}
}