docker_credentials_config/
lib.rs1use base64::{Engine, engine::general_purpose::STANDARD};
35use bollard::auth::DockerCredentials;
36use serde::{Deserialize, Deserializer};
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39
40pub use error::Error;
41
42mod error {
43 #[derive(Debug, thiserror::Error)]
45 pub enum Error {
46 #[error("Failed to parse Docker configuration file '{0}'")]
48 DockerConfigParseError(String),
49 #[error("Invalid base64 in auth field: {0}")]
51 AuthBase64Error(#[from] base64::DecodeError),
52 #[error("Auth field is not valid UTF-8: {0}")]
54 AuthUtf8Error(#[from] std::string::FromUtf8Error),
55 }
56}
57
58pub const DOCKER_CONFIG_FILENAME: &str = "config.json";
60
61pub const INDEX_NAME: &str = "docker.io";
63
64#[derive(Debug, Clone, Default, Deserialize)]
71#[serde(rename_all = "camelCase", default)]
72pub struct DockerConfig {
73 #[serde(deserialize_with = "deserialize_auths")]
76 pub auths: HashMap<String, DockerCredentials>,
77 pub creds_store: Option<String>,
79 pub cred_helpers: HashMap<String, String>,
81}
82
83impl DockerConfig {
84 pub async fn load() -> Result<Self, Error> {
92 let Some(path) = find_config_path() else {
93 return Ok(Self::default());
94 };
95 Self::load_from_file(&path).await
96 }
97
98 pub(crate) async fn load_from_file(path: &Path) -> Result<Self, Error> {
99 let contents = tokio::fs::read(path)
100 .await
101 .map_err(|_| Error::DockerConfigParseError(path.display().to_string()))?;
102 serde_json::from_slice::<DockerConfig>(&contents)
103 .map_err(|_| Error::DockerConfigParseError(path.display().to_string()))
104 }
105
106 pub fn credentials_for_registry(&self, registry: &str) -> Option<DockerCredentials> {
119 match docker_credential::get_credential(registry) {
120 Ok(docker_credential::DockerCredential::UsernamePassword(username, password)) => {
121 Some(DockerCredentials {
122 username: Some(username),
123 password: Some(password),
124 serveraddress: Some(registry.to_string()),
125 ..Default::default()
126 })
127 }
128 Ok(docker_credential::DockerCredential::IdentityToken(token)) => {
129 Some(DockerCredentials {
130 identitytoken: Some(token),
131 serveraddress: Some(registry.to_string()),
132 ..Default::default()
133 })
134 }
135 Err(_) => None,
136 }
137 }
138
139 pub fn all_credentials(&self) -> HashMap<String, DockerCredentials> {
149 let mut result = HashMap::new();
150 for registry in self.auths.keys().chain(self.cred_helpers.keys()) {
151 if let Some(creds) = self.credentials_for_registry(registry) {
152 result.insert(registry.clone(), creds);
153 }
154 }
155 result
156 }
157}
158
159pub fn image_registry(image: &str) -> String {
178 let image = image.split('@').next().unwrap_or(image);
180 let mut parts = image.splitn(2, '/');
182 let first = parts.next().unwrap_or(image);
183 if parts.next().is_none() {
185 return INDEX_NAME.to_string();
186 }
187 if first.contains('.') || first.contains(':') || first == "localhost" {
189 first.to_string()
190 } else {
191 INDEX_NAME.to_string()
192 }
193}
194
195fn find_config_path() -> Option<PathBuf> {
196 if let Ok(dir) = std::env::var("DOCKER_CONFIG") {
198 let path = PathBuf::from(dir).join(DOCKER_CONFIG_FILENAME);
199 if path.exists() {
200 return Some(path);
201 }
202 }
203 let base = directories::BaseDirs::new()?;
205 let path = base.home_dir().join(".docker").join(DOCKER_CONFIG_FILENAME);
206 if path.exists() { Some(path) } else { None }
207}
208
209#[derive(Deserialize, Default)]
211struct RawAuthEntry {
212 auth: Option<String>,
214 email: Option<String>,
215 identitytoken: Option<String>,
216}
217
218impl TryFrom<RawAuthEntry> for DockerCredentials {
219 type Error = Error;
220
221 fn try_from(entry: RawAuthEntry) -> Result<Self, Self::Error> {
222 let mut creds = DockerCredentials {
223 email: entry.email,
224 identitytoken: entry.identitytoken,
225 ..Default::default()
226 };
227 if let Some(auth_b64) = entry.auth {
228 let decoded = STANDARD.decode(auth_b64.trim())?;
229 let s = String::from_utf8(decoded)?;
230 if let Some((user, pass)) = s.split_once(':') {
231 creds.username = Some(user.to_string());
232 creds.password = Some(pass.to_string());
233 }
234 }
235 Ok(creds)
236 }
237}
238
239fn deserialize_auths<'de, D>(
240 deserializer: D,
241) -> Result<HashMap<String, DockerCredentials>, D::Error>
242where
243 D: Deserializer<'de>,
244{
245 let raw = HashMap::<String, RawAuthEntry>::deserialize(deserializer)?;
246 raw.into_iter()
247 .map(|(registry, entry): (String, RawAuthEntry)| {
248 let mut creds =
249 DockerCredentials::try_from(entry).map_err(|e| serde::de::Error::custom(e))?;
250 creds.serveraddress = Some(registry.clone());
251 Ok((registry, creds))
252 })
253 .collect()
254}
255
256#[cfg(test)]
257mod tests {
258 use super::{DockerConfig, image_registry};
259 use base64::Engine;
260
261 #[test]
264 fn test_registry_bare_name() {
265 assert_eq!(image_registry("ubuntu"), "docker.io");
266 }
267
268 #[test]
269 fn test_registry_with_tag() {
270 assert_eq!(image_registry("ubuntu:20.04"), "docker.io");
271 }
272
273 #[test]
274 fn test_registry_official_path() {
275 assert_eq!(image_registry("library/ubuntu"), "docker.io");
276 }
277
278 #[test]
279 fn test_registry_user_path() {
280 assert_eq!(image_registry("myuser/myimage:latest"), "docker.io");
281 }
282
283 #[test]
284 fn test_registry_explicit_registry() {
285 assert_eq!(image_registry("gcr.io/myproject/myimage:latest"), "gcr.io");
286 }
287
288 #[test]
289 fn test_registry_localhost_with_port() {
290 assert_eq!(
291 image_registry("localhost:5000/myimage:latest"),
292 "localhost:5000"
293 );
294 }
295
296 #[test]
297 fn test_registry_localhost_no_port() {
298 assert_eq!(image_registry("localhost/myimage"), "localhost");
299 }
300
301 #[test]
302 fn test_registry_with_digest() {
303 assert_eq!(
304 image_registry("gcr.io/myproject/myimage@sha256:abc123"),
305 "gcr.io"
306 );
307 }
308
309 #[test]
310 fn test_registry_bare_digest() {
311 assert_eq!(image_registry("ubuntu@sha256:abc123"), "docker.io");
312 }
313
314 #[tokio::test]
317 async fn test_load_from_file_parses_auths() {
318 use std::io::Write;
319 let mut tmp = tempfile::NamedTempFile::new().unwrap();
320 write!(
321 tmp,
322 r#"{{
323 "auths": {{
324 "https://index.docker.io/v1/": {{
325 "auth": "{}"
326 }}
327 }}
328 }}"#,
329 base64::engine::general_purpose::STANDARD.encode("alice:password123")
330 )
331 .unwrap();
332 let config = DockerConfig::load_from_file(tmp.path()).await.unwrap();
333 let creds = config.auths.get("https://index.docker.io/v1/").unwrap();
334 assert_eq!(creds.username.as_deref(), Some("alice"));
335 assert_eq!(creds.password.as_deref(), Some("password123"));
336 }
337
338 #[tokio::test]
339 async fn test_load_from_file_returns_error_on_invalid_json() {
340 use std::io::Write;
341 let mut tmp = tempfile::NamedTempFile::new().unwrap();
342 write!(tmp, "not valid json").unwrap();
343 let result = DockerConfig::load_from_file(tmp.path()).await;
344 assert!(matches!(
345 result,
346 Err(crate::Error::DockerConfigParseError(_))
347 ));
348 }
349
350 #[tokio::test]
351 async fn test_load_from_file_returns_error_on_missing_file() {
352 let result = DockerConfig::load_from_file(std::path::Path::new(
353 "/tmp/docker_credentials_config_test_nonexistent.json",
354 ))
355 .await;
356 assert!(matches!(
357 result,
358 Err(crate::Error::DockerConfigParseError(_))
359 ));
360 }
361
362 #[tokio::test]
363 async fn test_load_from_file_empty_config() {
364 use std::io::Write;
365 let mut tmp = tempfile::NamedTempFile::new().unwrap();
366 write!(tmp, "{{}}").unwrap();
367 let config = DockerConfig::load_from_file(tmp.path()).await.unwrap();
368 assert!(config.auths.is_empty());
369 assert!(config.creds_store.is_none());
370 assert!(config.cred_helpers.is_empty());
371 }
372}