Skip to main content

docker_credentials_config/
lib.rs

1//! Load Docker client configuration and credentials from `~/.docker/config.json`.
2//!
3//! This crate reads the Docker CLI configuration file and resolves credentials
4//! for registries, including support for external credential helpers
5//! (`credsStore`, `credHelpers`) such as `osxkeychain`, `secretservice`, or `pass`.
6//!
7//! # Usage
8//!
9//! ## Loading credentials for a specific registry
10//!
11//! ```rust,no_run
12//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
13//! use docker_credentials_config::DockerConfig;
14//!
15//! let config = DockerConfig::load().await?;
16//! if let Some(creds) = config.credentials_for_registry("https://index.docker.io/v1/") {
17//!     println!("logged in as {:?}", creds.username);
18//! }
19//! # Ok(()) }
20//! ```
21//!
22//! ## Resolving credentials from an image name
23//!
24//! ```rust,no_run
25//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
26//! use docker_credentials_config::{DockerConfig, image_registry};
27//!
28//! let config = DockerConfig::load().await?;
29//! let registry = image_registry("gcr.io/myproject/myimage:latest");
30//! let creds = config.credentials_for_registry(&registry);
31//! # Ok(()) }
32//! ```
33
34use 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    /// Errors returned by this crate.
44    #[derive(Debug, thiserror::Error)]
45    pub enum Error {
46        /// The Docker configuration file exists but could not be read or parsed.
47        #[error("Failed to parse Docker configuration file '{0}'")]
48        DockerConfigParseError(String),
49        /// The `auth` field in a registry entry is not valid base64.
50        #[error("Invalid base64 in auth field: {0}")]
51        AuthBase64Error(#[from] base64::DecodeError),
52        /// The decoded `auth` field is not valid UTF-8.
53        #[error("Auth field is not valid UTF-8: {0}")]
54        AuthUtf8Error(#[from] std::string::FromUtf8Error),
55    }
56}
57
58/// Default Docker configuration filename inside the config directory.
59pub const DOCKER_CONFIG_FILENAME: &str = "config.json";
60
61/// Canonical name for the Docker Hub registry.
62pub const INDEX_NAME: &str = "docker.io";
63
64/// Parsed Docker client configuration, loaded from `~/.docker/config.json`.
65///
66/// Contains inline credentials and references to external credential helpers.
67/// Use [`DockerConfig::credentials_for_registry`] to resolve credentials for a
68/// specific registry, or [`DockerConfig::all_credentials`] to collect credentials
69/// for every known registry.
70#[derive(Debug, Clone, Default, Deserialize)]
71#[serde(rename_all = "camelCase", default)]
72pub struct DockerConfig {
73    /// Inline credentials from the `auths` section of `config.json`, keyed
74    /// by registry URL or hostname.
75    #[serde(deserialize_with = "deserialize_auths")]
76    pub auths: HashMap<String, DockerCredentials>,
77    /// Global credential store helper name (e.g. `"osxkeychain"`).
78    pub creds_store: Option<String>,
79    /// Per-registry credential helpers, keyed by registry hostname.
80    pub cred_helpers: HashMap<String, String>,
81}
82
83impl DockerConfig {
84    /// Load the Docker configuration from the standard location.
85    ///
86    /// Search order:
87    /// 1. `$DOCKER_CONFIG/config.json` (if `DOCKER_CONFIG` env var is set)
88    /// 2. `~/.docker/config.json`
89    ///
90    /// Returns an empty config if no file is found.
91    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    /// Resolve credentials for the given registry, including via external
107    /// credential helpers (`credsStore` / `credHelpers`).
108    ///
109    /// `registry` may be a hostname (`docker.io`, `gcr.io`) or a full URL
110    /// (`https://index.docker.io/v1/`). The lookup order is:
111    ///
112    /// 1. Per-registry credential helper (`credHelpers`)
113    /// 2. Inline `auth` or `identitytoken` in the `auths` section
114    /// 3. Global credential store (`credsStore`)
115    ///
116    /// Returns `None` if no credentials are found or the credential helper
117    /// fails (e.g. the helper binary is not installed).
118    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    /// Retrieve credentials for every registry that appears in the `auths`
140    /// or `credHelpers` sections of the config file.
141    ///
142    /// Suitable for populating the `X-Registry-Config` header used by
143    /// multi-registry operations such as `Docker::build_image`.
144    ///
145    /// Each registry is resolved via [`credentials_for_registry`](Self::credentials_for_registry),
146    /// so credential helpers are invoked as needed. Registries whose helper
147    /// fails or returns no credentials are silently omitted.
148    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
159/// Extract the registry hostname from a Docker image reference.
160///
161/// Follows the same rules as the Docker CLI:
162/// - A bare name like `ubuntu` or `ubuntu:20.04` maps to `docker.io`.
163/// - A path without a registry-like first component (e.g. `myuser/myimage`) maps to `docker.io`.
164/// - A first component containing `.`, `:`, or equal to `localhost` is treated as the registry.
165///
166/// Digests (`@sha256:...`) are stripped before parsing.
167///
168/// # Examples
169///
170/// ```
171/// use docker_credentials_config::image_registry;
172///
173/// assert_eq!(image_registry("ubuntu"), "docker.io");
174/// assert_eq!(image_registry("gcr.io/myproject/myimage:latest"), "gcr.io");
175/// assert_eq!(image_registry("localhost:5000/myimage"), "localhost:5000");
176/// ```
177pub fn image_registry(image: &str) -> String {
178    // Strip digest (@sha256:...)
179    let image = image.split('@').next().unwrap_or(image);
180    // Split at first '/'
181    let mut parts = image.splitn(2, '/');
182    let first = parts.next().unwrap_or(image);
183    // No '/' means it's a bare image name like "ubuntu:20.04"
184    if parts.next().is_none() {
185        return INDEX_NAME.to_string();
186    }
187    // First component is a registry if it contains '.', ':', or is "localhost"
188    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    // $DOCKER_CONFIG takes priority
197    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    // Fall back to ~/.docker/config.json
204    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/// Raw deserialization target for a single entry inside the `auths` map.
210#[derive(Deserialize, Default)]
211struct RawAuthEntry {
212    /// Base64-encoded `"username:password"`.
213    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    // --- image_registry ---
262
263    #[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    // --- DockerConfig::load_from_file ---
315
316    #[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}