1
0
Fork 0
mirror of https://github.com/imjasonh/krust synced 2026-07-08 06:45:32 +00:00

feat: Add credential helper support to oci-distribution

- Move Docker config parsing and credential helper execution from krust
- Add automatic auth resolution methods (*_auto) to the client
- Support standard Docker config locations and environment variables
- Add comprehensive tests and examples
- Simplify krust to use the new credential helper functionality
This commit is contained in:
Jason Hall 2025-06-08 10:30:41 -04:00
parent 4af701e617
commit a6ae83c3b2
Failed to extract signature
12 changed files with 887 additions and 15 deletions

View file

@ -32,3 +32,7 @@ base64 = "0.22"
tempfile = "3.9" tempfile = "3.9"
assert_cmd = "2.0" assert_cmd = "2.0"
predicates = "3.0" predicates = "3.0"
[[example]]
name = "auto_auth_demo"
path = "examples/auto_auth_demo.rs"

View file

@ -0,0 +1,71 @@
//! Example demonstrating automatic authentication with oci-distribution
//!
//! This example shows how to use the automatic auth methods that resolve
//! credentials from Docker config files and credential helpers.
use anyhow::Result;
use oci_distribution::{client::Client, Reference};
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt::init();
// Create a client
let client = Client::default();
// Example 1: Pull a public image (anonymous auth)
println!("Pulling public image alpine:latest...");
let reference: Reference = "docker.io/library/alpine:latest".parse()?;
match client.pull_manifest_auto(&reference).await {
Ok((manifest, digest)) => {
println!("✓ Successfully pulled manifest");
println!(" Digest: {}", digest);
match manifest {
oci_distribution::manifest::OciManifest::Image(img) => {
println!(" Type: Single platform image");
println!(" Config: {}", img.config.digest);
}
oci_distribution::manifest::OciManifest::ImageIndex(idx) => {
println!(" Type: Multi-platform image");
println!(" Platforms: {}", idx.manifests.len());
}
}
}
Err(e) => {
println!("✗ Failed to pull: {}", e);
}
}
// Example 2: Get platforms for an image
println!("\nDetecting platforms for rust:alpine...");
let rust_ref: Reference = "docker.io/library/rust:alpine".parse()?;
match client.get_image_platforms_auto(&rust_ref).await {
Ok(platforms) => {
println!("✓ Found {} platforms:", platforms.len());
for (os, arch) in platforms {
println!(" - {}/{}", os, arch);
}
}
Err(e) => {
println!("✗ Failed to get platforms: {}", e);
}
}
// Example 3: Private registry (requires auth)
println!("\nNote: To test with a private registry, ensure you have credentials in:");
println!(" - ~/.docker/config.json");
println!(" - Or DOCKER_CONFIG environment variable");
println!(" - Or using a credential helper");
// Uncomment to test with a private registry:
// let private_ref: Reference = "ghcr.io/your-username/your-image:latest".parse()?;
// match client.pull_manifest_auto(&private_ref).await {
// Ok((_, digest)) => println!("✓ Successfully authenticated and pulled private image: {}", digest),
// Err(e) => println!("✗ Failed to pull private image: {}", e),
// }
Ok(())
}

View file

@ -9,8 +9,10 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
mod keychain; mod keychain;
mod simple;
pub use keychain::{DefaultKeychain, Keychain}; pub use keychain::{DefaultKeychain, Keychain};
pub use simple::resolve_auth;
/// Authentication configuration containing credentials /// Authentication configuration containing credentials
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]

11
src/auth/simple.rs Normal file
View file

@ -0,0 +1,11 @@
//! Simple authentication wrapper using oci-distribution's credential helper
use anyhow::Result;
use oci_distribution::credential_helper::resolve_docker_auth;
use oci_distribution::secrets::RegistryAuth;
/// Resolve authentication for a given resource using Docker config and credential helpers
pub fn resolve_auth(resource: &str) -> Result<RegistryAuth> {
resolve_docker_auth(resource)
.map_err(|e| anyhow::anyhow!("Failed to resolve auth: {}", e))
}

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use krust::{ use krust::{
auth::{DefaultKeychain, Keychain}, auth::resolve_auth,
builder::{get_rust_target_triple, RustBuilder}, builder::{get_rust_target_triple, RustBuilder},
cli::{Cli, Commands}, cli::{Cli, Commands},
config::Config, config::Config,
@ -59,8 +59,7 @@ async fn main() -> Result<()> {
format!("{}/{}:latest", repo, project_name) format!("{}/{}:latest", repo, project_name)
}; };
// Initialize registry client and keychain // Initialize registry client
let keychain = DefaultKeychain::new();
let mut registry_client = RegistryClient::new()?; let mut registry_client = RegistryClient::new()?;
// Determine platforms to build for // Determine platforms to build for
@ -74,10 +73,7 @@ async fn main() -> Result<()> {
base_image base_image
); );
// Get auth for the base image registry // Get auth for the base image registry
let base_auth = keychain let base_auth = resolve_auth(&base_image)?;
.resolve(&base_image)?
.authorization()?
.to_registry_auth();
match registry_client match registry_client
.get_image_platforms(&base_image, &base_auth) .get_image_platforms(&base_image, &base_auth)
@ -143,10 +139,7 @@ async fn main() -> Result<()> {
let platform_ref = format!("{}:{}", base_ref, platform_tag); let platform_ref = format!("{}:{}", base_ref, platform_tag);
// Get auth for the target registry // Get auth for the target registry
let push_auth = keychain let push_auth = resolve_auth(&platform_ref)?;
.resolve(&platform_ref)?
.authorization()?
.to_registry_auth();
let (digest_ref, manifest_size) = registry_client let (digest_ref, manifest_size) = registry_client
.push_image(&platform_ref, config_data, layers, &push_auth) .push_image(&platform_ref, config_data, layers, &push_auth)
@ -188,10 +181,7 @@ async fn main() -> Result<()> {
info!("Creating and pushing manifest list..."); info!("Creating and pushing manifest list...");
// Get auth for the final image push // Get auth for the final image push
let final_auth = keychain let final_auth = resolve_auth(&image_ref)?;
.resolve(&image_ref)?
.authorization()?
.to_registry_auth();
let manifest_list_ref = registry_client let manifest_list_ref = registry_client
.push_manifest_list(&image_ref, manifest_descriptors, &final_auth) .push_manifest_list(&image_ref, manifest_descriptors, &final_auth)

View file

@ -0,0 +1,111 @@
//! Integration tests for credential helper functionality
use anyhow::Result;
use krust::auth::resolve_auth;
use oci_distribution::secrets::RegistryAuth;
use std::env;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_resolve_auth_anonymous() -> Result<()> {
// Create empty temp directory for Docker config
let tmp_dir = TempDir::new()?;
// Save current env vars
let old_docker_config = env::var("DOCKER_CONFIG").ok();
let old_registry_auth = env::var("REGISTRY_AUTH_FILE").ok();
// Set to empty directory
env::set_var("DOCKER_CONFIG", tmp_dir.path());
env::remove_var("REGISTRY_AUTH_FILE");
// Should resolve to anonymous
let auth = resolve_auth("docker.io/library/alpine")?;
assert!(matches!(auth, RegistryAuth::Anonymous));
// Restore env vars
if let Some(val) = old_docker_config {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
if let Some(val) = old_registry_auth {
env::set_var("REGISTRY_AUTH_FILE", val);
} else {
env::remove_var("REGISTRY_AUTH_FILE");
}
Ok(())
}
#[test]
fn test_resolve_auth_from_config() -> Result<()> {
let tmp_dir = TempDir::new()?;
let config_path = tmp_dir.path().join("config.json");
// Create a test config
let config = r#"{
"auths": {
"test.registry.io": {
"username": "testuser",
"password": "testpass"
}
}
}"#;
fs::write(&config_path, config)?;
// Save current env var
let old_val = env::var("DOCKER_CONFIG").ok();
env::set_var("DOCKER_CONFIG", tmp_dir.path());
// Should resolve to basic auth
let auth = resolve_auth("test.registry.io/myimage")?;
assert!(matches!(auth, RegistryAuth::Basic(user, pass)
if user == "testuser" && pass == "testpass"));
// Restore env var
if let Some(val) = old_val {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
Ok(())
}
#[test]
fn test_resolve_auth_bearer_token() -> Result<()> {
let tmp_dir = TempDir::new()?;
let config_path = tmp_dir.path().join("config.json");
// Create a test config with bearer token
let config = r#"{
"auths": {
"ghcr.io": {
"registrytoken": "test-bearer-token"
}
}
}"#;
fs::write(&config_path, config)?;
// Save current env var
let old_val = env::var("DOCKER_CONFIG").ok();
env::set_var("DOCKER_CONFIG", tmp_dir.path());
// Should resolve to bearer auth
let auth = resolve_auth("ghcr.io/user/image")?;
assert!(matches!(auth, RegistryAuth::Bearer(token)
if token == "test-bearer-token"));
// Restore env var
if let Some(val) = old_val {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
Ok(())
}

View file

@ -32,8 +32,10 @@ trust-dns = ["reqwest/trust-dns"]
test-registry = [] test-registry = []
[dependencies] [dependencies]
base64 = "0.22"
bytes = "1" bytes = "1"
chrono = { version = "0.4.23", features = ["serde"] } chrono = { version = "0.4.23", features = ["serde"] }
dirs = "6.0"
futures-util = "0.3" futures-util = "0.3"
http = "1.1" http = "1.1"
http-auth = { version = "0.1", default_features = false } http-auth = { version = "0.1", default_features = false }

View file

@ -918,6 +918,45 @@ impl Client {
}) })
} }
/// Pull a manifest with automatic auth resolution using Docker config and credential helpers
pub async fn pull_manifest_auto(
&self,
image: &Reference,
) -> Result<(OciManifest, String)> {
let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?;
self.pull_manifest(image, &auth).await
}
/// Pull an image with automatic auth resolution using Docker config and credential helpers
pub async fn pull_auto(
&self,
image: &Reference,
) -> Result<ImageData> {
let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?;
self.pull(image, &auth, vec![]).await
}
/// Push an image with automatic auth resolution using Docker config and credential helpers
pub async fn push_auto(
&self,
image_ref: &Reference,
layers: &[ImageLayer],
config: Config,
manifest: Option<OciImageManifest>,
) -> Result<PushResponse> {
let auth = crate::credential_helper::resolve_docker_auth(&image_ref.to_string())?;
self.push(image_ref, layers, config, &auth, manifest).await
}
/// Get image platforms with automatic auth resolution
pub async fn get_image_platforms_auto(
&self,
image: &Reference,
) -> Result<Vec<(String, String)>> {
let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?;
self.get_image_platforms(image, &auth).await
}
async fn _pull_manifest_and_config( async fn _pull_manifest_and_config(
&self, &self,
image: &Reference, image: &Reference,

View file

@ -0,0 +1,165 @@
//! Docker credential helper support
use crate::docker_config::{DockerAuthEntry, DockerConfig, extract_registry, load_docker_config, normalize_registry};
use crate::errors::{OciDistributionError, Result};
use crate::secrets::RegistryAuth;
use serde::Deserialize;
use std::io::Write;
use std::process::{Command, Stdio};
use tracing::{debug, warn};
/// Response from a Docker credential helper
#[derive(Deserialize)]
struct HelperResponse {
#[serde(rename = "Username")]
username: Option<String>,
#[serde(rename = "Secret")]
secret: Option<String>,
#[serde(rename = "ServerURL")]
_server_url: Option<String>,
}
/// Execute a credential helper to get credentials
pub fn execute_credential_helper(helper: &str, registry: &str) -> Result<(String, String)> {
let helper_name = format!("docker-credential-{}", helper);
debug!("Executing credential helper: {} for {}", helper_name, registry);
let mut child = Command::new(&helper_name)
.arg("get")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| OciDistributionError::GenericError(Some(
format!("Failed to spawn credential helper {}: {}", helper_name, e)
)))?;
// Write registry URL to stdin
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(registry.as_bytes()).map_err(|e| {
OciDistributionError::IoError(e)
})?;
stdin.write_all(b"\n").map_err(|e| {
OciDistributionError::IoError(e)
})?;
}
let output = child.wait_with_output().map_err(|e| {
OciDistributionError::IoError(e)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(OciDistributionError::GenericError(Some(
format!("Credential helper {} failed: {}", helper_name, stderr)
)));
}
// Parse output as JSON
let response: HelperResponse = serde_json::from_slice(&output.stdout)
.map_err(|e| OciDistributionError::GenericError(Some(
format!("Failed to parse credential helper response: {}", e)
)))?;
match (response.username, response.secret) {
(Some(username), Some(password)) => Ok((username, password)),
_ => Err(OciDistributionError::GenericError(Some(
"Credential helper did not return username and password".to_string()
)))
}
}
/// Find auth entry for a registry in Docker config
fn find_auth_entry(config: &DockerConfig, registry: &str) -> Option<DockerAuthEntry> {
let variants = normalize_registry(registry);
for variant in variants {
if let Some(entry) = config.auths.get(&variant) {
return Some(entry.clone());
}
}
None
}
/// Get credential helper for a registry
fn get_credential_helper(config: &DockerConfig, registry: &str) -> Option<String> {
// Check specific credential helper for registry
if let Some(helper) = config.cred_helpers.get(registry) {
return Some(helper.clone());
}
// Check default credential store
config.creds_store.clone()
}
/// Resolve authentication for a given resource using Docker config and credential helpers
pub fn resolve_docker_auth(resource: &str) -> Result<RegistryAuth> {
let config = load_docker_config()?;
let registry = extract_registry(resource);
debug!("Resolving auth for resource: {} (registry: {})", resource, registry);
// Try to find auth entry in config
if let Some(auth_entry) = find_auth_entry(&config, registry) {
debug!("Found auth entry for {}", registry);
// Check if it's anonymous
if auth_entry.is_anonymous() {
return Ok(RegistryAuth::Anonymous);
}
// Check for bearer tokens first
if let Some(token) = auth_entry.registry_token {
return Ok(RegistryAuth::Bearer(token));
}
if let Some(token) = auth_entry.identity_token {
return Ok(RegistryAuth::Bearer(token));
}
// Then check for basic auth
if let (Some(username), Some(password)) = (&auth_entry.username, &auth_entry.password) {
return Ok(RegistryAuth::Basic(username.clone(), password.clone()));
}
// Try to decode base64 auth string
if let Some(auth) = &auth_entry.auth {
use base64::Engine;
match base64::engine::general_purpose::STANDARD.decode(auth) {
Ok(decoded) => {
if let Ok(decoded_str) = String::from_utf8(decoded) {
if let Some((user, pass)) = decoded_str.split_once(':') {
return Ok(RegistryAuth::Basic(user.to_string(), pass.to_string()));
}
}
}
Err(e) => {
warn!("Failed to decode auth for {}: {}", registry, e);
}
}
}
}
// Try credential helper
if let Some(helper) = get_credential_helper(&config, registry) {
debug!("Trying credential helper: {} for {}", helper, registry);
match execute_credential_helper(&helper, registry) {
Ok((username, password)) => {
return Ok(RegistryAuth::Basic(username, password));
}
Err(e) => {
warn!("Credential helper failed: {}", e);
}
}
}
// Default to anonymous
debug!("No credentials found for {}, using anonymous", registry);
Ok(RegistryAuth::Anonymous)
}
#[cfg(test)]
#[path = "credential_helper_tests.rs"]
mod tests;

View file

@ -0,0 +1,295 @@
//! Tests for credential helper functionality
#[cfg(test)]
mod tests {
use crate::credential_helper::*;
use crate::secrets::RegistryAuth;
use std::env;
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
#[test]
fn test_resolve_anonymous_when_no_config() {
// Create a temp directory for DOCKER_CONFIG that doesn't have any config
let tmp_dir = TempDir::new().unwrap();
// Save current env vars
let old_docker_config = env::var("DOCKER_CONFIG").ok();
let old_registry_auth = env::var("REGISTRY_AUTH_FILE").ok();
// Set to our empty temp directory
env::set_var("DOCKER_CONFIG", tmp_dir.path());
env::remove_var("REGISTRY_AUTH_FILE");
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
assert_eq!(auth, RegistryAuth::Anonymous);
// Restore env vars
if let Some(val) = old_docker_config {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
if let Some(val) = old_registry_auth {
env::set_var("REGISTRY_AUTH_FILE", val);
} else {
env::remove_var("REGISTRY_AUTH_FILE");
}
}
#[test]
fn test_resolve_basic_auth_from_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.json");
let config = r#"{
"auths": {
"docker.io": {
"username": "testuser",
"password": "testpass"
}
}
}"#;
fs::write(&config_path, config).unwrap();
// Save current env var
let old_val = env::var("DOCKER_CONFIG").ok();
env::set_var("DOCKER_CONFIG", tmp_dir.path());
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
assert_eq!(auth, RegistryAuth::Basic("testuser".to_string(), "testpass".to_string()));
// Restore env var
if let Some(val) = old_val {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
}
#[test]
fn test_resolve_bearer_token_from_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.json");
let config = r#"{
"auths": {
"ghcr.io": {
"registrytoken": "ghp_abc123token"
}
}
}"#;
fs::write(&config_path, config).unwrap();
// Save current env var
let old_val = env::var("DOCKER_CONFIG").ok();
env::set_var("DOCKER_CONFIG", tmp_dir.path());
let auth = resolve_docker_auth("ghcr.io/user/image").unwrap();
assert_eq!(auth, RegistryAuth::Bearer("ghp_abc123token".to_string()));
// Restore env var
if let Some(val) = old_val {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
}
#[test]
fn test_resolve_base64_auth_from_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.json");
// Base64 encoded "testuser:testpass"
let config = r#"{
"auths": {
"docker.io": {
"auth": "dGVzdHVzZXI6dGVzdHBhc3M="
}
}
}"#;
fs::write(&config_path, config).unwrap();
// Save current env var
let old_val = env::var("DOCKER_CONFIG").ok();
env::set_var("DOCKER_CONFIG", tmp_dir.path());
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
assert_eq!(auth, RegistryAuth::Basic("testuser".to_string(), "testpass".to_string()));
// Restore env var
if let Some(val) = old_val {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
}
#[test]
fn test_registry_normalization() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.json");
// Config has index.docker.io but we query with docker.io
let config = r#"{
"auths": {
"index.docker.io": {
"username": "testuser",
"password": "testpass"
}
}
}"#;
fs::write(&config_path, config).unwrap();
// Save current env var
let old_val = env::var("DOCKER_CONFIG").ok();
env::set_var("DOCKER_CONFIG", tmp_dir.path());
// Should find auth even though we use docker.io
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
assert_eq!(auth, RegistryAuth::Basic("testuser".to_string(), "testpass".to_string()));
// Restore env var
if let Some(val) = old_val {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
}
#[test]
fn test_credential_helper_mock() {
let tmp_dir = TempDir::new().unwrap();
// Create a mock credential helper script
let helper_path = tmp_dir.path().join("docker-credential-mock");
let mut file = fs::File::create(&helper_path).unwrap();
writeln!(file, "#!/bin/sh").unwrap();
writeln!(file, "echo '{{\"Username\":\"helper-user\",\"Secret\":\"helper-pass\"}}'").unwrap();
// Make it executable
let mut perms = fs::metadata(&helper_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&helper_path, perms).unwrap();
// Create config that uses the helper
let config_path = tmp_dir.path().join("config.json");
let config = r#"{
"credHelpers": {
"mock.registry.io": "mock"
}
}"#;
fs::write(&config_path, config).unwrap();
// Save current env vars
let old_config = env::var("DOCKER_CONFIG").ok();
let old_path = env::var("PATH").ok();
// Set our temp dir in PATH and DOCKER_CONFIG
env::set_var("DOCKER_CONFIG", tmp_dir.path());
let new_path = format!("{}:{}", tmp_dir.path().display(), env::var("PATH").unwrap_or_default());
env::set_var("PATH", new_path);
// Test the credential helper
let auth = resolve_docker_auth("mock.registry.io/test/image").unwrap();
assert_eq!(auth, RegistryAuth::Basic("helper-user".to_string(), "helper-pass".to_string()));
// Restore env vars
if let Some(val) = old_config {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
if let Some(val) = old_path {
env::set_var("PATH", val);
} else {
env::remove_var("PATH");
}
}
#[test]
fn test_default_credential_store_mock() {
let tmp_dir = TempDir::new().unwrap();
// Create a mock credential helper script
let helper_path = tmp_dir.path().join("docker-credential-defaultstore");
let mut file = fs::File::create(&helper_path).unwrap();
writeln!(file, "#!/bin/sh").unwrap();
writeln!(file, "echo '{{\"Username\":\"store-user\",\"Secret\":\"store-pass\"}}'").unwrap();
// Make it executable
let mut perms = fs::metadata(&helper_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&helper_path, perms).unwrap();
// Create config that uses default creds store
let config_path = tmp_dir.path().join("config.json");
let config = r#"{
"credsStore": "defaultstore"
}"#;
fs::write(&config_path, config).unwrap();
// Save current env vars
let old_config = env::var("DOCKER_CONFIG").ok();
let old_path = env::var("PATH").ok();
// Set our temp dir in PATH and DOCKER_CONFIG
env::set_var("DOCKER_CONFIG", tmp_dir.path());
let new_path = format!("{}:{}", tmp_dir.path().display(), env::var("PATH").unwrap_or_default());
env::set_var("PATH", new_path);
// Test the credential helper
let auth = resolve_docker_auth("any.registry.io/test/image").unwrap();
assert_eq!(auth, RegistryAuth::Basic("store-user".to_string(), "store-pass".to_string()));
// Restore env vars
if let Some(val) = old_config {
env::set_var("DOCKER_CONFIG", val);
} else {
env::remove_var("DOCKER_CONFIG");
}
if let Some(val) = old_path {
env::set_var("PATH", val);
} else {
env::remove_var("PATH");
}
}
#[test]
fn test_registry_auth_file_env() {
let tmp_dir = TempDir::new().unwrap();
let auth_file = tmp_dir.path().join("auth.json");
let config = r#"{
"auths": {
"special.registry.io": {
"username": "authfile-user",
"password": "authfile-pass"
}
}
}"#;
fs::write(&auth_file, config).unwrap();
// Save current env var
let old_val = env::var("REGISTRY_AUTH_FILE").ok();
env::set_var("REGISTRY_AUTH_FILE", auth_file.to_str().unwrap());
let auth = resolve_docker_auth("special.registry.io/image").unwrap();
assert_eq!(auth, RegistryAuth::Basic("authfile-user".to_string(), "authfile-pass".to_string()));
// Restore env var
if let Some(val) = old_val {
env::set_var("REGISTRY_AUTH_FILE", val);
} else {
env::remove_var("REGISTRY_AUTH_FILE");
}
}
}

View file

@ -0,0 +1,180 @@
//! Docker config file parsing and credential helper support
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{debug, warn};
/// Docker config file structure
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DockerConfig {
/// Registry authentication entries
#[serde(default)]
pub auths: HashMap<String, DockerAuthEntry>,
/// Registry-specific credential helpers
#[serde(rename = "credHelpers", default)]
pub cred_helpers: HashMap<String, String>,
/// Default credential store to use
#[serde(rename = "credsStore", skip_serializing_if = "Option::is_none")]
pub creds_store: Option<String>,
}
/// Entry in the Docker config auths section
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DockerAuthEntry {
/// Base64-encoded username:password
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<String>,
/// Username for authentication
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
/// Password for authentication
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
/// Identity token for registry authentication
#[serde(rename = "identitytoken", skip_serializing_if = "Option::is_none")]
pub identity_token: Option<String>,
/// Registry token for bearer authentication
#[serde(rename = "registrytoken", skip_serializing_if = "Option::is_none")]
pub registry_token: Option<String>,
}
impl DockerAuthEntry {
/// Check if this is anonymous authentication
pub fn is_anonymous(&self) -> bool {
self.username.is_none()
&& self.password.is_none()
&& self.auth.is_none()
&& self.identity_token.is_none()
&& self.registry_token.is_none()
}
}
/// Get paths to check for Docker config
pub fn config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Check DOCKER_CONFIG environment variable
if let Ok(docker_config) = std::env::var("DOCKER_CONFIG") {
paths.push(PathBuf::from(docker_config).join("config.json"));
}
// Check REGISTRY_AUTH_FILE environment variable
if let Ok(auth_file) = std::env::var("REGISTRY_AUTH_FILE") {
paths.push(PathBuf::from(auth_file));
}
// Check XDG_RUNTIME_DIR for containers auth
if let Ok(xdg_runtime) = std::env::var("XDG_RUNTIME_DIR") {
paths.push(PathBuf::from(xdg_runtime).join("containers/auth.json"));
}
// Check default Docker config location
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".docker/config.json"));
}
paths
}
/// Load Docker config from disk
pub fn load_docker_config() -> crate::errors::Result<DockerConfig> {
// Try each config path
for path in config_paths() {
if path.exists() {
debug!("Checking Docker config at: {}", path.display());
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<DockerConfig>(&content) {
Ok(config) => {
debug!("Loaded Docker config from: {}", path.display());
return Ok(config);
}
Err(e) => {
warn!("Failed to parse Docker config at {}: {}", path.display(), e);
}
}
}
Err(e) => {
warn!("Failed to read Docker config at {}: {}", path.display(), e);
}
}
}
}
// Return empty config if no valid config found
Ok(DockerConfig {
auths: HashMap::new(),
cred_helpers: HashMap::new(),
creds_store: None,
})
}
/// Extract registry from image reference
pub fn extract_registry(image_ref: &str) -> &str {
// Handle different image reference formats:
// - docker.io/library/ubuntu:latest -> docker.io
// - gcr.io/project/image:tag -> gcr.io
// - localhost:5000/image -> localhost:5000
// - ubuntu:latest -> docker.io (implicit)
if let Some(slash_pos) = image_ref.find('/') {
let registry_part = &image_ref[..slash_pos];
// Check if this looks like a registry (contains . or :)
if registry_part.contains('.') || registry_part.contains(':') {
return registry_part;
}
}
// Default to Docker Hub
"index.docker.io"
}
/// Normalize registry URL for matching
pub fn normalize_registry(registry: &str) -> Vec<String> {
let mut variants = vec![registry.to_string()];
// Add common variants
if registry == "docker.io" || registry == "index.docker.io" {
variants.push("docker.io".to_string());
variants.push("index.docker.io".to_string());
variants.push("https://index.docker.io/v1/".to_string());
variants.push("https://index.docker.io/v2/".to_string());
} else if !registry.starts_with("http://") && !registry.starts_with("https://") {
// Add protocol variants
variants.push(format!("https://{}", registry));
variants.push(format!("http://{}", registry));
// Add /v1/ and /v2/ variants
variants.push(format!("https://{}/v1/", registry));
variants.push(format!("https://{}/v2/", registry));
}
variants
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_registry() {
assert_eq!(extract_registry("docker.io/library/ubuntu:latest"), "docker.io");
assert_eq!(extract_registry("gcr.io/project/image:tag"), "gcr.io");
assert_eq!(extract_registry("localhost:5000/image"), "localhost:5000");
assert_eq!(extract_registry("ubuntu:latest"), "index.docker.io");
assert_eq!(extract_registry("user/image:tag"), "index.docker.io");
}
#[test]
fn test_normalize_registry() {
let variants = normalize_registry("docker.io");
assert!(variants.contains(&"docker.io".to_string()));
assert!(variants.contains(&"index.docker.io".to_string()));
let variants = normalize_registry("gcr.io");
assert!(variants.contains(&"gcr.io".to_string()));
assert!(variants.contains(&"https://gcr.io".to_string()));
}
}

View file

@ -6,6 +6,8 @@ use sha2::Digest;
pub mod annotations; pub mod annotations;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod credential_helper;
pub mod docker_config;
pub mod errors; pub mod errors;
pub mod manifest; pub mod manifest;
mod reference; mod reference;