Skip to main content

a3s_box_runtime/oci/
credentials.rs

1//! Persistent credential store for container registries.
2//!
3//! Stores per-registry credentials at `~/.a3s/auth/credentials.json`.
4//! Uses atomic writes (write tmp, rename) for safety.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use a3s_box_core::error::{BoxError, Result};
10use serde::{Deserialize, Serialize};
11
12/// Per-registry credential entry.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14struct CredentialEntry {
15    username: String,
16    password: String,
17}
18
19/// Persistent credential file format.
20#[derive(Debug, Default, Serialize, Deserialize)]
21struct CredentialFile {
22    registries: HashMap<String, CredentialEntry>,
23}
24
25/// Persistent credential store for container registries.
26///
27/// Stores credentials at `~/.a3s/auth/credentials.json`.
28pub struct CredentialStore {
29    path: PathBuf,
30}
31
32impl CredentialStore {
33    /// Create a credential store at the default path (`~/.a3s/auth/credentials.json`).
34    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    /// Create a credential store at a custom path.
43    pub fn new(path: PathBuf) -> Self {
44        Self { path }
45    }
46
47    /// Store credentials for a registry. Overwrites existing entry.
48    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    /// Get credentials for a registry. Returns `(username, password)`.
62    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    /// Remove credentials for a registry. Returns true if entry existed.
71    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    /// List all registries with stored credentials.
81    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    /// Load the credential file from disk. Returns empty if not found.
89    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    /// Save the credential file to disk atomically (write tmp, rename).
110    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    /// Run `f` over the credential file under a cross-process advisory lock,
142    /// re-loading fresh inside the lock and saving the result.
143    ///
144    /// `store`/`remove` funnel through here so two concurrent `a3s-box login`
145    /// processes cannot lose each other's entry: the atomic tmp+rename in
146    /// `save` only prevents a torn read, and `save` even uses a fixed `.tmp`
147    /// name that concurrent writers would collide on. `save` stays lock-free —
148    /// the guard is held here across the whole load → mutate → save and is
149    /// non-reentrant.
150    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
167/// Normalize registry names (e.g., "docker.io" and "index.docker.io" → "index.docker.io").
168fn 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    // The advisory lock is per-open-file-description, so separate
201    // FileLock::acquire calls serialize even across threads in one process —
202    // which lets this exercise the lost-update fix in-process.
203    #[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        // All Docker Hub aliases should resolve to the same entry
308        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        // Store with one instance
321        let store1 = CredentialStore::new(path.clone());
322        store1.store("ghcr.io", "user", "pass").unwrap();
323
324        // Read with a new instance
325        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}