Skip to main content

containerregistry_auth/
config.rs

1//! Docker config file parsing.
2//!
3//! This module parses Docker's `config.json` file which contains registry
4//! credentials and credential helper configuration.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use serde::Deserialize;
10
11use crate::{Credential, Error, Result};
12
13/// Docker config file structure.
14///
15/// This represents the contents of `~/.docker/config.json` or the file
16/// pointed to by `DOCKER_CONFIG`.
17#[derive(Clone, Debug, Default, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct DockerConfig {
20    /// Direct authentication entries (base64-encoded or plaintext).
21    #[serde(default)]
22    pub auths: HashMap<String, AuthEntry>,
23
24    /// Default credential store for all registries.
25    #[serde(default, rename = "credsStore")]
26    pub creds_store: Option<String>,
27
28    /// Per-registry credential helpers.
29    #[serde(default, rename = "credHelpers")]
30    pub cred_helpers: HashMap<String, String>,
31}
32
33/// An auth entry from the Docker config's "auths" section.
34#[derive(Clone, Debug, Default, Deserialize)]
35pub struct AuthEntry {
36    /// Base64-encoded "username:password" string.
37    #[serde(default)]
38    pub auth: Option<String>,
39
40    /// Username (if stored separately).
41    #[serde(default)]
42    pub username: Option<String>,
43
44    /// Password (if stored separately).
45    #[serde(default)]
46    pub password: Option<String>,
47
48    /// Identity token for OAuth2.
49    #[serde(default, rename = "identitytoken")]
50    pub identity_token: Option<String>,
51
52    /// Registry token (deprecated).
53    #[serde(default, rename = "registrytoken")]
54    pub registry_token: Option<String>,
55}
56
57impl DockerConfig {
58    /// Loads the Docker config from the default location.
59    ///
60    /// Checks `DOCKER_CONFIG` environment variable first, then falls back
61    /// to `~/.docker/config.json`.
62    pub fn load() -> Result<Self> {
63        let path = Self::default_config_path()?;
64        Self::load_from(&path)
65    }
66
67    /// Loads the Docker config from a specific path.
68    pub fn load_from(path: &Path) -> Result<Self> {
69        let contents = std::fs::read_to_string(path).map_err(|e| {
70            if e.kind() == std::io::ErrorKind::NotFound {
71                // Return empty config if file doesn't exist
72                return Error::Io(e);
73            }
74            Error::Io(e)
75        })?;
76
77        Self::from_json(&contents)
78    }
79
80    /// Parses a Docker config from JSON string.
81    pub fn from_json(json: &str) -> Result<Self> {
82        serde_json::from_str(json).map_err(|e| Error::ConfigParse(e.to_string()))
83    }
84
85    /// Returns the default Docker config file path.
86    ///
87    /// Checks `DOCKER_CONFIG` environment variable first, then uses
88    /// `~/.docker/config.json`.
89    pub fn default_config_path() -> Result<PathBuf> {
90        // Check DOCKER_CONFIG env var
91        if let Ok(docker_config) = std::env::var("DOCKER_CONFIG") {
92            let path = PathBuf::from(docker_config);
93            return Ok(path.join("config.json"));
94        }
95
96        // Fall back to ~/.docker/config.json
97        let home = std::env::var("HOME")
98            .or_else(|_| std::env::var("USERPROFILE"))
99            .map_err(|_| Error::ConfigParse("could not determine home directory".to_string()))?;
100
101        Ok(PathBuf::from(home).join(".docker").join("config.json"))
102    }
103
104    /// Gets the credential helper name for a registry.
105    ///
106    /// Returns the registry-specific helper from `credHelpers` if present,
107    /// otherwise falls back to `credsStore`.
108    pub fn get_credential_helper(&self, registry: &str) -> Option<&str> {
109        // Check registry-specific helper first
110        if let Some(helper) = self.cred_helpers.get(registry) {
111            return Some(helper.as_str());
112        }
113
114        // Normalize registry and try again
115        let normalized = normalize_registry(registry);
116        if let Some(helper) = self.cred_helpers.get(&normalized) {
117            return Some(helper.as_str());
118        }
119
120        // Fall back to global credential store
121        self.creds_store.as_deref()
122    }
123
124    /// Gets credentials from the `auths` section for a registry.
125    pub fn get_auth(&self, registry: &str) -> Option<Credential> {
126        // Try exact match first
127        if let Some(entry) = self.auths.get(registry)
128            && let Some(cred) = entry.to_credential()
129        {
130            return Some(cred);
131        }
132
133        // Normalize the input registry
134        let normalized = normalize_registry(registry);
135
136        // Try to find a matching entry by normalizing each auths key
137        for (key, entry) in &self.auths {
138            let normalized_key = normalize_registry(key);
139            if normalized_key == normalized
140                && let Some(cred) = entry.to_credential()
141            {
142                return Some(cred);
143            }
144        }
145
146        None
147    }
148}
149
150impl AuthEntry {
151    /// Converts this auth entry to a Credential.
152    pub fn to_credential(&self) -> Option<Credential> {
153        // Check for identity token first
154        if let Some(ref token) = self.identity_token
155            && !token.is_empty()
156        {
157            return Some(Credential::identity_token(token));
158        }
159
160        // Check for registry token
161        if let Some(ref token) = self.registry_token
162            && !token.is_empty()
163        {
164            return Some(Credential::bearer(token));
165        }
166
167        // Check for base64-encoded auth
168        if let Some(ref auth) = self.auth
169            && !auth.is_empty()
170        {
171            return Self::decode_auth(auth);
172        }
173
174        // Check for separate username/password
175        if let (Some(username), Some(password)) = (&self.username, &self.password)
176            && !username.is_empty()
177        {
178            return Some(Credential::basic(username, password));
179        }
180
181        None
182    }
183
184    /// Decodes a base64-encoded "username:password" auth string.
185    fn decode_auth(auth: &str) -> Option<Credential> {
186        use base64::Engine;
187
188        let decoded = base64::engine::general_purpose::STANDARD
189            .decode(auth)
190            .ok()?;
191        let decoded_str = String::from_utf8(decoded).ok()?;
192
193        // Split on first colon (password may contain colons)
194        let (username, password) = decoded_str.split_once(':')?;
195
196        if username.is_empty() {
197            None
198        } else {
199            Some(Credential::basic(username, password))
200        }
201    }
202}
203
204/// Normalizes a registry hostname for lookup.
205///
206/// Docker Hub can be referenced as "docker.io", "index.docker.io",
207/// "registry-1.docker.io", etc.
208fn normalize_registry(registry: &str) -> String {
209    let registry = registry
210        .trim_start_matches("https://")
211        .trim_start_matches("http://");
212    let registry = registry.trim_end_matches('/');
213
214    // Remove common path suffixes like /v1, /v2
215    let registry = registry.trim_end_matches("/v1").trim_end_matches("/v2");
216
217    // Normalize Docker Hub references
218    match registry {
219        "docker.io" | "registry-1.docker.io" => "index.docker.io".to_string(),
220        other => other.to_string(),
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::test_util::ENV_LOCK;
228
229    #[test]
230    fn test_parse_empty_config() {
231        let config: DockerConfig = serde_json::from_str("{}").unwrap();
232        assert!(config.auths.is_empty());
233        assert!(config.creds_store.is_none());
234        assert!(config.cred_helpers.is_empty());
235    }
236
237    #[test]
238    fn test_parse_auths_base64() {
239        let json = r#"{
240            "auths": {
241                "https://index.docker.io/v1/": {
242                    "auth": "dXNlcjpwYXNz"
243                }
244            }
245        }"#;
246
247        let config: DockerConfig = serde_json::from_str(json).unwrap();
248        let entry = config.auths.get("https://index.docker.io/v1/").unwrap();
249        let cred = entry.to_credential().unwrap();
250
251        assert_eq!(cred.username(), Some("user"));
252        assert_eq!(cred.password(), Some("pass"));
253    }
254
255    #[test]
256    fn test_parse_auths_plaintext() {
257        let json = r#"{
258            "auths": {
259                "gcr.io": {
260                    "username": "myuser",
261                    "password": "mypass"
262                }
263            }
264        }"#;
265
266        let config: DockerConfig = serde_json::from_str(json).unwrap();
267        let entry = config.auths.get("gcr.io").unwrap();
268        let cred = entry.to_credential().unwrap();
269
270        assert_eq!(cred.username(), Some("myuser"));
271        assert_eq!(cred.password(), Some("mypass"));
272    }
273
274    #[test]
275    fn test_parse_creds_store() {
276        let json = r#"{
277            "credsStore": "osxkeychain"
278        }"#;
279
280        let config: DockerConfig = serde_json::from_str(json).unwrap();
281        assert_eq!(config.creds_store.as_deref(), Some("osxkeychain"));
282    }
283
284    #[test]
285    fn test_parse_cred_helpers() {
286        let json = r#"{
287            "credHelpers": {
288                "gcr.io": "gcloud",
289                "123456789.dkr.ecr.us-east-1.amazonaws.com": "ecr-login"
290            }
291        }"#;
292
293        let config: DockerConfig = serde_json::from_str(json).unwrap();
294        assert_eq!(
295            config.cred_helpers.get("gcr.io"),
296            Some(&"gcloud".to_string())
297        );
298    }
299
300    #[test]
301    fn test_get_credential_helper() {
302        let json = r#"{
303            "credsStore": "osxkeychain",
304            "credHelpers": {
305                "gcr.io": "gcloud"
306            }
307        }"#;
308
309        let config: DockerConfig = serde_json::from_str(json).unwrap();
310
311        // Registry-specific helper takes precedence
312        assert_eq!(config.get_credential_helper("gcr.io"), Some("gcloud"));
313
314        // Falls back to credsStore for other registries
315        assert_eq!(
316            config.get_credential_helper("docker.io"),
317            Some("osxkeychain")
318        );
319    }
320
321    #[test]
322    fn test_normalize_registry() {
323        assert_eq!(normalize_registry("docker.io"), "index.docker.io");
324        assert_eq!(
325            normalize_registry("registry-1.docker.io"),
326            "index.docker.io"
327        );
328        assert_eq!(normalize_registry("gcr.io"), "gcr.io");
329        assert_eq!(normalize_registry("https://gcr.io/"), "gcr.io");
330    }
331
332    #[test]
333    fn test_get_auth_normalized() {
334        let json = r#"{
335            "auths": {
336                "https://index.docker.io/v1/": {
337                    "auth": "dXNlcjpwYXNz"
338                }
339            }
340        }"#;
341
342        let config: DockerConfig = serde_json::from_str(json).unwrap();
343
344        // Should find via various Docker Hub references
345        assert!(config.get_auth("index.docker.io").is_some());
346    }
347
348    #[test]
349    fn test_decode_auth_with_colon_in_password() {
350        // "user:pass:with:colons" base64 encoded
351        let auth = "dXNlcjpwYXNzOndpdGg6Y29sb25z";
352        let cred = AuthEntry::decode_auth(auth).unwrap();
353        assert_eq!(cred.username(), Some("user"));
354        assert_eq!(cred.password(), Some("pass:with:colons"));
355    }
356
357    #[test]
358    fn test_load_uses_docker_config_env() {
359        let _guard = ENV_LOCK.lock().unwrap();
360        let temp = tempfile::tempdir().unwrap();
361        let config_path = temp.path().join("config.json");
362
363        std::fs::write(
364            &config_path,
365            r#"{"auths":{"example.com":{"username":"user","password":"pass"}}}"#,
366        )
367        .unwrap();
368
369        let prev = std::env::var("DOCKER_CONFIG").ok();
370        unsafe {
371            std::env::set_var("DOCKER_CONFIG", temp.path());
372        }
373
374        let config = DockerConfig::load().unwrap();
375        let cred = config.get_auth("example.com").unwrap();
376        assert_eq!(cred.username(), Some("user"));
377        assert_eq!(cred.password(), Some("pass"));
378
379        if let Some(value) = prev {
380            unsafe {
381                std::env::set_var("DOCKER_CONFIG", value);
382            }
383        } else {
384            unsafe {
385                std::env::remove_var("DOCKER_CONFIG");
386            }
387        }
388    }
389}