1use std::collections::HashMap;
7use std::path::PathBuf;
8
9use a3s_box_core::error::{BoxError, Result};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14struct CredentialEntry {
15 username: String,
16 password: String,
17}
18
19#[derive(Debug, Default, Serialize, Deserialize)]
21struct CredentialFile {
22 registries: HashMap<String, CredentialEntry>,
23}
24
25pub struct CredentialStore {
29 path: PathBuf,
30}
31
32impl CredentialStore {
33 pub fn default_path() -> Result<Self> {
35 Ok(Self {
36 path: a3s_box_core::dirs_home()
37 .join("auth")
38 .join("credentials.json"),
39 })
40 }
41
42 pub fn new(path: PathBuf) -> Self {
44 Self { path }
45 }
46
47 pub fn store(&self, registry: &str, username: &str, password: &str) -> Result<()> {
49 self.with_write_lock(|file| {
50 file.registries.insert(
51 normalize_registry(registry),
52 CredentialEntry {
53 username: username.to_string(),
54 password: password.to_string(),
55 },
56 );
57 Ok(())
58 })
59 }
60
61 pub fn get(&self, registry: &str) -> Result<Option<(String, String)>> {
63 let file = self.load()?;
64 Ok(file
65 .registries
66 .get(&normalize_registry(registry))
67 .map(|e| (e.username.clone(), e.password.clone())))
68 }
69
70 pub fn remove(&self, registry: &str) -> Result<bool> {
72 self.with_write_lock(|file| {
73 Ok(file
74 .registries
75 .remove(&normalize_registry(registry))
76 .is_some())
77 })
78 }
79
80 pub fn list_registries(&self) -> Result<Vec<String>> {
82 let file = self.load()?;
83 let mut registries: Vec<String> = file.registries.keys().cloned().collect();
84 registries.sort();
85 Ok(registries)
86 }
87
88 fn load(&self) -> Result<CredentialFile> {
90 if !self.path.exists() {
91 return Ok(CredentialFile::default());
92 }
93 let data = std::fs::read_to_string(&self.path).map_err(|e| {
94 BoxError::ConfigError(format!(
95 "Failed to read credential store {}: {}",
96 self.path.display(),
97 e
98 ))
99 })?;
100 serde_json::from_str(&data).map_err(|e| {
101 BoxError::ConfigError(format!(
102 "Failed to parse credential store {}: {}",
103 self.path.display(),
104 e
105 ))
106 })
107 }
108
109 fn save(&self, file: &CredentialFile) -> Result<()> {
111 if let Some(parent) = self.path.parent() {
112 std::fs::create_dir_all(parent).map_err(|e| {
113 BoxError::ConfigError(format!(
114 "Failed to create credential store directory {}: {}",
115 parent.display(),
116 e
117 ))
118 })?;
119 }
120
121 let tmp_path = self.path.with_extension("tmp");
122 let data = serde_json::to_string_pretty(file)?;
123 std::fs::write(&tmp_path, &data).map_err(|e| {
124 BoxError::ConfigError(format!(
125 "Failed to write credential store {}: {}",
126 tmp_path.display(),
127 e
128 ))
129 })?;
130 std::fs::rename(&tmp_path, &self.path).map_err(|e| {
131 BoxError::ConfigError(format!(
132 "Failed to rename credential store {} -> {}: {}",
133 tmp_path.display(),
134 self.path.display(),
135 e
136 ))
137 })?;
138 Ok(())
139 }
140
141 fn with_write_lock<F, R>(&self, f: F) -> Result<R>
151 where
152 F: FnOnce(&mut CredentialFile) -> Result<R>,
153 {
154 let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
155 BoxError::ConfigError(format!(
156 "Failed to lock credential store {}: {e}",
157 self.path.display()
158 ))
159 })?;
160 let mut file = self.load()?;
161 let r = f(&mut file)?;
162 self.save(&file)?;
163 Ok(r)
164 }
165}
166
167fn normalize_registry(registry: &str) -> String {
169 let r = registry.trim().to_lowercase();
170 if r == "docker.io" || r == "registry-1.docker.io" {
171 "index.docker.io".to_string()
172 } else {
173 r
174 }
175}
176
177impl a3s_box_core::traits::CredentialProvider for CredentialStore {
178 fn get(&self, registry: &str) -> Result<Option<(String, String)>> {
179 self.get(registry)
180 }
181
182 fn store(&self, registry: &str, username: &str, password: &str) -> Result<()> {
183 self.store(registry, username, password)
184 }
185
186 fn remove(&self, registry: &str) -> Result<bool> {
187 self.remove(registry)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use tempfile::TempDir;
195
196 fn test_store(dir: &TempDir) -> CredentialStore {
197 CredentialStore::new(dir.path().join("credentials.json"))
198 }
199
200 #[test]
204 fn concurrent_logins_to_distinct_registries_no_lost_update() {
205 use std::sync::Arc;
206 use std::thread;
207
208 let dir = TempDir::new().unwrap();
209 let store = Arc::new(test_store(&dir));
210
211 let n = 16;
212 let handles: Vec<_> = (0..n)
213 .map(|i| {
214 let store = Arc::clone(&store);
215 thread::spawn(move || {
216 store
217 .store(&format!("reg{i}.example.com"), "user", "pass")
218 .unwrap();
219 })
220 })
221 .collect();
222 for h in handles {
223 h.join().unwrap();
224 }
225
226 assert_eq!(
227 store.list_registries().unwrap().len(),
228 n,
229 "every concurrent login must persist (no lost update)"
230 );
231 }
232
233 #[test]
234 fn test_store_and_get() {
235 let dir = TempDir::new().unwrap();
236 let store = test_store(&dir);
237
238 store.store("ghcr.io", "user1", "pass1").unwrap();
239 let creds = store.get("ghcr.io").unwrap();
240 assert_eq!(creds, Some(("user1".to_string(), "pass1".to_string())));
241 }
242
243 #[test]
244 fn test_get_nonexistent() {
245 let dir = TempDir::new().unwrap();
246 let store = test_store(&dir);
247
248 let creds = store.get("ghcr.io").unwrap();
249 assert_eq!(creds, None);
250 }
251
252 #[test]
253 fn test_overwrite_existing() {
254 let dir = TempDir::new().unwrap();
255 let store = test_store(&dir);
256
257 store.store("ghcr.io", "user1", "pass1").unwrap();
258 store.store("ghcr.io", "user2", "pass2").unwrap();
259 let creds = store.get("ghcr.io").unwrap();
260 assert_eq!(creds, Some(("user2".to_string(), "pass2".to_string())));
261 }
262
263 #[test]
264 fn test_remove() {
265 let dir = TempDir::new().unwrap();
266 let store = test_store(&dir);
267
268 store.store("ghcr.io", "user1", "pass1").unwrap();
269 assert!(store.remove("ghcr.io").unwrap());
270 assert_eq!(store.get("ghcr.io").unwrap(), None);
271 }
272
273 #[test]
274 fn test_remove_nonexistent() {
275 let dir = TempDir::new().unwrap();
276 let store = test_store(&dir);
277
278 assert!(!store.remove("ghcr.io").unwrap());
279 }
280
281 #[test]
282 fn test_list_registries() {
283 let dir = TempDir::new().unwrap();
284 let store = test_store(&dir);
285
286 store.store("ghcr.io", "u1", "p1").unwrap();
287 store.store("quay.io", "u2", "p2").unwrap();
288 let registries = store.list_registries().unwrap();
289 assert_eq!(registries, vec!["ghcr.io", "quay.io"]);
290 }
291
292 #[test]
293 fn test_list_empty() {
294 let dir = TempDir::new().unwrap();
295 let store = test_store(&dir);
296
297 let registries = store.list_registries().unwrap();
298 assert!(registries.is_empty());
299 }
300
301 #[test]
302 fn test_docker_io_normalization() {
303 let dir = TempDir::new().unwrap();
304 let store = test_store(&dir);
305
306 store.store("docker.io", "user", "pass").unwrap();
307 let creds = store.get("index.docker.io").unwrap();
309 assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
310
311 let creds = store.get("registry-1.docker.io").unwrap();
312 assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
313 }
314
315 #[test]
316 fn test_persistence() {
317 let dir = TempDir::new().unwrap();
318 let path = dir.path().join("credentials.json");
319
320 let store1 = CredentialStore::new(path.clone());
322 store1.store("ghcr.io", "user", "pass").unwrap();
323
324 let store2 = CredentialStore::new(path);
326 let creds = store2.get("ghcr.io").unwrap();
327 assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
328 }
329
330 #[test]
331 fn test_multiple_registries() {
332 let dir = TempDir::new().unwrap();
333 let store = test_store(&dir);
334
335 store.store("ghcr.io", "u1", "p1").unwrap();
336 store.store("quay.io", "u2", "p2").unwrap();
337 store.store("ecr.aws", "u3", "p3").unwrap();
338
339 assert_eq!(
340 store.get("ghcr.io").unwrap(),
341 Some(("u1".to_string(), "p1".to_string()))
342 );
343 assert_eq!(
344 store.get("quay.io").unwrap(),
345 Some(("u2".to_string(), "p2".to_string()))
346 );
347 assert_eq!(
348 store.get("ecr.aws").unwrap(),
349 Some(("u3".to_string(), "p3".to_string()))
350 );
351 }
352}