Skip to main content

zlayer_core/auth/
docker_config.rs

1//! Docker config.json authentication parser
2//!
3//! This module parses the Docker config file format used by Docker and other container tools
4//! to store registry credentials. The config file is typically located at ~/.docker/config.json.
5
6use crate::error::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Docker config.json authentication manager
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DockerConfigAuth {
15    #[serde(default)]
16    auths: HashMap<String, AuthEntry>,
17}
18
19/// Authentication entry in Docker config
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct AuthEntry {
22    /// Base64-encoded "username:password"
23    auth: Option<String>,
24    /// Plain username (alternative to auth field)
25    username: Option<String>,
26    /// Plain password (alternative to auth field)
27    password: Option<String>,
28}
29
30impl DockerConfigAuth {
31    /// Load Docker config from the default location (`~/.docker/config.json`)
32    ///
33    /// # Errors
34    /// Returns an error if the home directory cannot be determined or the config
35    /// file exists but cannot be read or parsed.
36    pub fn load() -> Result<Self> {
37        let path = Self::default_config_path()?;
38        Self::load_from_path(&path)
39    }
40
41    /// Load Docker config from a specific path
42    ///
43    /// # Errors
44    /// Returns an error if the file exists but cannot be read or parsed.
45    pub fn load_from_path(path: &Path) -> Result<Self> {
46        if !path.exists() {
47            return Ok(Self {
48                auths: HashMap::new(),
49            });
50        }
51
52        let contents = fs::read_to_string(path).map_err(|e| {
53            crate::error::Error::config(format!("Failed to read Docker config: {e}"))
54        })?;
55
56        let config: DockerConfigAuth = serde_json::from_str(&contents).map_err(|e| {
57            crate::error::Error::config(format!("Failed to parse Docker config: {e}"))
58        })?;
59
60        Ok(config)
61    }
62
63    /// Get credentials for a specific registry
64    ///
65    /// Returns (username, password) if credentials are found for the registry.
66    /// The registry parameter should match the registry hostname (e.g., "docker.io", "ghcr.io").
67    #[must_use]
68    pub fn get_credentials(&self, registry: &str) -> Option<(String, String)> {
69        // Try exact match first
70        if let Some(entry) = self.auths.get(registry) {
71            return Self::extract_credentials(entry);
72        }
73
74        // Try with https:// prefix
75        let https_registry = format!("https://{registry}");
76        if let Some(entry) = self.auths.get(&https_registry) {
77            return Self::extract_credentials(entry);
78        }
79
80        // Try index.docker.io for docker.io
81        if registry == "docker.io" || registry == "registry-1.docker.io" {
82            if let Some(entry) = self.auths.get("https://index.docker.io/v1/") {
83                return Self::extract_credentials(entry);
84            }
85        }
86
87        None
88    }
89
90    /// Extract credentials from an auth entry
91    fn extract_credentials(entry: &AuthEntry) -> Option<(String, String)> {
92        // If username and password are provided directly
93        if let (Some(username), Some(password)) = (&entry.username, &entry.password) {
94            return Some((username.clone(), password.clone()));
95        }
96
97        // If auth field is provided (base64 encoded "username:password")
98        if let Some(auth) = &entry.auth {
99            return Self::decode_auth(auth);
100        }
101
102        None
103    }
104
105    /// Decode base64-encoded "username:password" auth string
106    fn decode_auth(auth: &str) -> Option<(String, String)> {
107        use base64::Engine;
108        let decoded = base64::engine::general_purpose::STANDARD
109            .decode(auth)
110            .ok()?;
111
112        let decoded_str = String::from_utf8(decoded).ok()?;
113        let parts: Vec<&str> = decoded_str.splitn(2, ':').collect();
114
115        if parts.len() == 2 {
116            Some((parts[0].to_string(), parts[1].to_string()))
117        } else {
118            None
119        }
120    }
121
122    /// Get the default Docker config path (~/.docker/config.json)
123    ///
124    /// Respects the `DOCKER_CONFIG` environment variable (standard Docker
125    /// convention). This is important when running under sudo where
126    /// `dirs::home_dir()` returns `/root` but the Docker config was written
127    /// by a non-root user.
128    fn default_config_path() -> Result<PathBuf> {
129        if let Ok(config_dir) = std::env::var("DOCKER_CONFIG") {
130            return Ok(PathBuf::from(config_dir).join("config.json"));
131        }
132
133        let home = dirs::home_dir().ok_or_else(|| {
134            crate::error::Error::config("Cannot determine home directory".to_string())
135        })?;
136
137        Ok(home.join(".docker").join("config.json"))
138    }
139
140    /// Get all configured registry hostnames
141    #[must_use]
142    pub fn registries(&self) -> Vec<String> {
143        self.auths.keys().cloned().collect()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_parse_docker_config() {
153        let config_json = r#"
154        {
155            "auths": {
156                "ghcr.io": {
157                    "auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
158                },
159                "docker.io": {
160                    "username": "myuser",
161                    "password": "mypass"
162                }
163            }
164        }
165        "#;
166
167        let config: DockerConfigAuth = serde_json::from_str(config_json).unwrap();
168
169        // Test base64 auth field
170        let (username, password) = config.get_credentials("ghcr.io").unwrap();
171        assert_eq!(username, "username");
172        assert_eq!(password, "password");
173
174        // Test plain username/password
175        let (username, password) = config.get_credentials("docker.io").unwrap();
176        assert_eq!(username, "myuser");
177        assert_eq!(password, "mypass");
178    }
179
180    #[test]
181    fn test_registry_normalization() {
182        let config_json = r#"
183        {
184            "auths": {
185                "https://ghcr.io": {
186                    "auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
187                },
188                "https://index.docker.io/v1/": {
189                    "auth": "ZG9ja2VyOnBhc3M="
190                }
191            }
192        }
193        "#;
194
195        let config: DockerConfigAuth = serde_json::from_str(config_json).unwrap();
196
197        // Should find with or without https://
198        assert!(config.get_credentials("ghcr.io").is_some());
199
200        // Should find docker.io credentials from index.docker.io
201        assert!(config.get_credentials("docker.io").is_some());
202    }
203
204    #[test]
205    fn test_decode_auth() {
206        // "username:password" in base64
207        let auth = "dXNlcm5hbWU6cGFzc3dvcmQ=";
208        let (username, password) = DockerConfigAuth::decode_auth(auth).unwrap();
209        assert_eq!(username, "username");
210        assert_eq!(password, "password");
211    }
212
213    #[test]
214    fn test_empty_config() {
215        let config = DockerConfigAuth {
216            auths: HashMap::new(),
217        };
218
219        assert!(config.get_credentials("docker.io").is_none());
220    }
221}