diff --git a/Cargo.toml b/Cargo.toml index 3f3a74f..3dcb0c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,7 @@ base64 = "0.22" tempfile = "3.9" assert_cmd = "2.0" predicates = "3.0" + +[[example]] +name = "auto_auth_demo" +path = "examples/auto_auth_demo.rs" diff --git a/examples/auto_auth_demo.rs b/examples/auto_auth_demo.rs new file mode 100644 index 0000000..e64c441 --- /dev/null +++ b/examples/auto_auth_demo.rs @@ -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(()) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index aeab575..09af8b1 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -9,8 +9,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; mod keychain; +mod simple; pub use keychain::{DefaultKeychain, Keychain}; +pub use simple::resolve_auth; /// Authentication configuration containing credentials #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/src/auth/simple.rs b/src/auth/simple.rs new file mode 100644 index 0000000..c10c230 --- /dev/null +++ b/src/auth/simple.rs @@ -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 { + resolve_docker_auth(resource) + .map_err(|e| anyhow::anyhow!("Failed to resolve auth: {}", e)) +} diff --git a/src/main.rs b/src/main.rs index 1cc71a1..d64beb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use clap::Parser; use krust::{ - auth::{DefaultKeychain, Keychain}, + auth::resolve_auth, builder::{get_rust_target_triple, RustBuilder}, cli::{Cli, Commands}, config::Config, @@ -59,8 +59,7 @@ async fn main() -> Result<()> { format!("{}/{}:latest", repo, project_name) }; - // Initialize registry client and keychain - let keychain = DefaultKeychain::new(); + // Initialize registry client let mut registry_client = RegistryClient::new()?; // Determine platforms to build for @@ -74,10 +73,7 @@ async fn main() -> Result<()> { base_image ); // Get auth for the base image registry - let base_auth = keychain - .resolve(&base_image)? - .authorization()? - .to_registry_auth(); + let base_auth = resolve_auth(&base_image)?; match registry_client .get_image_platforms(&base_image, &base_auth) @@ -143,10 +139,7 @@ async fn main() -> Result<()> { let platform_ref = format!("{}:{}", base_ref, platform_tag); // Get auth for the target registry - let push_auth = keychain - .resolve(&platform_ref)? - .authorization()? - .to_registry_auth(); + let push_auth = resolve_auth(&platform_ref)?; let (digest_ref, manifest_size) = registry_client .push_image(&platform_ref, config_data, layers, &push_auth) @@ -188,10 +181,7 @@ async fn main() -> Result<()> { info!("Creating and pushing manifest list..."); // Get auth for the final image push - let final_auth = keychain - .resolve(&image_ref)? - .authorization()? - .to_registry_auth(); + let final_auth = resolve_auth(&image_ref)?; let manifest_list_ref = registry_client .push_manifest_list(&image_ref, manifest_descriptors, &final_auth) diff --git a/tests/credential_helper_integration_test.rs b/tests/credential_helper_integration_test.rs new file mode 100644 index 0000000..04d8103 --- /dev/null +++ b/tests/credential_helper_integration_test.rs @@ -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(()) +} diff --git a/vendor/oci-distribution/Cargo.toml b/vendor/oci-distribution/Cargo.toml index 35899c8..bcd7dc8 100644 --- a/vendor/oci-distribution/Cargo.toml +++ b/vendor/oci-distribution/Cargo.toml @@ -32,8 +32,10 @@ trust-dns = ["reqwest/trust-dns"] test-registry = [] [dependencies] +base64 = "0.22" bytes = "1" chrono = { version = "0.4.23", features = ["serde"] } +dirs = "6.0" futures-util = "0.3" http = "1.1" http-auth = { version = "0.1", default_features = false } diff --git a/vendor/oci-distribution/src/client.rs b/vendor/oci-distribution/src/client.rs index 89da0a4..011d652 100644 --- a/vendor/oci-distribution/src/client.rs +++ b/vendor/oci-distribution/src/client.rs @@ -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 { + 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, + ) -> Result { + 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> { + let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?; + self.get_image_platforms(image, &auth).await + } + async fn _pull_manifest_and_config( &self, image: &Reference, diff --git a/vendor/oci-distribution/src/credential_helper.rs b/vendor/oci-distribution/src/credential_helper.rs new file mode 100644 index 0000000..70c58b2 --- /dev/null +++ b/vendor/oci-distribution/src/credential_helper.rs @@ -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, + #[serde(rename = "Secret")] + secret: Option, + #[serde(rename = "ServerURL")] + _server_url: Option, +} + +/// 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 { + 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 { + // 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 { + 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; diff --git a/vendor/oci-distribution/src/credential_helper_tests.rs b/vendor/oci-distribution/src/credential_helper_tests.rs new file mode 100644 index 0000000..74d141f --- /dev/null +++ b/vendor/oci-distribution/src/credential_helper_tests.rs @@ -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"); + } + } +} diff --git a/vendor/oci-distribution/src/docker_config.rs b/vendor/oci-distribution/src/docker_config.rs new file mode 100644 index 0000000..9a29e4a --- /dev/null +++ b/vendor/oci-distribution/src/docker_config.rs @@ -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, + /// Registry-specific credential helpers + #[serde(rename = "credHelpers", default)] + pub cred_helpers: HashMap, + /// Default credential store to use + #[serde(rename = "credsStore", skip_serializing_if = "Option::is_none")] + pub creds_store: Option, +} + +/// 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, + /// Username for authentication + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + /// Password for authentication + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + /// Identity token for registry authentication + #[serde(rename = "identitytoken", skip_serializing_if = "Option::is_none")] + pub identity_token: Option, + /// Registry token for bearer authentication + #[serde(rename = "registrytoken", skip_serializing_if = "Option::is_none")] + pub registry_token: Option, +} + +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 { + 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 { + // 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::(&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 { + 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())); + } +} diff --git a/vendor/oci-distribution/src/lib.rs b/vendor/oci-distribution/src/lib.rs index e230fff..f6b6cbc 100644 --- a/vendor/oci-distribution/src/lib.rs +++ b/vendor/oci-distribution/src/lib.rs @@ -6,6 +6,8 @@ use sha2::Digest; pub mod annotations; pub mod client; pub mod config; +pub mod credential_helper; +pub mod docker_config; pub mod errors; pub mod manifest; mod reference;