use std::path::{Path, PathBuf};
use async_trait::async_trait;
use crate::credential::CredentialFile;
use crate::error::{ClaudeCodeAuthError, ClaudeCodeAuthResult};
#[async_trait]
pub trait CredentialStore: Send + Sync + 'static {
async fn load(&self) -> ClaudeCodeAuthResult<Option<CredentialFile>>;
async fn save(&self, file: &CredentialFile) -> ClaudeCodeAuthResult<()>;
}
#[derive(Debug, Clone)]
pub struct FileCredentialStore {
path: PathBuf,
}
impl FileCredentialStore {
#[must_use]
pub fn with_path(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn default_claude_path() -> ClaudeCodeAuthResult<PathBuf> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or(ClaudeCodeAuthError::HomeUnresolved)?;
let mut path = PathBuf::from(home);
path.push(".claude");
path.push(".credentials.json");
Ok(path)
}
}
#[async_trait]
impl CredentialStore for FileCredentialStore {
async fn load(&self) -> ClaudeCodeAuthResult<Option<CredentialFile>> {
let path = self.path.clone();
let read = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|join_err| ClaudeCodeAuthError::Io {
path: self.path.display().to_string(),
source: std::io::Error::other(join_err.to_string()),
})?;
let bytes = match read {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(ClaudeCodeAuthError::Io {
path: self.path.display().to_string(),
source,
});
}
};
let file: CredentialFile = serde_json::from_slice(&bytes).map_err(|source| {
ClaudeCodeAuthError::InvalidStorage {
path: self.path.display().to_string(),
source,
}
})?;
Ok(Some(file))
}
async fn save(&self, file: &CredentialFile) -> ClaudeCodeAuthResult<()> {
let path = self.path.clone();
let display = self.path.display().to_string();
let bytes = serde_json::to_vec_pretty(file).map_err(|source| {
ClaudeCodeAuthError::InvalidStorage {
path: display.clone(),
source,
}
})?;
let display_for_blocking = display.clone();
let write = tokio::task::spawn_blocking(move || atomic_write(&path, &bytes))
.await
.map_err(|join_err| ClaudeCodeAuthError::Io {
path: display_for_blocking.clone(),
source: std::io::Error::other(join_err.to_string()),
})?;
write.map_err(|source| ClaudeCodeAuthError::Io {
path: display,
source,
})
}
}
fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut tmp_name = path
.file_name()
.ok_or_else(|| std::io::Error::other("destination path has no file name"))?
.to_owned();
tmp_name.push(".tmp");
let tmp_path = path.with_file_name(tmp_name);
let _ = std::fs::remove_file(&tmp_path);
std::fs::write(&tmp_path, bytes)?;
std::fs::rename(&tmp_path, path)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::credential::OAuthCredential;
use chrono::Utc;
fn tmp_path(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"entelix-claude-code-{}-{}.json",
std::process::id(),
name
));
path
}
#[tokio::test]
async fn load_returns_none_when_file_absent() {
let store = FileCredentialStore::with_path(tmp_path("absent"));
let loaded = store.load().await.unwrap();
assert!(loaded.is_none());
}
#[tokio::test]
async fn save_overwrites_existing_file_via_rename() {
let path = tmp_path("overwrite");
let _ = std::fs::remove_file(&path);
let store = FileCredentialStore::with_path(&path);
let first = CredentialFile::with_oauth(OAuthCredential::new(
"first",
(Utc::now() + chrono::Duration::hours(1)).timestamp_millis(),
));
store.save(&first).await.unwrap();
let second = CredentialFile::with_oauth(OAuthCredential::new(
"second",
(Utc::now() + chrono::Duration::hours(2)).timestamp_millis(),
));
store.save(&second).await.unwrap();
let loaded = store.load().await.unwrap().unwrap();
assert_eq!(
loaded.claude_ai_oauth.unwrap().access_token,
"second",
"second save must replace first"
);
let mut tmp_sibling = path.clone();
let mut tmp_name = path.file_name().unwrap().to_owned();
tmp_name.push(".tmp");
tmp_sibling.set_file_name(tmp_name);
assert!(
!tmp_sibling.exists(),
"rename must consume the .tmp staging file"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn save_then_load_round_trips() {
let path = tmp_path("round_trip");
let _ = std::fs::remove_file(&path);
let store = FileCredentialStore::with_path(&path);
let envelope = CredentialFile::with_oauth(
OAuthCredential::new(
"tok",
(Utc::now() + chrono::Duration::hours(1)).timestamp_millis(),
)
.with_refresh_token("ref")
.with_subscription_type("pro")
.with_scopes(["user:inference"]),
);
store.save(&envelope).await.unwrap();
let loaded = store.load().await.unwrap().unwrap();
let oauth = loaded.claude_ai_oauth.unwrap();
assert_eq!(oauth.access_token, "tok");
assert_eq!(oauth.subscription_type.as_deref(), Some("pro"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn default_path_resolves_from_environment() {
if let Ok(path) = FileCredentialStore::default_claude_path() {
assert!(
path.ends_with(".claude/.credentials.json")
|| path.ends_with(r".claude\.credentials.json"),
"unexpected path shape: {}",
path.display()
);
}
}
}