use base64::{Engine, engine::general_purpose::STANDARD};
use bollard::auth::DockerCredentials;
use serde::{Deserialize, Deserializer};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub use error::Error;
mod error {
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Failed to parse Docker configuration file '{0}'")]
DockerConfigParseError(String),
#[error("Invalid base64 in auth field: {0}")]
AuthBase64Error(#[from] base64::DecodeError),
#[error("Auth field is not valid UTF-8: {0}")]
AuthUtf8Error(#[from] std::string::FromUtf8Error),
}
}
pub const DOCKER_CONFIG_FILENAME: &str = "config.json";
pub const INDEX_NAME: &str = "docker.io";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DockerConfig {
#[serde(deserialize_with = "deserialize_auths")]
pub auths: HashMap<String, DockerCredentials>,
pub creds_store: Option<String>,
pub cred_helpers: HashMap<String, String>,
}
impl DockerConfig {
pub async fn load() -> Result<Self, Error> {
let Some(path) = find_config_path() else {
return Ok(Self::default());
};
Self::load_from_file(&path).await
}
pub(crate) async fn load_from_file(path: &Path) -> Result<Self, Error> {
let contents = tokio::fs::read(path)
.await
.map_err(|_| Error::DockerConfigParseError(path.display().to_string()))?;
serde_json::from_slice::<DockerConfig>(&contents)
.map_err(|_| Error::DockerConfigParseError(path.display().to_string()))
}
pub fn credentials_for_registry(&self, registry: &str) -> Option<DockerCredentials> {
match docker_credential::get_credential(registry) {
Ok(docker_credential::DockerCredential::UsernamePassword(username, password)) => {
Some(DockerCredentials {
username: Some(username),
password: Some(password),
serveraddress: Some(registry.to_string()),
..Default::default()
})
}
Ok(docker_credential::DockerCredential::IdentityToken(token)) => {
Some(DockerCredentials {
identitytoken: Some(token),
serveraddress: Some(registry.to_string()),
..Default::default()
})
}
Err(_) => None,
}
}
pub fn all_credentials(&self) -> HashMap<String, DockerCredentials> {
let mut result = HashMap::new();
for registry in self.auths.keys().chain(self.cred_helpers.keys()) {
if let Some(creds) = self.credentials_for_registry(registry) {
result.insert(registry.clone(), creds);
}
}
result
}
}
pub fn image_registry(image: &str) -> String {
let image = image.split('@').next().unwrap_or(image);
let mut parts = image.splitn(2, '/');
let first = parts.next().unwrap_or(image);
if parts.next().is_none() {
return INDEX_NAME.to_string();
}
if first.contains('.') || first.contains(':') || first == "localhost" {
first.to_string()
} else {
INDEX_NAME.to_string()
}
}
fn find_config_path() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("DOCKER_CONFIG") {
let path = PathBuf::from(dir).join(DOCKER_CONFIG_FILENAME);
if path.exists() {
return Some(path);
}
}
let base = directories::BaseDirs::new()?;
let path = base.home_dir().join(".docker").join(DOCKER_CONFIG_FILENAME);
if path.exists() { Some(path) } else { None }
}
#[derive(Deserialize, Default)]
struct RawAuthEntry {
auth: Option<String>,
email: Option<String>,
identitytoken: Option<String>,
}
impl TryFrom<RawAuthEntry> for DockerCredentials {
type Error = Error;
fn try_from(entry: RawAuthEntry) -> Result<Self, Self::Error> {
let mut creds = DockerCredentials {
email: entry.email,
identitytoken: entry.identitytoken,
..Default::default()
};
if let Some(auth_b64) = entry.auth {
let decoded = STANDARD.decode(auth_b64.trim())?;
let s = String::from_utf8(decoded)?;
if let Some((user, pass)) = s.split_once(':') {
creds.username = Some(user.to_string());
creds.password = Some(pass.to_string());
}
}
Ok(creds)
}
}
fn deserialize_auths<'de, D>(
deserializer: D,
) -> Result<HashMap<String, DockerCredentials>, D::Error>
where
D: Deserializer<'de>,
{
let raw = HashMap::<String, RawAuthEntry>::deserialize(deserializer)?;
raw.into_iter()
.map(|(registry, entry): (String, RawAuthEntry)| {
let mut creds =
DockerCredentials::try_from(entry).map_err(|e| serde::de::Error::custom(e))?;
creds.serveraddress = Some(registry.clone());
Ok((registry, creds))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{DockerConfig, image_registry};
use base64::Engine;
#[test]
fn test_registry_bare_name() {
assert_eq!(image_registry("ubuntu"), "docker.io");
}
#[test]
fn test_registry_with_tag() {
assert_eq!(image_registry("ubuntu:20.04"), "docker.io");
}
#[test]
fn test_registry_official_path() {
assert_eq!(image_registry("library/ubuntu"), "docker.io");
}
#[test]
fn test_registry_user_path() {
assert_eq!(image_registry("myuser/myimage:latest"), "docker.io");
}
#[test]
fn test_registry_explicit_registry() {
assert_eq!(image_registry("gcr.io/myproject/myimage:latest"), "gcr.io");
}
#[test]
fn test_registry_localhost_with_port() {
assert_eq!(
image_registry("localhost:5000/myimage:latest"),
"localhost:5000"
);
}
#[test]
fn test_registry_localhost_no_port() {
assert_eq!(image_registry("localhost/myimage"), "localhost");
}
#[test]
fn test_registry_with_digest() {
assert_eq!(
image_registry("gcr.io/myproject/myimage@sha256:abc123"),
"gcr.io"
);
}
#[test]
fn test_registry_bare_digest() {
assert_eq!(image_registry("ubuntu@sha256:abc123"), "docker.io");
}
#[tokio::test]
async fn test_load_from_file_parses_auths() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(
tmp,
r#"{{
"auths": {{
"https://index.docker.io/v1/": {{
"auth": "{}"
}}
}}
}}"#,
base64::engine::general_purpose::STANDARD.encode("alice:password123")
)
.unwrap();
let config = DockerConfig::load_from_file(tmp.path()).await.unwrap();
let creds = config.auths.get("https://index.docker.io/v1/").unwrap();
assert_eq!(creds.username.as_deref(), Some("alice"));
assert_eq!(creds.password.as_deref(), Some("password123"));
}
#[tokio::test]
async fn test_load_from_file_returns_error_on_invalid_json() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "not valid json").unwrap();
let result = DockerConfig::load_from_file(tmp.path()).await;
assert!(matches!(
result,
Err(crate::Error::DockerConfigParseError(_))
));
}
#[tokio::test]
async fn test_load_from_file_returns_error_on_missing_file() {
let result = DockerConfig::load_from_file(std::path::Path::new(
"/tmp/docker_credentials_config_test_nonexistent.json",
))
.await;
assert!(matches!(
result,
Err(crate::Error::DockerConfigParseError(_))
));
}
#[tokio::test]
async fn test_load_from_file_empty_config() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "{{}}").unwrap();
let config = DockerConfig::load_from_file(tmp.path()).await.unwrap();
assert!(config.auths.is_empty());
assert!(config.creds_store.is_none());
assert!(config.cred_helpers.is_empty());
}
}