use std::cell::Cell;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer};
use toml_edit::DocumentMut;
use crate::error::CliCoreError;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredentialStore {
Auto,
#[default]
Keyring,
File,
}
impl CredentialStore {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
CredentialStore::Auto => "auto",
CredentialStore::Keyring => "keyring",
CredentialStore::File => "file",
}
}
}
impl std::fmt::Display for CredentialStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseCredentialStoreError(String);
impl std::fmt::Display for ParseCredentialStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid credential store {:?} (expected one of: auto, keyring, file)",
self.0
)
}
}
impl std::error::Error for ParseCredentialStoreError {}
impl FromStr for CredentialStore {
type Err = ParseCredentialStoreError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(CredentialStore::Auto),
"keyring" | "keychain" => Ok(CredentialStore::Keyring),
"file" => Ok(CredentialStore::File),
_ => Err(ParseCredentialStoreError(s.to_owned())),
}
}
}
impl<'de> Deserialize<'de> for CredentialStore {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct EngineConfig {
pub credentials: CredentialsConfig,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct CredentialsConfig {
pub store: Option<CredentialStore>,
}
thread_local! {
static CREDENTIAL_STORE_FLAG: Cell<u8> = const { Cell::new(0) };
}
fn encode_store(store: Option<CredentialStore>) -> u8 {
match store {
None => 0,
Some(CredentialStore::Auto) => 1,
Some(CredentialStore::Keyring) => 2,
Some(CredentialStore::File) => 3,
}
}
fn decode_store(byte: u8) -> Option<CredentialStore> {
match byte {
1 => Some(CredentialStore::Auto),
2 => Some(CredentialStore::Keyring),
3 => Some(CredentialStore::File),
_ => None,
}
}
pub(crate) fn set_credential_store_flag(store: Option<CredentialStore>) {
CREDENTIAL_STORE_FLAG.with(|f| f.set(encode_store(store)));
}
pub(crate) fn clear_credential_store_flag() {
CREDENTIAL_STORE_FLAG.with(|f| f.set(0));
}
#[must_use]
pub(crate) fn credential_store_flag() -> Option<CredentialStore> {
CREDENTIAL_STORE_FLAG.with(|f| decode_store(f.get()))
}
#[must_use]
pub fn credential_store_env_var(app_id: &str) -> String {
format!(
"{}_CREDENTIAL_STORE",
crate::flags::app_id_env_prefix(app_id)
)
}
#[must_use]
pub fn config_file_path(app_id: &str) -> Option<PathBuf> {
if !crate::fs::is_safe_path_component(app_id) {
tracing::warn!(app_id, "refusing config path with unsafe app id");
return None;
}
crate::fs::config_base_dir().map(|base| base.join(app_id).join("config.toml"))
}
#[must_use]
pub fn load(app_id: &str) -> EngineConfig {
ConfigFile::load(app_id).engine()
}
#[derive(Clone, Debug)]
pub struct ConfigFile {
path: Option<PathBuf>,
doc: DocumentMut,
}
impl Default for ConfigFile {
fn default() -> Self {
Self::from_doc(None, DocumentMut::new())
}
}
impl ConfigFile {
fn from_doc(path: Option<PathBuf>, doc: DocumentMut) -> Self {
Self { path, doc }
}
#[must_use]
pub fn load(app_id: &str) -> Self {
let path = config_file_path(app_id);
let doc = match &path {
None => DocumentMut::new(),
Some(p) => match std::fs::read_to_string(p) {
Ok(contents) => contents.parse::<DocumentMut>().unwrap_or_else(|e| {
tracing::warn!(path = %p.display(), error = %e, "ignoring malformed config file");
DocumentMut::new()
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => {
tracing::warn!(path = %p.display(), error = %e, "could not read config file");
DocumentMut::new()
}
},
};
Self::from_doc(path, doc)
}
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
#[must_use]
pub fn engine(&self) -> EngineConfig {
self.deserialize().unwrap_or_default()
}
pub fn section<T: DeserializeOwned>(&self, name: &str) -> crate::Result<Option<T>> {
let item = match self.doc.get(name) {
None => return Ok(None),
Some(item) => item,
};
let mut tmp = DocumentMut::new();
if let Some(tbl) = item.as_table_like() {
for (k, v) in tbl.iter() {
tmp[k] = v.clone();
}
}
toml_edit::de::from_document::<T>(tmp)
.map(Some)
.map_err(|e| CliCoreError::message(format!("config section {name:?}: {e}")))
}
pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
toml_edit::de::from_document::<T>(self.doc.clone())
.map_err(|e| CliCoreError::message(format!("config deserialize error: {e}")))
}
#[must_use]
pub fn get(&self, dotted_key: &str) -> Option<String> {
let mut item = self.doc.as_item();
for segment in dotted_key.split('.') {
item = item.as_table_like()?.get(segment)?;
}
match item.as_value() {
Some(toml_edit::Value::String(s)) => Some(s.value().clone()),
Some(other) => Some(other.to_string().trim().to_owned()),
None => Some(item.to_string()),
}
}
pub fn set(&mut self, dotted_key: &str, value: &str) -> crate::Result<()> {
const ENGINE_RESERVED_TABLES: &[&str] = &["credentials"];
let first_segment = dotted_key.split('.').next().unwrap_or("");
if ENGINE_RESERVED_TABLES.contains(&first_segment) {
match dotted_key {
"credentials.store" => {
value
.parse::<CredentialStore>()
.map_err(|e| CliCoreError::message(e.to_string()))?;
}
other => {
return Err(CliCoreError::message(format!(
"unknown engine-reserved key {other:?}; \
the only supported key in [credentials] is \"credentials.store\""
)));
}
}
}
let segments: Vec<&str> = dotted_key.split('.').collect();
if segments.iter().any(|s| s.is_empty()) {
return Err(CliCoreError::message(format!(
"invalid config key {dotted_key:?}"
)));
}
let Some((last, parents)) = segments.split_last() else {
return Err(CliCoreError::message("empty config key"));
};
let mut table = self.doc.as_table_mut();
for segment in parents {
let entry = table
.entry(segment)
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
table = entry.as_table_mut().ok_or_else(|| {
CliCoreError::message(format!("config key {segment:?} is not a table"))
})?;
}
table[last] = toml_edit::Item::Value(infer_toml_value(value));
Ok(())
}
#[must_use]
pub fn to_toml_string(&self) -> String {
self.doc.to_string()
}
pub fn save(&self) -> crate::Result<()> {
let path = self.path.as_ref().ok_or_else(|| {
CliCoreError::message(
"no config path available (set XDG_CONFIG_HOME, HOME, or %APPDATA% \
to a directory)",
)
})?;
crate::fs::write_string_atomic(path, &self.doc.to_string())
}
}
fn infer_toml_value(value: &str) -> toml_edit::Value {
if let Ok(b) = value.parse::<bool>() {
return b.into();
}
if let Ok(i) = value.parse::<i64>() {
return i.into();
}
if let Ok(f) = value.parse::<f64>() {
return f.into();
}
value.into()
}
#[must_use]
pub fn resolve_credential_store_with(
flag: Option<CredentialStore>,
env: Option<&str>,
file: &EngineConfig,
) -> CredentialStore {
if let Some(store) = flag {
return store;
}
if let Some(raw) = env {
match raw.parse::<CredentialStore>() {
Ok(store) => return store,
Err(e) => tracing::warn!(error = %e, "ignoring invalid credential-store env var"),
}
}
if let Some(store) = file.credentials.store {
return store;
}
CredentialStore::default()
}
pub fn resolve_credential_store(
app_id: &str,
var: impl Fn(&str) -> Option<String>,
) -> CredentialStore {
let env = var(&credential_store_env_var(app_id));
let file = load(app_id);
resolve_credential_store_with(credential_store_flag(), env.as_deref(), &file)
}
#[cfg(test)]
#[allow(unsafe_code, dead_code)]
pub(crate) mod test_env {
use std::path::Path;
use std::sync::{Mutex, MutexGuard};
pub(crate) static XDG_TEST_MUTEX: Mutex<()> = Mutex::new(());
pub(crate) fn lock() -> MutexGuard<'static, ()> {
XDG_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
}
pub(crate) struct EnvVarGuard {
key: &'static str,
prev: Option<String>,
}
impl EnvVarGuard {
pub(crate) fn set(key: &'static str, value: Option<&Path>) -> Self {
let prev = std::env::var(key).ok();
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
Self { key, prev }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
pub(crate) fn with_xdg_config_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
let _lock = lock();
let _restore = EnvVarGuard::set("XDG_CONFIG_HOME", Some(value));
f()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_known_variants_case_insensitively() {
assert_eq!("auto".parse(), Ok(CredentialStore::Auto));
assert_eq!("Keyring".parse(), Ok(CredentialStore::Keyring));
assert_eq!("KEYCHAIN".parse(), Ok(CredentialStore::Keyring));
assert_eq!(" file ".parse(), Ok(CredentialStore::File));
}
#[test]
fn rejects_unknown_variant() {
let err = "vault"
.parse::<CredentialStore>()
.expect_err("should reject");
assert!(err.to_string().contains("vault"));
}
#[test]
fn display_round_trips_through_from_str() {
for store in [
CredentialStore::Auto,
CredentialStore::Keyring,
CredentialStore::File,
] {
assert_eq!(store.to_string().parse(), Ok(store));
}
}
#[test]
fn env_var_name_is_derived_from_app_id() {
assert_eq!(
credential_store_env_var("godaddy"),
"GODADDY_CREDENTIAL_STORE"
);
assert_eq!(
credential_store_env_var("my-cli"),
"MY_CLI_CREDENTIAL_STORE"
);
}
#[test]
fn deserializes_store_from_toml() {
let config: EngineConfig =
toml_edit::de::from_str("[credentials]\nstore = \"file\"\n").expect("valid toml");
assert_eq!(config.credentials.store, Some(CredentialStore::File));
}
#[test]
fn deserialize_rejects_bad_store_value() {
let result = toml_edit::de::from_str::<EngineConfig>("[credentials]\nstore = \"nope\"\n");
assert!(result.is_err(), "bad store value should fail to parse");
}
#[test]
fn unknown_keys_are_ignored() {
let config: EngineConfig =
toml_edit::de::from_str("future_section = true\n[credentials]\nstore = \"auto\"\n")
.expect("unknown keys tolerated");
assert_eq!(config.credentials.store, Some(CredentialStore::Auto));
}
#[test]
fn resolution_precedence_flag_beats_env_beats_file() {
let file = EngineConfig {
credentials: CredentialsConfig {
store: Some(CredentialStore::Keyring),
},
};
assert_eq!(
resolve_credential_store_with(Some(CredentialStore::Auto), Some("file"), &file),
CredentialStore::Auto
);
assert_eq!(
resolve_credential_store_with(None, Some("file"), &file),
CredentialStore::File
);
assert_eq!(
resolve_credential_store_with(None, None, &file),
CredentialStore::Keyring
);
}
#[test]
fn resolution_defaults_to_keyring() {
assert_eq!(
resolve_credential_store_with(None, None, &EngineConfig::default()),
CredentialStore::Keyring
);
}
#[test]
fn resolution_ignores_invalid_env_and_falls_through() {
let file = EngineConfig {
credentials: CredentialsConfig {
store: Some(CredentialStore::File),
},
};
assert_eq!(
resolve_credential_store_with(None, Some("garbage"), &file),
CredentialStore::File
);
assert_eq!(
resolve_credential_store_with(None, Some("garbage"), &EngineConfig::default()),
CredentialStore::Keyring
);
}
#[test]
fn config_file_path_rejects_unsafe_app_id() {
assert_eq!(config_file_path("../evil"), None);
assert_eq!(config_file_path("a/b"), None);
}
#[test]
fn credential_store_flag_encodes_round_trips() {
for store in [
None,
Some(CredentialStore::Auto),
Some(CredentialStore::Keyring),
Some(CredentialStore::File),
] {
assert_eq!(decode_store(encode_store(store)), store);
}
}
#[test]
fn config_file_path_uses_xdg_config_home() {
let dir = std::env::temp_dir().join("cli-engine-config-path-test");
test_env::with_xdg_config_home(&dir, || {
assert_eq!(
config_file_path("myapp"),
Some(dir.join("myapp").join("config.toml"))
);
});
}
#[derive(Debug, Deserialize, PartialEq)]
struct Deploy {
region: String,
replicas: u32,
}
fn doc_config(toml: &str) -> ConfigFile {
ConfigFile::from_doc(None, toml.parse().expect("valid toml"))
}
#[test]
fn section_reads_consumer_table() {
let cfg = doc_config("[deploy]\nregion = \"us-west\"\nreplicas = 3\n");
let deploy: Deploy = cfg.section("deploy").expect("ok").expect("present");
assert_eq!(
deploy,
Deploy {
region: "us-west".to_owned(),
replicas: 3
}
);
assert!(cfg.section::<Deploy>("absent").expect("ok").is_none());
}
#[test]
fn engine_and_consumer_sections_coexist() {
let cfg = doc_config(
"[credentials]\nstore = \"file\"\n[deploy]\nregion = \"eu\"\nreplicas = 1\n",
);
assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
assert_eq!(
cfg.section::<Deploy>("deploy")
.expect("ok")
.expect("present")
.region,
"eu"
);
}
#[test]
fn get_reads_dotted_scalar() {
let cfg = doc_config("[credentials]\nstore = \"file\"\n[deploy]\nreplicas = 3\n");
assert_eq!(cfg.get("credentials.store").as_deref(), Some("file"));
assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("3"));
assert_eq!(cfg.get("deploy.missing"), None);
assert_eq!(cfg.get("nope.at.all"), None);
}
#[test]
fn set_infers_scalar_types() {
let mut cfg = ConfigFile::default();
cfg.set("telemetry.enabled", "true").expect("set bool");
cfg.set("deploy.replicas", "5").expect("set int");
cfg.set("deploy.region", "us-west").expect("set str");
assert_eq!(cfg.get("telemetry.enabled").as_deref(), Some("true"));
assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("5"));
assert_eq!(cfg.get("deploy.region").as_deref(), Some("us-west"));
assert!(cfg.doc.to_string().contains("enabled = true"));
assert!(cfg.doc.to_string().contains("replicas = 5"));
}
#[test]
fn set_validates_engine_store_key() {
let mut cfg = ConfigFile::default();
assert!(cfg.set("credentials.store", "bogus").is_err());
assert!(cfg.set("credentials.store", "file").is_ok());
assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
}
#[test]
fn set_rejects_unknown_engine_reserved_keys() {
let mut cfg = ConfigFile::default();
assert!(
cfg.set("credentials.unknown_future_key", "foo").is_err(),
"unknown credentials key should be rejected"
);
assert!(
cfg.set("credentials.timeout", "30").is_err(),
"unknown credentials.timeout should be rejected"
);
assert!(
cfg.set("deploy.region", "us-west").is_ok(),
"consumer-owned keys should be accepted"
);
}
#[test]
fn set_rejects_empty_key_segments() {
let mut cfg = ConfigFile::default();
assert!(cfg.set("a..b", "x").is_err());
assert!(cfg.set("", "x").is_err());
}
#[test]
fn set_preserves_comments_and_other_tables() {
let mut cfg =
doc_config("# keep me\n[credentials]\nstore = \"file\"\n\n[deploy]\nregion = \"us\"\n");
cfg.set("deploy.region", "eu").expect("set");
let rendered = cfg.doc.to_string();
assert!(
rendered.contains("# keep me"),
"comment preserved: {rendered}"
);
assert!(
rendered.contains("store = \"file\""),
"other table preserved"
);
assert!(rendered.contains("region = \"eu\""), "value updated");
}
#[test]
fn load_and_save_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
test_env::with_xdg_config_home(dir.path(), || {
let mut cfg = ConfigFile::load("roundtrip");
assert!(cfg.path().is_some());
cfg.set("deploy.region", "us-west").expect("set");
cfg.save().expect("save");
let reloaded = ConfigFile::load("roundtrip");
assert_eq!(reloaded.get("deploy.region").as_deref(), Some("us-west"));
});
}
#[test]
fn malformed_file_loads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");
test_env::with_xdg_config_home(dir.path(), || {
let path = config_file_path("broken").expect("path");
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, "not = valid = toml").expect("write");
let cfg = ConfigFile::load("broken");
assert_eq!(cfg.engine().credentials.store, None);
assert_eq!(cfg.get("anything"), None);
});
}
#[test]
fn default_config_has_no_path_and_save_errors() {
let cfg = ConfigFile::default();
assert!(cfg.path().is_none());
assert!(cfg.save().is_err(), "save without a path should error");
}
}