use std::path::PathBuf;
use std::process::{Command, Stdio};
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use eyre::{Context, Result, bail};
use serde::Deserialize;
use crate::cmd::{RunningPidGuard, prepare_noninteractive_child};
use crate::env;
#[derive(Debug, Clone)]
pub(crate) struct Credential {
pub username: String,
pub secret: String,
}
impl Credential {
pub(crate) fn basic_auth_header(&self) -> String {
let raw = format!("{}:{}", self.username, self.secret);
format!("Basic {}", BASE64_STANDARD.encode(raw))
}
}
#[derive(Debug, Default, Deserialize)]
struct AuthFile {
#[serde(default)]
auths: indexmap::IndexMap<String, AuthEntry>,
#[serde(default, rename = "credHelpers")]
cred_helpers: indexmap::IndexMap<String, String>,
#[serde(default, rename = "credsStore")]
creds_store: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct AuthEntry {
#[serde(default)]
auth: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
password: Option<String>,
#[serde(default, rename = "identitytoken")]
identity_token: Option<String>,
}
pub(crate) fn resolve_credential(registry: &str) -> Result<Option<Credential>> {
for path in auth_file_paths() {
if !path.is_file() {
continue;
}
let raw = match crate::file::read_to_string(&path) {
Ok(raw) => raw,
Err(e) => {
warn!("skipping unreadable auth file {}: {e}", path.display());
continue;
}
};
let file: AuthFile = match serde_json::from_str(&raw) {
Ok(f) => f,
Err(e) => {
warn!("skipping malformed auth file {}: {e}", path.display());
continue;
}
};
match credential_from_file(&file, registry) {
Ok(Some(cred)) => {
debug!("using registry credentials from {}", path.display());
return Ok(Some(cred));
}
Ok(None) => {}
Err(e) => warn!("skipping auth file {} ({e})", path.display()),
}
}
Ok(None)
}
fn auth_file_paths() -> Vec<PathBuf> {
let mut paths = vec![];
if let Some(p) = env::var_os("REGISTRY_AUTH_FILE") {
paths.push(PathBuf::from(p));
}
if let Some(p) = env::var_os("XDG_RUNTIME_DIR") {
paths.push(PathBuf::from(p).join("containers/auth.json"));
}
paths.push(env::XDG_CONFIG_HOME.join("containers/auth.json"));
if let Some(p) = env::var_os("DOCKER_CONFIG") {
paths.push(PathBuf::from(p).join("config.json"));
} else {
paths.push(env::HOME.join(".docker/config.json"));
}
paths
}
fn credential_from_file(file: &AuthFile, registry: &str) -> Result<Option<Credential>> {
let aliases = registry_aliases(registry);
for (key, helper) in &file.cred_helpers {
if key_matches(key, &aliases) {
match run_credential_helper(helper, registry) {
Ok(cred) => return Ok(Some(cred)),
Err(e) => {
debug!("credHelpers helper {helper} has no credentials for {registry}: {e}");
}
}
}
}
for (key, entry) in &file.auths {
if !key_matches(key, &aliases) {
continue;
}
match credential_from_entry(entry, key) {
Ok(Some(cred)) => return Ok(Some(cred)),
Ok(None) => {}
Err(e) => warn!("ignoring malformed auth entry for {key}: {e}"),
}
}
if let Some(helper) = &file.creds_store {
match run_credential_helper(helper, registry) {
Ok(cred) => return Ok(Some(cred)),
Err(e) => {
debug!("credsStore helper {helper} has no credentials for {registry}: {e}");
}
}
}
Ok(None)
}
fn credential_from_entry(entry: &AuthEntry, key: &str) -> Result<Option<Credential>> {
let (mut username, mut secret) = (entry.username.clone(), entry.password.clone());
if let Some(auth) = entry.auth.as_deref().filter(|a| !a.is_empty()) {
let decoded = BASE64_STANDARD
.decode(auth.trim())
.wrap_err_with(|| format!("decoding base64 `auth` for {key}"))?;
let decoded = String::from_utf8(decoded)
.wrap_err_with(|| format!("`auth` for {key} is not valid UTF-8"))?;
let Some((u, p)) = decoded.split_once(':') else {
bail!("`auth` for {key} is not `user:password`");
};
username = Some(u.to_string());
secret = Some(p.to_string());
}
if let Some(token) = entry.identity_token.as_deref().filter(|t| !t.is_empty()) {
return Ok(Some(Credential {
username: "<token>".to_string(),
secret: token.to_string(),
}));
}
match (username, secret) {
(Some(u), Some(p)) => Ok(Some(Credential {
username: u,
secret: p,
})),
_ => Ok(None),
}
}
fn registry_aliases(registry: &str) -> Vec<String> {
let mut aliases = vec![registry.to_string()];
if matches!(
registry,
"docker.io" | "index.docker.io" | "registry-1.docker.io"
) {
aliases.extend([
"docker.io".into(),
"index.docker.io".into(),
"registry-1.docker.io".into(),
"https://index.docker.io/v1/".into(),
]);
}
aliases
}
fn key_matches(key: &str, aliases: &[String]) -> bool {
if aliases.iter().any(|a| a == key) {
return true;
}
let stripped = key
.trim_start_matches("https://")
.trim_start_matches("http://");
let host = stripped.split('/').next().unwrap_or(stripped);
aliases.iter().any(|a| a == host)
}
fn run_credential_helper(helper: &str, registry: &str) -> Result<Credential> {
let bin = format!("docker-credential-{helper}");
let server = if registry == "docker.io" || registry == "registry-1.docker.io" {
"https://index.docker.io/v1/"
} else {
registry
};
debug!("running {bin} get for {server}");
let mut command = Command::new(&bin);
command
.arg("get")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
prepare_noninteractive_child(&mut command);
let mut child = command
.spawn()
.wrap_err_with(|| format!("spawning {bin} (from credHelpers/credsStore)"))?;
let _running_pid = RunningPidGuard::new(Some(child.id()));
{
use std::io::Write;
let mut stdin = child.stdin.take().expect("stdin piped");
stdin.write_all(server.as_bytes())?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
bail!(
"{bin} get failed for {server}: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
#[derive(Deserialize)]
struct HelperResponse {
#[serde(rename = "Username")]
username: String,
#[serde(rename = "Secret")]
secret: String,
}
let resp: HelperResponse = serde_json::from_slice(&out.stdout)
.wrap_err_with(|| format!("parsing {bin} get output"))?;
Ok(Credential {
username: resp.username,
secret: resp.secret,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> AuthFile {
serde_json::from_str(json).unwrap()
}
#[test]
fn inline_auth_base64() {
let file = parse(r#"{"auths": {"ghcr.io": {"auth": "dXNlcjpwYXNz"}}}"#);
let cred = credential_from_file(&file, "ghcr.io").unwrap().unwrap();
assert_eq!(cred.username, "user");
assert_eq!(cred.secret, "pass");
}
#[test]
fn inline_auth_username_password() {
let file = parse(r#"{"auths": {"ghcr.io": {"username": "u", "password": "p"}}}"#);
let cred = credential_from_file(&file, "ghcr.io").unwrap().unwrap();
assert_eq!(cred.username, "u");
assert_eq!(cred.secret, "p");
}
#[test]
fn dockerhub_legacy_key_matches() {
let file = parse(r#"{"auths": {"https://index.docker.io/v1/": {"auth": "dXNlcjpwYXNz"}}}"#);
let cred = credential_from_file(&file, "docker.io").unwrap().unwrap();
assert_eq!(cred.username, "user");
}
#[test]
fn scheme_prefixed_key_matches() {
let file = parse(r#"{"auths": {"https://ghcr.io": {"auth": "dXNlcjpwYXNz"}}}"#);
assert!(credential_from_file(&file, "ghcr.io").unwrap().is_some());
}
#[test]
fn identity_token_wins() {
let file =
parse(r#"{"auths": {"ghcr.io": {"auth": "dXNlcjpwYXNz", "identitytoken": "idtok"}}}"#);
let cred = credential_from_file(&file, "ghcr.io").unwrap().unwrap();
assert_eq!(cred.username, "<token>");
assert_eq!(cred.secret, "idtok");
}
#[test]
fn no_entry_returns_none() {
let file = parse(r#"{"auths": {"ghcr.io": {"auth": "dXNlcjpwYXNz"}}}"#);
assert!(credential_from_file(&file, "quay.io").unwrap().is_none());
}
#[test]
fn empty_auth_entry_is_none() {
let file = parse(r#"{"auths": {"ghcr.io": {}}}"#);
assert!(credential_from_file(&file, "ghcr.io").unwrap().is_none());
}
#[test]
fn malformed_entry_is_skipped_not_fatal() {
let file = parse(r#"{"auths": {"ghcr.io": {"auth": "!!!"}}}"#);
assert!(credential_from_file(&file, "ghcr.io").unwrap().is_none());
}
#[test]
fn malformed_entry_falls_through_to_other_entry() {
assert!(
credential_from_entry(
&AuthEntry {
auth: Some("!!!".into()),
..Default::default()
},
"ghcr.io"
)
.is_err()
);
}
#[test]
fn basic_auth_header_roundtrip() {
let cred = Credential {
username: "user".into(),
secret: "pass".into(),
};
assert_eq!(cred.basic_auth_header(), "Basic dXNlcjpwYXNz");
}
}