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::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10use base64::Engine as _;
11use serde::{Deserialize, Serialize};
12
13/// Per-registry credential entry.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15struct CredentialEntry {
16    username: String,
17    password: String,
18}
19
20/// Persistent credential file format.
21#[derive(Debug, Default, Serialize, Deserialize)]
22struct CredentialFile {
23    registries: HashMap<String, CredentialEntry>,
24}
25
26#[derive(Debug, Default, Deserialize)]
27struct DockerConfigFile {
28    #[serde(default)]
29    auths: HashMap<String, DockerAuthEntry>,
30    #[serde(default, rename = "credsStore")]
31    creds_store: Option<String>,
32    #[serde(default, rename = "credHelpers")]
33    cred_helpers: HashMap<String, String>,
34}
35
36#[derive(Debug, Default, Deserialize)]
37struct DockerAuthEntry {
38    #[serde(default)]
39    auth: Option<String>,
40    #[serde(default)]
41    username: Option<String>,
42    #[serde(default)]
43    password: Option<String>,
44    #[serde(default, rename = "identitytoken")]
45    identity_token: Option<String>,
46}
47
48/// Persistent credential store for container registries.
49///
50/// Stores credentials at `~/.a3s/auth/credentials.json`.
51pub struct CredentialStore {
52    path: PathBuf,
53}
54
55impl CredentialStore {
56    /// Create a credential store at the default path (`~/.a3s/auth/credentials.json`).
57    pub fn default_path() -> Result<Self> {
58        Ok(Self {
59            path: a3s_box_core::dirs_home()
60                .join("auth")
61                .join("credentials.json"),
62        })
63    }
64
65    /// Create a credential store at a custom path.
66    pub fn new(path: PathBuf) -> Self {
67        Self { path }
68    }
69
70    /// Store credentials for a registry. Overwrites existing entry.
71    pub fn store(&self, registry: &str, username: &str, password: &str) -> Result<()> {
72        self.with_write_lock(|file| {
73            file.registries.insert(
74                normalize_registry(registry),
75                CredentialEntry {
76                    username: username.to_string(),
77                    password: password.to_string(),
78                },
79            );
80            Ok(())
81        })
82    }
83
84    /// Get credentials for a registry. Returns `(username, password)`.
85    pub fn get(&self, registry: &str) -> Result<Option<(String, String)>> {
86        let file = self.load()?;
87        Ok(file
88            .registries
89            .get(&normalize_registry(registry))
90            .map(|e| (e.username.clone(), e.password.clone())))
91    }
92
93    /// Remove credentials for a registry. Returns true if entry existed.
94    pub fn remove(&self, registry: &str) -> Result<bool> {
95        self.with_write_lock(|file| {
96            Ok(file
97                .registries
98                .remove(&normalize_registry(registry))
99                .is_some())
100        })
101    }
102
103    /// List all registries with stored credentials.
104    pub fn list_registries(&self) -> Result<Vec<String>> {
105        let file = self.load()?;
106        let mut registries: Vec<String> = file.registries.keys().cloned().collect();
107        registries.sort();
108        Ok(registries)
109    }
110
111    /// Load the credential file from disk. Returns empty if not found.
112    fn load(&self) -> Result<CredentialFile> {
113        if !self.path.exists() {
114            return Ok(CredentialFile::default());
115        }
116        let data = std::fs::read_to_string(&self.path).map_err(|e| {
117            BoxError::ConfigError(format!(
118                "Failed to read credential store {}: {}",
119                self.path.display(),
120                e
121            ))
122        })?;
123        serde_json::from_str(&data).map_err(|e| {
124            BoxError::ConfigError(format!(
125                "Failed to parse credential store {}: {}",
126                self.path.display(),
127                e
128            ))
129        })
130    }
131
132    /// Save the credential file to disk atomically (write tmp, rename).
133    fn save(&self, file: &CredentialFile) -> Result<()> {
134        if let Some(parent) = self.path.parent() {
135            std::fs::create_dir_all(parent).map_err(|e| {
136                BoxError::ConfigError(format!(
137                    "Failed to create credential store directory {}: {}",
138                    parent.display(),
139                    e
140                ))
141            })?;
142        }
143
144        let tmp_path = self.path.with_extension("tmp");
145        let data = serde_json::to_string_pretty(file)?;
146        std::fs::write(&tmp_path, &data).map_err(|e| {
147            BoxError::ConfigError(format!(
148                "Failed to write credential store {}: {}",
149                tmp_path.display(),
150                e
151            ))
152        })?;
153        std::fs::rename(&tmp_path, &self.path).map_err(|e| {
154            BoxError::ConfigError(format!(
155                "Failed to rename credential store {} -> {}: {}",
156                tmp_path.display(),
157                self.path.display(),
158                e
159            ))
160        })?;
161        Ok(())
162    }
163
164    /// Run `f` over the credential file under a cross-process advisory lock,
165    /// re-loading fresh inside the lock and saving the result.
166    ///
167    /// `store`/`remove` funnel through here so two concurrent `a3s-box login`
168    /// processes cannot lose each other's entry: the atomic tmp+rename in
169    /// `save` only prevents a torn read, and `save` even uses a fixed `.tmp`
170    /// name that concurrent writers would collide on. `save` stays lock-free —
171    /// the guard is held here across the whole load → mutate → save and is
172    /// non-reentrant.
173    fn with_write_lock<F, R>(&self, f: F) -> Result<R>
174    where
175        F: FnOnce(&mut CredentialFile) -> Result<R>,
176    {
177        let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
178            BoxError::ConfigError(format!(
179                "Failed to lock credential store {}: {e}",
180                self.path.display()
181            ))
182        })?;
183        let mut file = self.load()?;
184        let r = f(&mut file)?;
185        self.save(&file)?;
186        Ok(r)
187    }
188}
189
190/// Get credentials from Docker's config (`~/.docker/config.json` or
191/// `$DOCKER_CONFIG/config.json`), including credential helpers.
192pub(crate) fn docker_credentials(registry: &str) -> Option<(String, String)> {
193    let config_path = docker_config_path()?;
194    let config = load_docker_config(&config_path).ok()?;
195    let candidates = docker_registry_candidates(registry);
196
197    if let Some((key, helper)) = matching_credential_helper(&config, &candidates) {
198        if let Some(creds) =
199            docker_credential_helper_get(helper, &helper_server_candidates(key, registry))
200        {
201            return Some(creds);
202        }
203    }
204
205    if let Some(helper) = config.creds_store.as_deref() {
206        if let Some(creds) = docker_credential_helper_get(helper, &candidates) {
207            return Some(creds);
208        }
209    }
210
211    matching_docker_auth(&config, &candidates).and_then(docker_auth_entry_credentials)
212}
213
214fn docker_config_path() -> Option<PathBuf> {
215    if let Ok(config_dir) = std::env::var("DOCKER_CONFIG") {
216        return Some(PathBuf::from(config_dir).join("config.json"));
217    }
218    dirs::home_dir().map(|home| home.join(".docker").join("config.json"))
219}
220
221fn load_docker_config(path: &Path) -> Result<DockerConfigFile> {
222    let data = std::fs::read_to_string(path).map_err(|e| {
223        BoxError::ConfigError(format!(
224            "Failed to read Docker credential config {}: {}",
225            path.display(),
226            e
227        ))
228    })?;
229    serde_json::from_str(&data).map_err(|e| {
230        BoxError::ConfigError(format!(
231            "Failed to parse Docker credential config {}: {}",
232            path.display(),
233            e
234        ))
235    })
236}
237
238fn docker_registry_candidates(registry: &str) -> Vec<String> {
239    let normalized = normalize_registry(registry);
240    let mut candidates = vec![
241        registry.trim().to_string(),
242        normalized.clone(),
243        format!("https://{}", registry.trim()),
244        format!("http://{}", registry.trim()),
245        format!("https://{normalized}"),
246        format!("http://{normalized}"),
247    ];
248
249    if normalized == "index.docker.io" {
250        candidates.extend(
251            [
252                "docker.io",
253                "registry-1.docker.io",
254                "https://index.docker.io/v1/",
255                "https://index.docker.io/v1",
256                "index.docker.io/v1/",
257                "index.docker.io/v1",
258            ]
259            .into_iter()
260            .map(str::to_string),
261        );
262    }
263
264    candidates.sort();
265    candidates.dedup();
266    candidates
267}
268
269fn matching_credential_helper<'a>(
270    config: &'a DockerConfigFile,
271    candidates: &[String],
272) -> Option<(&'a str, &'a str)> {
273    config
274        .cred_helpers
275        .iter()
276        .find(|(key, _)| registry_key_matches(key, candidates))
277        .map(|(key, helper)| (key.as_str(), helper.as_str()))
278}
279
280fn matching_docker_auth<'a>(
281    config: &'a DockerConfigFile,
282    candidates: &[String],
283) -> Option<&'a DockerAuthEntry> {
284    config
285        .auths
286        .iter()
287        .find(|(key, _)| registry_key_matches(key, candidates))
288        .map(|(_, entry)| entry)
289}
290
291fn registry_key_matches(key: &str, candidates: &[String]) -> bool {
292    let key_norm = normalize_docker_server_key(key);
293    candidates
294        .iter()
295        .any(|candidate| key == candidate || key_norm == normalize_docker_server_key(candidate))
296}
297
298fn normalize_docker_server_key(value: &str) -> String {
299    let trimmed = value.trim().trim_end_matches('/');
300    let without_scheme = trimmed
301        .strip_prefix("https://")
302        .or_else(|| trimmed.strip_prefix("http://"))
303        .unwrap_or(trimmed);
304    let without_v1 = without_scheme.strip_suffix("/v1").unwrap_or(without_scheme);
305    normalize_registry(without_v1)
306}
307
308fn helper_server_candidates(matched_key: &str, registry: &str) -> Vec<String> {
309    let mut candidates = vec![matched_key.to_string()];
310    candidates.extend(docker_registry_candidates(registry));
311    candidates.sort();
312    candidates.dedup();
313    candidates
314}
315
316fn docker_auth_entry_credentials(entry: &DockerAuthEntry) -> Option<(String, String)> {
317    if let (Some(username), Some(password)) = (&entry.username, &entry.password) {
318        if !username.is_empty() && !password.is_empty() {
319            return Some((username.clone(), password.clone()));
320        }
321    }
322
323    if let Some(auth) = entry.auth.as_deref() {
324        if let Some(creds) = decode_docker_auth(auth) {
325            return Some(creds);
326        }
327    }
328
329    entry
330        .identity_token
331        .as_ref()
332        .filter(|token| !token.is_empty())
333        .map(|token| ("oauth2accesstoken".to_string(), token.clone()))
334}
335
336fn decode_docker_auth(auth: &str) -> Option<(String, String)> {
337    let decoded = base64::engine::general_purpose::STANDARD
338        .decode(auth.trim())
339        .ok()?;
340    let text = String::from_utf8(decoded).ok()?;
341    let (username, password) = text.split_once(':')?;
342    if username.is_empty() || password.is_empty() {
343        return None;
344    }
345    Some((username.to_string(), password.to_string()))
346}
347
348fn docker_credential_helper_get(
349    helper: &str,
350    server_candidates: &[String],
351) -> Option<(String, String)> {
352    for server in server_candidates {
353        if let Some(creds) = docker_credential_helper_get_one(helper, server) {
354            return Some(creds);
355        }
356    }
357    None
358}
359
360fn docker_credential_helper_get_one(helper: &str, server: &str) -> Option<(String, String)> {
361    let program = format!("docker-credential-{helper}");
362    let mut child = std::process::Command::new(program)
363        .arg("get")
364        .stdin(std::process::Stdio::piped())
365        .stdout(std::process::Stdio::piped())
366        .stderr(std::process::Stdio::null())
367        .spawn()
368        .ok()?;
369
370    {
371        use std::io::Write as _;
372        let stdin = child.stdin.as_mut()?;
373        if stdin.write_all(server.as_bytes()).is_err() {
374            let _ = child.kill();
375            return None;
376        }
377    }
378
379    let output = child.wait_with_output().ok()?;
380    if !output.status.success() {
381        return None;
382    }
383
384    #[derive(Deserialize)]
385    #[allow(non_snake_case)]
386    struct HelperResponse {
387        Username: String,
388        Secret: String,
389    }
390
391    let response: HelperResponse = serde_json::from_slice(&output.stdout).ok()?;
392    if response.Username.is_empty() || response.Secret.is_empty() {
393        return None;
394    }
395    Some((response.Username, response.Secret))
396}
397
398/// Normalize registry names (e.g., "docker.io" and "index.docker.io" → "index.docker.io").
399fn normalize_registry(registry: &str) -> String {
400    let r = registry.trim().to_lowercase();
401    if r == "docker.io" || r == "registry-1.docker.io" {
402        "index.docker.io".to_string()
403    } else {
404        r
405    }
406}
407
408impl a3s_box_core::traits::CredentialProvider for CredentialStore {
409    fn get(&self, registry: &str) -> Result<Option<(String, String)>> {
410        self.get(registry)
411    }
412
413    fn store(&self, registry: &str, username: &str, password: &str) -> Result<()> {
414        self.store(registry, username, password)
415    }
416
417    fn remove(&self, registry: &str) -> Result<bool> {
418        self.remove(registry)
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::sync::{Mutex, OnceLock};
426    use tempfile::TempDir;
427
428    fn test_store(dir: &TempDir) -> CredentialStore {
429        CredentialStore::new(dir.path().join("credentials.json"))
430    }
431
432    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
433        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
434        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
435    }
436
437    fn with_docker_config_dir<R>(dir: &TempDir, f: impl FnOnce() -> R) -> R {
438        let _guard = env_lock();
439        let previous = std::env::var_os("DOCKER_CONFIG");
440        std::env::set_var("DOCKER_CONFIG", dir.path());
441        let result = f();
442        match previous {
443            Some(value) => std::env::set_var("DOCKER_CONFIG", value),
444            None => std::env::remove_var("DOCKER_CONFIG"),
445        }
446        result
447    }
448
449    // The advisory lock is per-open-file-description, so separate
450    // FileLock::acquire calls serialize even across threads in one process —
451    // which lets this exercise the lost-update fix in-process.
452    #[test]
453    fn concurrent_logins_to_distinct_registries_no_lost_update() {
454        use std::sync::Arc;
455        use std::thread;
456
457        let dir = TempDir::new().unwrap();
458        let store = Arc::new(test_store(&dir));
459
460        let n = 16;
461        let handles: Vec<_> = (0..n)
462            .map(|i| {
463                let store = Arc::clone(&store);
464                thread::spawn(move || {
465                    store
466                        .store(&format!("reg{i}.example.com"), "user", "pass")
467                        .unwrap();
468                })
469            })
470            .collect();
471        for h in handles {
472            h.join().unwrap();
473        }
474
475        assert_eq!(
476            store.list_registries().unwrap().len(),
477            n,
478            "every concurrent login must persist (no lost update)"
479        );
480    }
481
482    #[test]
483    fn test_store_and_get() {
484        let dir = TempDir::new().unwrap();
485        let store = test_store(&dir);
486
487        store.store("ghcr.io", "user1", "pass1").unwrap();
488        let creds = store.get("ghcr.io").unwrap();
489        assert_eq!(creds, Some(("user1".to_string(), "pass1".to_string())));
490    }
491
492    #[test]
493    fn test_get_nonexistent() {
494        let dir = TempDir::new().unwrap();
495        let store = test_store(&dir);
496
497        let creds = store.get("ghcr.io").unwrap();
498        assert_eq!(creds, None);
499    }
500
501    #[test]
502    fn test_overwrite_existing() {
503        let dir = TempDir::new().unwrap();
504        let store = test_store(&dir);
505
506        store.store("ghcr.io", "user1", "pass1").unwrap();
507        store.store("ghcr.io", "user2", "pass2").unwrap();
508        let creds = store.get("ghcr.io").unwrap();
509        assert_eq!(creds, Some(("user2".to_string(), "pass2".to_string())));
510    }
511
512    #[test]
513    fn test_remove() {
514        let dir = TempDir::new().unwrap();
515        let store = test_store(&dir);
516
517        store.store("ghcr.io", "user1", "pass1").unwrap();
518        assert!(store.remove("ghcr.io").unwrap());
519        assert_eq!(store.get("ghcr.io").unwrap(), None);
520    }
521
522    #[test]
523    fn test_remove_nonexistent() {
524        let dir = TempDir::new().unwrap();
525        let store = test_store(&dir);
526
527        assert!(!store.remove("ghcr.io").unwrap());
528    }
529
530    #[test]
531    fn test_list_registries() {
532        let dir = TempDir::new().unwrap();
533        let store = test_store(&dir);
534
535        store.store("ghcr.io", "u1", "p1").unwrap();
536        store.store("quay.io", "u2", "p2").unwrap();
537        let registries = store.list_registries().unwrap();
538        assert_eq!(registries, vec!["ghcr.io", "quay.io"]);
539    }
540
541    #[test]
542    fn test_list_empty() {
543        let dir = TempDir::new().unwrap();
544        let store = test_store(&dir);
545
546        let registries = store.list_registries().unwrap();
547        assert!(registries.is_empty());
548    }
549
550    #[test]
551    fn test_docker_io_normalization() {
552        let dir = TempDir::new().unwrap();
553        let store = test_store(&dir);
554
555        store.store("docker.io", "user", "pass").unwrap();
556        // All Docker Hub aliases should resolve to the same entry
557        let creds = store.get("index.docker.io").unwrap();
558        assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
559
560        let creds = store.get("registry-1.docker.io").unwrap();
561        assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
562    }
563
564    #[test]
565    fn docker_credentials_reads_auths_for_host_port_registry() {
566        use base64::Engine as _;
567
568        let dir = TempDir::new().unwrap();
569        let auth = base64::engine::general_purpose::STANDARD.encode("user:pass");
570        std::fs::write(
571            dir.path().join("config.json"),
572            format!(
573                r#"{{
574  "auths": {{
575    "10.12.111.133:49164": {{ "auth": "{auth}" }}
576  }}
577}}"#
578            ),
579        )
580        .unwrap();
581
582        let creds = with_docker_config_dir(&dir, || docker_credentials("10.12.111.133:49164"));
583        assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
584    }
585
586    #[test]
587    fn docker_credentials_matches_docker_hub_legacy_url() {
588        let dir = TempDir::new().unwrap();
589        std::fs::write(
590            dir.path().join("config.json"),
591            r#"{
592  "auths": {
593    "https://index.docker.io/v1/": {
594      "username": "dock",
595      "password": "secret"
596    }
597  }
598}"#,
599        )
600        .unwrap();
601
602        let creds = with_docker_config_dir(&dir, || docker_credentials("docker.io"));
603        assert_eq!(creds, Some(("dock".to_string(), "secret".to_string())));
604    }
605
606    #[test]
607    fn docker_credentials_uses_identity_token_as_oauth_password() {
608        let dir = TempDir::new().unwrap();
609        std::fs::write(
610            dir.path().join("config.json"),
611            r#"{
612  "auths": {
613    "registry.example.com": {
614      "identitytoken": "token-value"
615    }
616  }
617}"#,
618        )
619        .unwrap();
620
621        let creds = with_docker_config_dir(&dir, || docker_credentials("registry.example.com"));
622        assert_eq!(
623            creds,
624            Some(("oauth2accesstoken".to_string(), "token-value".to_string()))
625        );
626    }
627
628    #[test]
629    fn test_persistence() {
630        let dir = TempDir::new().unwrap();
631        let path = dir.path().join("credentials.json");
632
633        // Store with one instance
634        let store1 = CredentialStore::new(path.clone());
635        store1.store("ghcr.io", "user", "pass").unwrap();
636
637        // Read with a new instance
638        let store2 = CredentialStore::new(path);
639        let creds = store2.get("ghcr.io").unwrap();
640        assert_eq!(creds, Some(("user".to_string(), "pass".to_string())));
641    }
642
643    #[test]
644    fn test_multiple_registries() {
645        let dir = TempDir::new().unwrap();
646        let store = test_store(&dir);
647
648        store.store("ghcr.io", "u1", "p1").unwrap();
649        store.store("quay.io", "u2", "p2").unwrap();
650        store.store("ecr.aws", "u3", "p3").unwrap();
651
652        assert_eq!(
653            store.get("ghcr.io").unwrap(),
654            Some(("u1".to_string(), "p1".to_string()))
655        );
656        assert_eq!(
657            store.get("quay.io").unwrap(),
658            Some(("u2".to_string(), "p2".to_string()))
659        );
660        assert_eq!(
661            store.get("ecr.aws").unwrap(),
662            Some(("u3".to_string(), "p3".to_string()))
663        );
664    }
665}