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())
}
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 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()
}
pub struct BasicEnvStore;
impl SecretStore for BasicEnvStore {
fn resolve(&self, _profile: &str, _auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
match (env_var("IGNITION_USER"), env_var("IGNITION_PASSWORD")) {
(Some(user), Some(password)) => Ok(Some(Credential::Basic(
Secret::new(user),
Secret::new(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;
}
}