1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15struct CredentialEntry {
16 username: String,
17 password: String,
18}
19
20#[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
48pub struct CredentialStore {
52 path: PathBuf,
53}
54
55impl CredentialStore {
56 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 pub fn new(path: PathBuf) -> Self {
67 Self { path }
68 }
69
70 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 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 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 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 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 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 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
190pub(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
398fn 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 #[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 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 let store1 = CredentialStore::new(path.clone());
635 store1.store("ghcr.io", "user", "pass").unwrap();
636
637 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}