const DOCKER_HUB_AUTH_KEY: &str = "https://index.docker.io/v1/";
pub(crate) fn auths_key(descriptor: &str) -> String {
let first = descriptor
.split('/')
.next()
.expect("split は必ず 1 要素以上を返すため到達しない");
if descriptor.contains('/')
&& (first.contains('.') || first.contains(':') || first == "localhost")
{
if first == "docker.io" || first == "index.docker.io" {
DOCKER_HUB_AUTH_KEY.to_string()
} else {
first.to_string()
}
} else {
DOCKER_HUB_AUTH_KEY.to_string()
}
}
pub(crate) fn x_registry_auth(descriptor: &str) -> Option<String> {
let config_json = load_config_json()?;
let key = auths_key(descriptor);
let auth_entry = extract_auth_entry(&config_json, &key)?;
Some(auth_entry)
}
fn load_config_json() -> Option<String> {
if let Ok(json) = std::env::var("DOCKER_AUTH_CONFIG")
&& !json.is_empty()
{
return Some(json);
}
if let Ok(dir) = std::env::var("DOCKER_CONFIG") {
let path = std::path::Path::new(&dir).join("config.json");
if let Ok(content) = std::fs::read_to_string(&path) {
return Some(content);
}
}
if let Some(home) = home_dir() {
let path = home.join(".docker").join("config.json");
if let Ok(content) = std::fs::read_to_string(&path) {
return Some(content);
}
}
None
}
fn home_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(std::path::PathBuf::from)
}
fn extract_auth_entry(config_json: &str, key: &str) -> Option<String> {
use base64ct::{Base64, Encoding};
let parsed = nojson::RawJson::parse(config_json).ok()?;
let value = parsed.value();
let auths_value = value.to_member("auths").and_then(|m| m.required()).ok()?;
let entry_value = auths_value.to_member(key).and_then(|m| m.required()).ok()?;
if let Ok(token_value) = entry_value
.to_member("identitytoken")
.and_then(|m| m.required())
&& let Ok(token_str) = TryInto::<String>::try_into(token_value)
&& !token_str.is_empty()
{
let header_json = format!(
"{{\"identitytoken\":\"{}\"}}",
escape_json_value(&token_str)
);
let encoded = Base64::encode_string(header_json.as_bytes());
return Some(encoded);
}
let auth_value = entry_value
.to_member("auth")
.and_then(|m| m.required())
.ok()?;
let auth_str: String = TryInto::<String>::try_into(auth_value).ok()?;
if auth_str.is_empty() {
return None;
}
let decoded_bytes = Base64::decode_vec(&auth_str).ok()?;
let decoded = String::from_utf8(decoded_bytes).ok()?;
let (username, password) = decoded.split_once(':')?;
let header_json = format!(
"{{\"username\":\"{}\",\"password\":\"{}\",\"serveraddress\":\"{}\"}}",
escape_json_value(username),
escape_json_value(password),
escape_json_value(key)
);
let encoded = Base64::encode_string(header_json.as_bytes());
Some(encoded)
}
fn escape_json_value(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\u{0008}' => escaped.push_str("\\b"),
'\u{000c}' => escaped.push_str("\\f"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c if (c as u32) < 0x20 => escaped.push_str(&format!("\\u{:04x}", c as u32)),
c => escaped.push(c),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
use base64ct::{Base64, Encoding};
const DOCKER_HUB_TEST_CHILD_ENV: &str = "SHIGUREDO_CONTAINER_REGISTRY_AUTH_TEST_CHILD";
#[test]
fn auths_key_docker_hub_for_plain_image() {
assert_eq!(auths_key("alpine:latest"), "https://index.docker.io/v1/");
assert_eq!(auths_key("library/nginx"), "https://index.docker.io/v1/");
}
#[test]
fn auths_key_docker_hub_normalizes_docker_io_references() {
assert_eq!(
auths_key("docker.io/org/private"),
"https://index.docker.io/v1/"
);
assert_eq!(
auths_key("index.docker.io/org/private"),
"https://index.docker.io/v1/"
);
assert_eq!(
auths_key("docker.io/library/nginx:latest"),
"https://index.docker.io/v1/"
);
}
#[test]
fn auths_key_private_registry() {
assert_eq!(auths_key("ghcr.io/org/app:tag"), "ghcr.io");
assert_eq!(auths_key("localhost:5000/myimage"), "localhost:5000");
assert_eq!(
auths_key("registry.example.com/img"),
"registry.example.com"
);
}
#[test]
fn auths_key_does_not_normalize_docker_hub_subdomains() {
assert_eq!(
auths_key("registry-1.docker.io/org/img"),
"registry-1.docker.io"
);
assert_eq!(auths_key("docker.io:5000/img"), "docker.io:5000");
}
#[test]
fn extract_auth_entry_decodes_base64_auth() {
let credentials = Base64::encode_string(b"user:pass");
let config =
format!(r#"{{"auths":{{"https://index.docker.io/v1/":{{"auth":"{credentials}"}}}}}}"#);
let result = extract_auth_entry(&config, "https://index.docker.io/v1/");
assert!(result.is_some(), "認証エントリが取得できること");
let decoded = Base64::decode_vec(&result.expect("認証エントリを取得できたこと"))
.expect("デコードできること");
let json_str = String::from_utf8(decoded).expect("UTF-8 であること");
assert!(
json_str.contains("\"username\":\"user\""),
"username が含まれること: {json_str}"
);
assert!(
json_str.contains("\"password\":\"pass\""),
"password が含まれること: {json_str}"
);
}
#[test]
fn x_registry_auth_picks_docker_hub_entry_for_docker_io_reference() {
let credentials = Base64::encode_string(b"user:pass");
let config =
format!(r#"{{"auths":{{"https://index.docker.io/v1/":{{"auth":"{credentials}"}}}}}}"#);
let executable = std::env::current_exe().expect("テストバイナリのパスを取得できること");
let status = std::process::Command::new(executable)
.args([
"--exact",
"core::client::registry_auth::tests::x_registry_auth_docker_hub_child",
])
.env(DOCKER_HUB_TEST_CHILD_ENV, "1")
.env("DOCKER_AUTH_CONFIG", config)
.status()
.expect("環境変数を渡す子テストを起動できること");
assert!(status.success(), "子テストが成功すること: {status}");
}
#[test]
fn x_registry_auth_docker_hub_child() {
if std::env::var_os(DOCKER_HUB_TEST_CHILD_ENV).is_none() {
return;
}
let result = x_registry_auth("docker.io/org/private")
.expect("docker.io 形式でも Docker Hub エントリを取得できること");
let decoded = Base64::decode_vec(&result).expect("デコードできること");
let json_str = String::from_utf8(decoded).expect("UTF-8 であること");
assert!(
json_str.contains("\"username\":\"user\""),
"username が含まれること: {json_str}"
);
assert!(
json_str.contains("\"password\":\"pass\""),
"password が含まれること: {json_str}"
);
assert!(
json_str.contains("\"serveraddress\":\"https://index.docker.io/v1/\""),
"serveraddress が含まれること: {json_str}"
);
}
#[test]
fn extract_auth_entry_returns_none_for_missing_key() {
let config = r#"{"auths":{"ghcr.io":{"auth":"dXNlcjpwYXNz"}}}"#;
assert!(extract_auth_entry(config, "https://index.docker.io/v1/").is_none());
}
#[test]
fn extract_auth_entry_returns_none_for_empty_auths() {
let config = r#"{"auths":{}}"#;
assert!(extract_auth_entry(config, "https://index.docker.io/v1/").is_none());
}
#[test]
fn escape_json_value_escapes_control_characters() {
assert_eq!(escape_json_value("a\nb"), "a\\nb");
assert_eq!(escape_json_value("a\rb"), "a\\rb");
assert_eq!(escape_json_value("a\tb"), "a\\tb");
assert_eq!(escape_json_value("a\u{0008}b"), "a\\bb");
assert_eq!(escape_json_value("a\u{000c}b"), "a\\fb");
assert_eq!(escape_json_value("a\u{0001}b"), "a\\u0001b");
assert_eq!(escape_json_value("a\"b\\c"), "a\\\"b\\\\c");
assert_eq!(escape_json_value("plain"), "plain");
}
#[test]
fn escape_json_value_roundtrips_through_nojson() {
for s in [
"",
"a\nb",
"a\rb",
"a\tb",
"a\u{0008}b",
"a\u{000c}b",
"a\u{0000}b",
"a\u{001f}b",
"a\u{0001}b",
"a\"b\\c",
"plain",
] {
let escaped = escape_json_value(s);
let json = format!("\"{escaped}\"");
let parsed = nojson::RawJson::parse(&json)
.expect("escape_json_value の出力は JSON 文字列としてパースできること");
let value = String::try_from(parsed.value()).expect("JSON 文字列値として読めること");
assert_eq!(value, s, "往復で元の文字列が復元されること");
}
}
#[test]
fn extract_auth_entry_with_control_chars_in_credentials() {
let credentials = Base64::encode_string("user\nline:pa\tss\u{0001}".as_bytes());
let config =
format!(r#"{{"auths":{{"https://index.docker.io/v1/":{{"auth":"{credentials}"}}}}}}"#);
let result = extract_auth_entry(&config, "https://index.docker.io/v1/")
.expect("認証エントリが取得できること");
let decoded = Base64::decode_vec(&result).expect("デコードできること");
let parsed =
nojson::RawJson::parse(std::str::from_utf8(&decoded).expect("UTF-8 であること"))
.expect("制御文字入り資格情報でもヘッダ JSON が有効であること");
let value = parsed.value();
let username: String = value
.to_member("username")
.and_then(|m| m.required())
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())
.expect("username が復元できること");
let password: String = value
.to_member("password")
.and_then(|m| m.required())
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())
.expect("password が復元できること");
assert_eq!(
username, "user\nline",
"改行を含む username が復元されること"
);
assert_eq!(
password, "pa\tss\u{0001}",
"タブと制御文字を含む password が復元されること"
);
}
#[test]
fn extract_auth_entry_with_control_chars_in_identitytoken() {
let config =
r#"{"auths":{"https://index.docker.io/v1/":{"identitytoken":"tok\nen\u0001"}}}"#;
let result = extract_auth_entry(config, "https://index.docker.io/v1/")
.expect("identitytoken が取得できること");
let decoded = Base64::decode_vec(&result).expect("デコードできること");
let parsed =
nojson::RawJson::parse(std::str::from_utf8(&decoded).expect("UTF-8 であること"))
.expect("制御文字入り identitytoken でもヘッダ JSON が有効であること");
let token: String = parsed
.value()
.to_member("identitytoken")
.and_then(|m| m.required())
.ok()
.and_then(|v| TryInto::<String>::try_into(v).ok())
.expect("identitytoken が復元できること");
assert_eq!(
token, "tok\nen\u{0001}",
"改行と制御文字を含む identitytoken が復元されること"
);
}
}