entelix_auth_claude_code/
store.rs1use std::path::{Path, PathBuf};
13
14use async_trait::async_trait;
15
16use crate::credential::CredentialFile;
17use crate::error::{ClaudeCodeAuthError, ClaudeCodeAuthResult};
18
19#[async_trait]
21pub trait CredentialStore: Send + Sync + 'static {
22 async fn load(&self) -> ClaudeCodeAuthResult<Option<CredentialFile>>;
25 async fn save(&self, file: &CredentialFile) -> ClaudeCodeAuthResult<()>;
30}
31
32#[derive(Debug, Clone)]
39pub struct FileCredentialStore {
40 path: PathBuf,
41}
42
43impl FileCredentialStore {
44 #[must_use]
46 pub fn with_path(path: impl Into<PathBuf>) -> Self {
47 Self { path: path.into() }
48 }
49
50 #[must_use]
52 pub fn path(&self) -> &Path {
53 &self.path
54 }
55
56 pub fn default_claude_path() -> ClaudeCodeAuthResult<PathBuf> {
61 let home = std::env::var_os("HOME")
62 .or_else(|| std::env::var_os("USERPROFILE"))
63 .ok_or(ClaudeCodeAuthError::HomeUnresolved)?;
64 let mut path = PathBuf::from(home);
65 path.push(".claude");
66 path.push(".credentials.json");
67 Ok(path)
68 }
69}
70
71#[async_trait]
72impl CredentialStore for FileCredentialStore {
73 async fn load(&self) -> ClaudeCodeAuthResult<Option<CredentialFile>> {
74 let path = self.path.clone();
75 let read = tokio::task::spawn_blocking(move || std::fs::read(&path))
76 .await
77 .map_err(|join_err| ClaudeCodeAuthError::Io {
78 path: self.path.display().to_string(),
79 source: std::io::Error::other(join_err.to_string()),
80 })?;
81 let bytes = match read {
82 Ok(bytes) => bytes,
83 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
84 Err(source) => {
85 return Err(ClaudeCodeAuthError::Io {
86 path: self.path.display().to_string(),
87 source,
88 });
89 }
90 };
91 let file: CredentialFile = serde_json::from_slice(&bytes).map_err(|source| {
92 ClaudeCodeAuthError::InvalidStorage {
93 path: self.path.display().to_string(),
94 source,
95 }
96 })?;
97 Ok(Some(file))
98 }
99
100 async fn save(&self, file: &CredentialFile) -> ClaudeCodeAuthResult<()> {
101 let path = self.path.clone();
102 let display = self.path.display().to_string();
103 let bytes = serde_json::to_vec_pretty(file).map_err(|source| {
104 ClaudeCodeAuthError::InvalidStorage {
105 path: display.clone(),
106 source,
107 }
108 })?;
109 let display_for_blocking = display.clone();
110 let write = tokio::task::spawn_blocking(move || atomic_write(&path, &bytes))
111 .await
112 .map_err(|join_err| ClaudeCodeAuthError::Io {
113 path: display_for_blocking.clone(),
114 source: std::io::Error::other(join_err.to_string()),
115 })?;
116 write.map_err(|source| ClaudeCodeAuthError::Io {
117 path: display,
118 source,
119 })
120 }
121}
122
123fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
135 if let Some(parent) = path.parent() {
136 std::fs::create_dir_all(parent)?;
137 }
138 let mut tmp_name = path
139 .file_name()
140 .ok_or_else(|| std::io::Error::other("destination path has no file name"))?
141 .to_owned();
142 tmp_name.push(".tmp");
143 let tmp_path = path.with_file_name(tmp_name);
144 let _ = std::fs::remove_file(&tmp_path);
147 std::fs::write(&tmp_path, bytes)?;
148 std::fs::rename(&tmp_path, path)
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used)]
153mod tests {
154 use super::*;
155 use crate::credential::OAuthCredential;
156 use chrono::Utc;
157
158 fn tmp_path(name: &str) -> PathBuf {
159 let mut path = std::env::temp_dir();
160 path.push(format!(
161 "entelix-claude-code-{}-{}.json",
162 std::process::id(),
163 name
164 ));
165 path
166 }
167
168 #[tokio::test]
169 async fn load_returns_none_when_file_absent() {
170 let store = FileCredentialStore::with_path(tmp_path("absent"));
171 let loaded = store.load().await.unwrap();
172 assert!(loaded.is_none());
173 }
174
175 #[tokio::test]
176 async fn save_overwrites_existing_file_via_rename() {
177 let path = tmp_path("overwrite");
182 let _ = std::fs::remove_file(&path);
183 let store = FileCredentialStore::with_path(&path);
184 let first = CredentialFile::with_oauth(OAuthCredential::new(
185 "first",
186 (Utc::now() + chrono::Duration::hours(1)).timestamp_millis(),
187 ));
188 store.save(&first).await.unwrap();
189 let second = CredentialFile::with_oauth(OAuthCredential::new(
190 "second",
191 (Utc::now() + chrono::Duration::hours(2)).timestamp_millis(),
192 ));
193 store.save(&second).await.unwrap();
194 let loaded = store.load().await.unwrap().unwrap();
195 assert_eq!(
196 loaded.claude_ai_oauth.unwrap().access_token,
197 "second",
198 "second save must replace first"
199 );
200 let mut tmp_sibling = path.clone();
203 let mut tmp_name = path.file_name().unwrap().to_owned();
204 tmp_name.push(".tmp");
205 tmp_sibling.set_file_name(tmp_name);
206 assert!(
207 !tmp_sibling.exists(),
208 "rename must consume the .tmp staging file"
209 );
210 let _ = std::fs::remove_file(&path);
211 }
212
213 #[tokio::test]
214 async fn save_then_load_round_trips() {
215 let path = tmp_path("round_trip");
216 let _ = std::fs::remove_file(&path);
217 let store = FileCredentialStore::with_path(&path);
218 let envelope = CredentialFile::with_oauth(
219 OAuthCredential::new(
220 "tok",
221 (Utc::now() + chrono::Duration::hours(1)).timestamp_millis(),
222 )
223 .with_refresh_token("ref")
224 .with_subscription_type("pro")
225 .with_scopes(["user:inference"]),
226 );
227 store.save(&envelope).await.unwrap();
228 let loaded = store.load().await.unwrap().unwrap();
229 let oauth = loaded.claude_ai_oauth.unwrap();
230 assert_eq!(oauth.access_token, "tok");
231 assert_eq!(oauth.subscription_type.as_deref(), Some("pro"));
232 let _ = std::fs::remove_file(&path);
233 }
234
235 #[test]
236 fn default_path_resolves_from_environment() {
237 if let Ok(path) = FileCredentialStore::default_claude_path() {
243 assert!(
244 path.ends_with(".claude/.credentials.json")
245 || path.ends_with(r".claude\.credentials.json"),
246 "unexpected path shape: {}",
247 path.display()
248 );
249 }
250 }
251}