use std::sync::Arc;
use async_trait::async_trait;
use crate::Result;
use crate::config::CredentialStore;
use crate::fs::{config_base_dir, is_safe_path_component};
#[derive(Clone, Copy, Debug)]
pub struct CredentialKey<'key> {
pub app_id: &'key str,
pub provider: &'key str,
pub env: &'key str,
}
impl<'key> CredentialKey<'key> {
#[must_use]
pub fn new(app_id: &'key str, provider: &'key str, env: &'key str) -> Self {
Self {
app_id,
provider,
env,
}
}
}
#[async_trait]
pub trait CredentialStorage: Send + Sync + std::fmt::Debug {
async fn load(&self, key: &CredentialKey<'_>) -> Option<String>;
async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()>;
async fn delete(&self, key: &CredentialKey<'_>);
async fn list(&self) -> Result<Vec<String>> {
Ok(Vec::new())
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FileStorage;
impl FileStorage {
#[must_use]
pub fn new() -> Self {
Self
}
fn path_for(key: &CredentialKey<'_>) -> Option<std::path::PathBuf> {
let app = if key.app_id.is_empty() {
key.provider
} else {
key.app_id
};
if !is_safe_path_component(app)
|| !is_safe_path_component(key.provider)
|| !is_safe_path_component(key.env)
{
tracing::warn!(
app,
provider = key.provider,
env = key.env,
"refusing credential path with unsafe component"
);
return None;
}
let base = config_base_dir()?;
Some(
base.join(app)
.join("credentials")
.join(format!("{}-{}.json", key.provider, key.env)),
)
}
}
#[async_trait]
impl CredentialStorage for FileStorage {
async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
let path = Self::path_for(key)?;
match tokio::fs::read_to_string(&path).await {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "credential file read failed");
None
}
}
}
async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
let path = Self::path_for(key).ok_or_else(|| {
crate::error::CliCoreError::message("could not determine credential file path")
})?;
let value = value.to_owned();
tokio::task::spawn_blocking(move || crate::fs::write_string_atomic(&path, &value))
.await
.map_err(|e| {
crate::error::CliCoreError::message(format!(
"credential file write task {}: {e}",
if e.is_cancelled() {
"cancelled"
} else {
"panicked"
}
))
})?
}
async fn delete(&self, key: &CredentialKey<'_>) {
let Some(path) = Self::path_for(key) else {
return;
};
match tokio::fs::remove_file(&path).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to delete credential file");
}
}
}
}
#[cfg(feature = "pkce-auth")]
pub use keychain::{AutoStorage, KeyringStorage};
#[cfg(feature = "pkce-auth")]
mod keychain {
use super::{CredentialKey, CredentialStorage, FileStorage, Result, async_trait};
const KEYCHAIN_USER: &str = "token";
fn keychain_service(key: &CredentialKey<'_>) -> String {
if key.app_id.is_empty() {
format!("{}/{}", key.provider, key.env)
} else {
format!("{}/{}/{}", key.app_id, key.provider, key.env)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct KeyringStorage;
impl KeyringStorage {
#[must_use]
pub fn new() -> Self {
Self
}
pub(super) async fn read_three_state(
&self,
key: &CredentialKey<'_>,
) -> Option<Option<String>> {
let service = keychain_service(key);
match tokio::task::spawn_blocking({
let service = service.clone();
move || keychain_read_blocking(&service, KEYCHAIN_USER)
})
.await
{
Ok(result) => result,
Err(e) => {
let reason = if e.is_cancelled() {
"cancelled"
} else {
"panicked"
};
tracing::warn!(service, error = %e, reason, "keychain read task failed");
None
}
}
}
pub(super) async fn write_raw(&self, key: &CredentialKey<'_>, value: &str) -> bool {
let service = keychain_service(key);
let value = value.to_owned();
match tokio::task::spawn_blocking({
let service = service.clone();
move || keychain_write_blocking(&service, KEYCHAIN_USER, &value)
})
.await
{
Ok(saved) => saved,
Err(e) => {
let reason = if e.is_cancelled() {
"cancelled"
} else {
"panicked"
};
tracing::warn!(service, error = %e, reason, "keychain write task failed");
false
}
}
}
pub(super) async fn delete_entry(&self, key: &CredentialKey<'_>) {
let service = keychain_service(key);
let service_for_warn = service.clone();
if let Err(e) =
tokio::task::spawn_blocking(move || match keyring::Entry::new(&service, KEYCHAIN_USER) {
Err(e) => {
tracing::warn!(service, error = %e, "keychain entry creation failed on delete");
}
Ok(entry) => match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(e) => {
tracing::warn!(service, error = %e, "keychain delete failed");
}
},
})
.await
{
let reason = if e.is_cancelled() {
"cancelled"
} else {
"panicked"
};
tracing::warn!(service = service_for_warn, error = %e, reason, "keychain delete task failed");
}
}
}
#[async_trait]
impl CredentialStorage for KeyringStorage {
async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
self.read_three_state(key).await.flatten()
}
async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
if self.write_raw(key, value).await {
Ok(())
} else {
Err(crate::error::CliCoreError::message(
"failed to save token to keychain — check logs for the underlying error, \
ensure your system keychain (e.g. gnome-keyring, macOS Keychain) is running \
and unlocked, or select file storage (credential store \"file\" or \"auto\")",
))
}
}
async fn delete(&self, key: &CredentialKey<'_>) {
self.delete_entry(key).await;
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AutoStorage {
file: FileStorage,
keyring: KeyringStorage,
}
impl AutoStorage {
#[must_use]
pub fn new() -> Self {
Self {
file: FileStorage::new(),
keyring: KeyringStorage::new(),
}
}
}
#[async_trait]
impl CredentialStorage for AutoStorage {
async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
match self.keyring.read_three_state(key).await {
Some(Some(json)) => Some(json),
Some(None) => None,
None => self.file.load(key).await,
}
}
async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
if self.keyring.write_raw(key, value).await {
self.file.delete(key).await;
return Ok(());
}
self.file.save(key, value).await
}
async fn delete(&self, key: &CredentialKey<'_>) {
self.keyring.delete(key).await;
self.file.delete(key).await;
}
}
fn keychain_read_blocking(service: &str, user: &str) -> Option<Option<String>> {
match keyring::Entry::new(service, user) {
Err(e) => {
tracing::warn!(service, error = %e, "keychain entry creation failed");
None
}
Ok(entry) => match entry.get_password() {
Err(keyring::Error::NoEntry) => {
tracing::debug!(service, "no stored token in keychain");
Some(None)
}
Err(e) => {
tracing::warn!(service, error = %e, "keychain read failed");
None
}
Ok(json) => Some(Some(json)),
},
}
}
fn keychain_write_blocking(service: &str, user: &str, json: &str) -> bool {
match keyring::Entry::new(service, user) {
Err(e) => {
tracing::warn!(service, error = %e, "keychain entry creation failed");
false
}
Ok(entry) => match entry.set_password(json) {
Err(e) => {
tracing::warn!(service, error = %e, "keychain write failed");
false
}
Ok(()) => {
tracing::debug!(service, "token saved to keychain");
true
}
},
}
}
}
#[must_use]
pub fn storage_for(mode: CredentialStore) -> Arc<dyn CredentialStorage> {
match mode {
CredentialStore::File => Arc::new(FileStorage::new()),
#[cfg(feature = "pkce-auth")]
CredentialStore::Keyring => Arc::new(KeyringStorage::new()),
#[cfg(feature = "pkce-auth")]
CredentialStore::Auto => Arc::new(AutoStorage::new()),
#[cfg(not(feature = "pkce-auth"))]
mode => {
tracing::warn!(
%mode,
"keyring backends unavailable (pkce-auth feature disabled); using file storage"
);
Arc::new(FileStorage::new())
}
}
}
#[must_use]
pub fn default_storage(app_id: &str) -> Arc<dyn CredentialStorage> {
let mode = crate::config::resolve_credential_store(app_id, |k| std::env::var(k).ok());
storage_for(mode)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_env::{EnvVarGuard, lock, with_xdg_config_home};
#[test]
fn file_path_uses_app_id_and_provider() {
let dir = std::env::temp_dir().join("cli-engine-storage-test-xdg");
with_xdg_config_home(&dir, || {
let key = CredentialKey::new("myapp", "prov", "prod");
assert_eq!(
FileStorage::path_for(&key),
Some(dir.join("myapp").join("credentials").join("prov-prod.json"))
);
let key2 = CredentialKey::new("", "prov", "prod");
assert_eq!(
FileStorage::path_for(&key2),
Some(dir.join("prov").join("credentials").join("prov-prod.json"))
);
});
}
#[test]
fn file_path_rejects_unsafe_components() {
for env in ["../../etc/passwd", "dev/subdir", "dev\\subdir", ".."] {
let key = CredentialKey::new("app", "prov", env);
assert_eq!(
FileStorage::path_for(&key),
None,
"{env:?} should be rejected"
);
}
}
#[test]
fn file_path_rejects_relative_base_dir() {
with_xdg_config_home(std::path::Path::new("."), || {
let key = CredentialKey::new("app", "prov", "dev");
assert_eq!(FileStorage::path_for(&key), None);
});
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn file_storage_round_trip() {
let dir = tempfile::tempdir().expect("tempdir");
let _lock = lock();
let _env = EnvVarGuard::set("XDG_CONFIG_HOME", Some(dir.path()));
let store = FileStorage::new();
let key = CredentialKey::new("app", "prov", "dev");
assert_eq!(store.load(&key).await, None);
store.save(&key, "{\"token\":\"abc\"}").await.expect("save");
assert_eq!(
store.load(&key).await.as_deref(),
Some("{\"token\":\"abc\"}")
);
store.delete(&key).await;
assert_eq!(store.load(&key).await, None);
}
#[test]
fn storage_for_file_is_always_available() {
let store = storage_for(CredentialStore::File);
assert!(format!("{store:?}").contains("FileStorage"));
}
}