diff --git a/Cargo.toml b/Cargo.toml index 59765af..c71c08d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ dirs = "6.0" toml = "0.8" which = "8.0" tempfile = "3.9" +base64 = "0.22" [dev-dependencies] tempfile = "3.9" diff --git a/src/auth/keychain.rs b/src/auth/keychain.rs new file mode 100644 index 0000000..1dcadba --- /dev/null +++ b/src/auth/keychain.rs @@ -0,0 +1,364 @@ +//! Keychain implementation for credential management + +use super::{Anonymous, AuthConfig, Authenticator, DockerAuthEntry, DockerConfig}; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tracing::{debug, warn}; + +/// Trait for types that can resolve authentication for a given resource +pub trait Keychain: Send + Sync { + /// Resolve authentication for a given resource (registry URL or image reference) + fn resolve(&self, resource: &str) -> Result>; +} + +/// Default keychain implementation that checks Docker config files +pub struct DefaultKeychain { + /// Cached config to avoid re-reading files + config_cache: Arc>>, +} + +impl DefaultKeychain { + /// Create a new DefaultKeychain + pub fn new() -> Self { + Self { + config_cache: Arc::new(Mutex::new(None)), + } + } + + /// Get paths to check for Docker config + 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 + fn load_config(&self) -> Result { + // Check cache first + { + let cache = self.config_cache.lock().unwrap(); + if let Some(config) = cache.as_ref() { + return Ok(config.clone()); + } + } + + // Try each config path + for path in Self::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()); + // Cache the config + let mut cache = self.config_cache.lock().unwrap(); + *cache = Some(config.clone()); + 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 + 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 + 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 + } + + /// Find auth entry for a registry + fn find_auth_entry(&self, config: &DockerConfig, registry: &str) -> Option { + let variants = Self::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(&self, 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() + } + + /// Execute credential helper to get credentials + fn execute_credential_helper(&self, helper: &str, registry: &str) -> Result { + use std::io::Write; + use std::process::{Command, Stdio}; + + 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() + .context(format!( + "Failed to spawn credential helper: {}", + helper_name + ))?; + + // Write registry URL to stdin + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(registry.as_bytes())?; + stdin.write_all(b"\n")?; + } + + let output = child.wait_with_output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Credential helper {} failed: {}", helper_name, stderr); + } + + // Parse output as JSON + #[derive(serde::Deserialize)] + struct HelperResponse { + #[serde(rename = "Username")] + username: Option, + #[serde(rename = "Secret")] + secret: Option, + #[serde(rename = "ServerURL")] + _server_url: Option, + } + + let response: HelperResponse = serde_json::from_slice(&output.stdout) + .context("Failed to parse credential helper response")?; + + Ok(AuthConfig { + username: response.username, + password: response.secret, + ..Default::default() + }) + } +} + +impl Default for DefaultKeychain { + fn default() -> Self { + Self::new() + } +} + +impl Keychain for DefaultKeychain { + fn resolve(&self, resource: &str) -> Result> { + let config = self.load_config()?; + let registry = Self::extract_registry(resource); + + debug!( + "Resolving auth for resource: {} (registry: {})", + resource, registry + ); + + // Try to find auth entry in config + if let Some(auth_entry) = self.find_auth_entry(&config, registry) { + debug!("Found auth entry for {}", registry); + let auth_config = auth_entry.to_auth_config(); + + // Return appropriate authenticator based on auth type + if auth_config.is_anonymous() { + return Ok(Box::new(Anonymous)); + } + + return Ok(Box::new(ConfigAuthenticator { + config: auth_config, + })); + } + + // Try credential helper + if let Some(helper) = self.get_credential_helper(&config, registry) { + debug!("Trying credential helper: {} for {}", helper, registry); + match self.execute_credential_helper(&helper, registry) { + Ok(auth_config) => { + return Ok(Box::new(ConfigAuthenticator { + config: auth_config, + })); + } + Err(e) => { + warn!("Credential helper failed: {}", e); + } + } + } + + // Default to anonymous + debug!("No credentials found for {}, using anonymous", registry); + Ok(Box::new(Anonymous)) + } +} + +/// Authenticator that returns a fixed AuthConfig +struct ConfigAuthenticator { + config: AuthConfig, +} + +impl Authenticator for ConfigAuthenticator { + fn authorization(&self) -> Result { + Ok(self.config.clone()) + } +} + +/// Multi-keychain that tries multiple keychains in order +pub struct MultiKeychain { + keychains: Vec>, +} + +impl MultiKeychain { + /// Create a new MultiKeychain + #[allow(dead_code)] + pub fn new(keychains: Vec>) -> Self { + Self { keychains } + } +} + +impl Keychain for MultiKeychain { + fn resolve(&self, resource: &str) -> Result> { + for keychain in &self.keychains { + match keychain.resolve(resource) { + Ok(auth) => { + // Check if it's not anonymous + if let Ok(config) = auth.authorization() { + if !config.is_anonymous() { + return Ok(auth); + } + } + } + Err(e) => { + debug!("Keychain failed: {}", e); + } + } + } + + // Default to anonymous + Ok(Box::new(Anonymous)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_registry() { + assert_eq!( + DefaultKeychain::extract_registry("docker.io/library/ubuntu:latest"), + "docker.io" + ); + assert_eq!( + DefaultKeychain::extract_registry("gcr.io/project/image:tag"), + "gcr.io" + ); + assert_eq!( + DefaultKeychain::extract_registry("localhost:5000/image"), + "localhost:5000" + ); + assert_eq!( + DefaultKeychain::extract_registry("ubuntu:latest"), + "index.docker.io" + ); + assert_eq!( + DefaultKeychain::extract_registry("user/image:tag"), + "index.docker.io" + ); + } + + #[test] + fn test_normalize_registry() { + let variants = DefaultKeychain::normalize_registry("docker.io"); + assert!(variants.contains(&"docker.io".to_string())); + assert!(variants.contains(&"index.docker.io".to_string())); + + let variants = DefaultKeychain::normalize_registry("gcr.io"); + assert!(variants.contains(&"gcr.io".to_string())); + assert!(variants.contains(&"https://gcr.io".to_string())); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..b5aeda9 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,237 @@ +//! Authentication module for container registries +//! +//! This module provides authentication functionality similar to go-containerregistry's authn package, +//! supporting Docker config files, credential helpers, and various authentication methods. + +use anyhow::Result; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +mod keychain; + +pub use keychain::{DefaultKeychain, Keychain}; + +/// Authentication configuration containing credentials +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registry_token: Option, +} + +impl AuthConfig { + /// Create a new AuthConfig with username and password + pub fn new(username: String, password: String) -> Self { + Self { + username: Some(username), + password: Some(password), + ..Default::default() + } + } + + /// Create an anonymous AuthConfig + pub fn anonymous() -> Self { + Self::default() + } + + /// 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() + } + + /// Convert to authorization header value + pub fn to_authorization_header(&self) -> Result> { + if let Some(token) = &self.registry_token { + return Ok(Some(format!("Bearer {}", token))); + } + + if let Some(token) = &self.identity_token { + return Ok(Some(format!("Bearer {}", token))); + } + + if let Some(auth) = &self.auth { + return Ok(Some(format!("Basic {}", auth))); + } + + if let (Some(username), Some(password)) = (&self.username, &self.password) { + let encoded = base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", username, password)); + return Ok(Some(format!("Basic {}", encoded))); + } + + Ok(None) + } + + /// Convert to oci-distribution RegistryAuth + pub fn to_registry_auth(&self) -> oci_distribution::secrets::RegistryAuth { + use oci_distribution::secrets::RegistryAuth; + + if self.is_anonymous() { + return RegistryAuth::Anonymous; + } + + if let (Some(username), Some(password)) = (&self.username, &self.password) { + return RegistryAuth::Basic(username.clone(), password.clone()); + } + + if let Some(auth) = &self.auth { + // Try to decode the base64 auth string + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(auth) { + if let Ok(decoded_str) = String::from_utf8(decoded) { + if let Some((user, pass)) = decoded_str.split_once(':') { + return RegistryAuth::Basic(user.to_string(), pass.to_string()); + } + } + } + } + + RegistryAuth::Anonymous + } +} + +/// Trait for types that can provide authentication +pub trait Authenticator: Send + Sync { + /// Get the authentication configuration + fn authorization(&self) -> Result; +} + +/// Anonymous authenticator +pub struct Anonymous; + +impl Authenticator for Anonymous { + fn authorization(&self) -> Result { + Ok(AuthConfig::anonymous()) + } +} + +/// Basic authenticator with username and password +pub struct Basic { + username: String, + password: String, +} + +impl Basic { + pub fn new(username: String, password: String) -> Self { + Self { username, password } + } +} + +impl Authenticator for Basic { + fn authorization(&self) -> Result { + Ok(AuthConfig::new( + self.username.clone(), + self.password.clone(), + )) + } +} + +/// Bearer token authenticator +pub struct Bearer { + token: String, +} + +impl Bearer { + pub fn new(token: String) -> Self { + Self { token } + } +} + +impl Authenticator for Bearer { + fn authorization(&self) -> Result { + Ok(AuthConfig { + registry_token: Some(self.token.clone()), + ..Default::default() + }) + } +} + +/// Docker config file structure +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DockerConfig { + #[serde(default)] + pub auths: HashMap, + #[serde(rename = "credHelpers", default)] + pub cred_helpers: HashMap, + #[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 { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(rename = "identitytoken", skip_serializing_if = "Option::is_none")] + pub identity_token: Option, + #[serde(rename = "registrytoken", skip_serializing_if = "Option::is_none")] + pub registry_token: Option, +} + +impl DockerAuthEntry { + /// Convert to AuthConfig + pub fn to_auth_config(&self) -> AuthConfig { + AuthConfig { + username: self.username.clone(), + password: self.password.clone(), + auth: self.auth.clone(), + identity_token: self.identity_token.clone(), + registry_token: self.registry_token.clone(), + } + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod unit_tests { + use super::*; + + #[test] + fn test_auth_config_anonymous() { + let auth = AuthConfig::anonymous(); + assert!(auth.is_anonymous()); + assert_eq!(auth.to_authorization_header().unwrap(), None); + } + + #[test] + fn test_auth_config_basic() { + let auth = AuthConfig::new("user".to_string(), "pass".to_string()); + assert!(!auth.is_anonymous()); + + let header = auth.to_authorization_header().unwrap().unwrap(); + assert!(header.starts_with("Basic ")); + + // Verify base64 encoding + let expected = base64::engine::general_purpose::STANDARD.encode("user:pass"); + assert_eq!(header, format!("Basic {}", expected)); + } + + #[test] + fn test_auth_config_bearer() { + let auth = AuthConfig { + registry_token: Some("token123".to_string()), + ..Default::default() + }; + + let header = auth.to_authorization_header().unwrap().unwrap(); + assert_eq!(header, "Bearer token123"); + } +} diff --git a/src/auth/tests.rs b/src/auth/tests.rs new file mode 100644 index 0000000..417fd03 --- /dev/null +++ b/src/auth/tests.rs @@ -0,0 +1,169 @@ +//! Tests for the auth module + +use super::*; +use crate::auth::keychain::MultiKeychain; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +/// Helper to create a test Docker config file +fn create_test_config(dir: &Path, content: &str) -> PathBuf { + let config_path = dir.join("config.json"); + fs::write(&config_path, content).unwrap(); + config_path +} + +#[test] +fn test_docker_config_parsing() { + let config_json = r#"{ + "auths": { + "docker.io": { + "auth": "dXNlcjpwYXNz" + }, + "gcr.io": { + "username": "oauth2accesstoken", + "password": "ya29.token", + "registrytoken": "bearer-token" + } + }, + "credHelpers": { + "ecr.amazonaws.com": "ecr-login" + }, + "credsStore": "osxkeychain" + }"#; + + let config: DockerConfig = serde_json::from_str(config_json).unwrap(); + + assert_eq!(config.auths.len(), 2); + assert!(config.auths.contains_key("docker.io")); + assert!(config.auths.contains_key("gcr.io")); + + let docker_auth = &config.auths["docker.io"]; + assert_eq!(docker_auth.auth, Some("dXNlcjpwYXNz".to_string())); + + let gcr_auth = &config.auths["gcr.io"]; + assert_eq!(gcr_auth.username, Some("oauth2accesstoken".to_string())); + assert_eq!(gcr_auth.password, Some("ya29.token".to_string())); + assert_eq!(gcr_auth.registry_token, Some("bearer-token".to_string())); + + assert_eq!(config.cred_helpers.len(), 1); + assert_eq!(config.cred_helpers["ecr.amazonaws.com"], "ecr-login"); + + assert_eq!(config.creds_store, Some("osxkeychain".to_string())); +} + +#[test] +fn test_docker_auth_entry_to_auth_config() { + let entry = DockerAuthEntry { + auth: Some("dXNlcjpwYXNz".to_string()), + username: None, + password: None, + identity_token: None, + registry_token: None, + }; + + let config = entry.to_auth_config(); + assert_eq!(config.auth, Some("dXNlcjpwYXNz".to_string())); + assert!(config.username.is_none()); + assert!(config.password.is_none()); +} + +#[test] +fn test_default_keychain_config_paths() { + let temp_dir = TempDir::new().unwrap(); + let config_content = r#"{ + "auths": { + "test.registry.io": { + "auth": "dGVzdDp0ZXN0" + } + } + }"#; + + // Test DOCKER_CONFIG env var + let docker_config_dir = temp_dir.path().join("docker"); + fs::create_dir_all(&docker_config_dir).unwrap(); + create_test_config(&docker_config_dir, config_content); + + std::env::set_var("DOCKER_CONFIG", docker_config_dir.to_str().unwrap()); + + let keychain = DefaultKeychain::new(); + let auth = keychain.resolve("test.registry.io/image:tag").unwrap(); + let auth_config = auth.authorization().unwrap(); + + assert!(!auth_config.is_anonymous()); + assert_eq!(auth_config.auth, Some("dGVzdDp0ZXN0".to_string())); + + std::env::remove_var("DOCKER_CONFIG"); +} + +// Registry extraction tests are skipped as the method is private +// The functionality is tested through the public resolve() method + +#[test] +fn test_keychain_resolve_different_registries() { + // Test that keychain can resolve different registry formats + let keychain = DefaultKeychain::new(); + + // Should return anonymous for unknown registries + let auth = keychain.resolve("unknown.registry.io/image:tag").unwrap(); + let config = auth.authorization().unwrap(); + assert!(config.is_anonymous()); +} + +#[test] +fn test_anonymous_authenticator() { + let anon = Anonymous; + let auth = anon.authorization().unwrap(); + assert!(auth.is_anonymous()); + assert!(auth.to_authorization_header().unwrap().is_none()); +} + +#[test] +fn test_basic_authenticator() { + let basic = Basic::new("user".to_string(), "pass".to_string()); + let auth = basic.authorization().unwrap(); + assert!(!auth.is_anonymous()); + + let header = auth.to_authorization_header().unwrap().unwrap(); + assert!(header.starts_with("Basic ")); +} + +#[test] +fn test_bearer_authenticator() { + let bearer = Bearer::new("token123".to_string()); + let auth = bearer.authorization().unwrap(); + assert!(!auth.is_anonymous()); + + let header = auth.to_authorization_header().unwrap().unwrap(); + assert_eq!(header, "Bearer token123"); +} + +#[test] +fn test_multi_keychain() { + // Create a keychain that always returns anonymous + struct AlwaysAnonymous; + impl Keychain for AlwaysAnonymous { + fn resolve(&self, _: &str) -> Result> { + Ok(Box::new(Anonymous)) + } + } + + // Create a keychain that returns basic auth + struct AlwaysBasic; + impl Keychain for AlwaysBasic { + fn resolve(&self, _: &str) -> Result> { + Ok(Box::new(Basic::new("user".to_string(), "pass".to_string()))) + } + } + + // Test that MultiKeychain returns the first non-anonymous auth + let multi = MultiKeychain::new(vec![ + Box::new(AlwaysAnonymous) as Box, + Box::new(AlwaysBasic) as Box, + ]); + + let auth = multi.resolve("any.registry.io").unwrap(); + let config = auth.authorization().unwrap(); + assert!(!config.is_anonymous()); + assert_eq!(config.username, Some("user".to_string())); +} diff --git a/src/lib.rs b/src/lib.rs index bfd2632..2d1bbb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod builder; pub mod cli; pub mod config; diff --git a/src/main.rs b/src/main.rs index cdd7097..1cc71a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use clap::Parser; use krust::{ + auth::{DefaultKeychain, Keychain}, builder::{get_rust_target_triple, RustBuilder}, cli::{Cli, Commands}, config::Config, @@ -58,9 +59,9 @@ async fn main() -> Result<()> { format!("{}/{}:latest", repo, project_name) }; - // Initialize registry client early for platform detection - let auth = oci_distribution::secrets::RegistryAuth::Anonymous; - let mut registry_client = RegistryClient::new(auth)?; + // Initialize registry client and keychain + let keychain = DefaultKeychain::new(); + let mut registry_client = RegistryClient::new()?; // Determine platforms to build for let platforms = if let Some(platforms) = platform { @@ -72,7 +73,16 @@ async fn main() -> Result<()> { "Detecting available platforms from base image: {}", base_image ); - match registry_client.get_image_platforms(&base_image).await { + // Get auth for the base image registry + let base_auth = keychain + .resolve(&base_image)? + .authorization()? + .to_registry_auth(); + + match registry_client + .get_image_platforms(&base_image, &base_auth) + .await + { Ok(detected_platforms) => { if detected_platforms.is_empty() { info!("No platforms detected, using defaults"); @@ -132,8 +142,14 @@ async fn main() -> Result<()> { let platform_tag = format!("platform-{}", platform_str.replace('/', "-")); 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 (digest_ref, manifest_size) = registry_client - .push_image(&platform_ref, config_data, layers) + .push_image(&platform_ref, config_data, layers, &push_auth) .await?; // Parse platform string @@ -171,8 +187,14 @@ async fn main() -> Result<()> { if !no_push { 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 manifest_list_ref = registry_client - .push_manifest_list(&image_ref, manifest_descriptors) + .push_manifest_list(&image_ref, manifest_descriptors, &final_auth) .await?; // Output the manifest list reference diff --git a/src/registry/mod.rs b/src/registry/mod.rs index a88d292..eb75915 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -10,14 +10,12 @@ mod tests; pub struct RegistryClient { client: Client, - #[allow(dead_code)] - auth: RegistryAuth, } impl RegistryClient { - pub fn new(auth: RegistryAuth) -> Result { + pub fn new() -> Result { let client = Client::new(oci_distribution::client::ClientConfig::default()); - Ok(Self { client, auth }) + Ok(Self { client }) } pub async fn push_image( @@ -25,6 +23,7 @@ impl RegistryClient { image_ref: &str, config_data: Vec, layers: Vec<(Vec, String)>, + auth: &RegistryAuth, ) -> Result<(String, usize)> { let reference: Reference = image_ref .parse() @@ -32,6 +31,12 @@ impl RegistryClient { info!("Pushing image to {}", reference); + // Authenticate with the registry + self.client + .auth(&reference, auth, oci_distribution::RegistryOperation::Push) + .await + .context("Failed to authenticate with registry")?; + // Push config blob let config_digest = format!("sha256:{}", sha256::digest(&config_data)); debug!("Pushing config blob: {}", config_digest); @@ -110,10 +115,17 @@ impl RegistryClient { &mut self, image_ref: &str, manifest_descriptors: Vec, + auth: &RegistryAuth, ) -> Result { let reference = Reference::from_str(image_ref) .context(format!("Failed to parse image reference: {}", image_ref))?; + // Authenticate with the registry + self.client + .auth(&reference, auth, oci_distribution::RegistryOperation::Push) + .await + .context("Failed to authenticate with registry")?; + // Create the image index let index = crate::manifest::ImageIndex::new(manifest_descriptors); @@ -180,7 +192,11 @@ impl RegistryClient { } /// Fetch the manifest for an image and extract available platforms - pub async fn get_image_platforms(&mut self, image_ref: &str) -> Result> { + pub async fn get_image_platforms( + &mut self, + image_ref: &str, + auth: &RegistryAuth, + ) -> Result> { let reference: Reference = image_ref .parse() .context("Failed to parse image reference")?; @@ -190,7 +206,7 @@ impl RegistryClient { // Pull the manifest let (manifest, _) = self .client - .pull_manifest(&reference, &self.auth) + .pull_manifest(&reference, auth) .await .context("Failed to pull manifest")?; diff --git a/tests/auth_integration_test.rs b/tests/auth_integration_test.rs new file mode 100644 index 0000000..7f2ce37 --- /dev/null +++ b/tests/auth_integration_test.rs @@ -0,0 +1,76 @@ +//! Integration tests for authentication + +use anyhow::Result; +use krust::auth::{AuthConfig, DefaultKeychain, Keychain}; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_auth_integration_with_docker_config() -> Result<()> { + // Create a temporary directory for Docker config + let temp_dir = TempDir::new()?; + let config_dir = temp_dir.path().join(".docker"); + fs::create_dir_all(&config_dir)?; + + // Create a test Docker config + let config_content = r#"{ + "auths": { + "ghcr.io": { + "auth": "dGVzdDp0ZXN0MTIz" + }, + "docker.io": { + "username": "testuser", + "password": "testpass" + } + } + }"#; + + fs::write(config_dir.join("config.json"), config_content)?; + + // Set HOME to temp directory + std::env::set_var("HOME", temp_dir.path()); + + let keychain = DefaultKeychain::new(); + + // Test GitHub Container Registry auth + let ghcr_auth = keychain.resolve("ghcr.io/user/image:tag")?; + let ghcr_config = ghcr_auth.authorization()?; + assert!(!ghcr_config.is_anonymous()); + assert_eq!(ghcr_config.auth, Some("dGVzdDp0ZXN0MTIz".to_string())); + + // Test Docker Hub auth + let docker_auth = keychain.resolve("docker.io/library/ubuntu:latest")?; + let docker_config = docker_auth.authorization()?; + assert!(!docker_config.is_anonymous()); + assert_eq!(docker_config.username, Some("testuser".to_string())); + assert_eq!(docker_config.password, Some("testpass".to_string())); + + // Test unknown registry returns anonymous + let unknown_auth = keychain.resolve("unknown.registry.io/image:tag")?; + let unknown_config = unknown_auth.authorization()?; + assert!(unknown_config.is_anonymous()); + + // Clean up + std::env::remove_var("HOME"); + + Ok(()) +} + +#[test] +fn test_auth_config_creation() { + // Test anonymous + let anon = AuthConfig::anonymous(); + assert!(anon.is_anonymous()); + + // Test basic auth + let basic = AuthConfig::new("user".to_string(), "pass".to_string()); + assert!(!basic.is_anonymous()); + assert_eq!(basic.username, Some("user".to_string())); + assert_eq!(basic.password, Some("pass".to_string())); + + // Test auth header generation + let header = basic.to_authorization_header().unwrap(); + assert!(header.is_some()); + let header_val = header.unwrap(); + assert!(header_val.starts_with("Basic ")); +}