mirror of
https://github.com/imjasonh/krust
synced 2026-07-08 14:55:35 +00:00
Merge pull request #11 from imjasonh/feat/registry-auth
feat: Implement registry authentication support
This commit is contained in:
commit
e2c4b6b14c
8 changed files with 898 additions and 12 deletions
|
|
@ -26,6 +26,7 @@ dirs = "6.0"
|
|||
toml = "0.8"
|
||||
which = "8.0"
|
||||
tempfile = "3.9"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.9"
|
||||
|
|
|
|||
364
src/auth/keychain.rs
Normal file
364
src/auth/keychain.rs
Normal file
|
|
@ -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<Box<dyn Authenticator>>;
|
||||
}
|
||||
|
||||
/// Default keychain implementation that checks Docker config files
|
||||
pub struct DefaultKeychain {
|
||||
/// Cached config to avoid re-reading files
|
||||
config_cache: Arc<Mutex<Option<DockerConfig>>>,
|
||||
}
|
||||
|
||||
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<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
|
||||
fn load_config(&self) -> Result<DockerConfig> {
|
||||
// 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::<DockerConfig>(&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<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
|
||||
}
|
||||
|
||||
/// Find auth entry for a registry
|
||||
fn find_auth_entry(&self, config: &DockerConfig, registry: &str) -> Option<DockerAuthEntry> {
|
||||
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<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()
|
||||
}
|
||||
|
||||
/// Execute credential helper to get credentials
|
||||
fn execute_credential_helper(&self, helper: &str, registry: &str) -> Result<AuthConfig> {
|
||||
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<String>,
|
||||
#[serde(rename = "Secret")]
|
||||
secret: Option<String>,
|
||||
#[serde(rename = "ServerURL")]
|
||||
_server_url: Option<String>,
|
||||
}
|
||||
|
||||
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<Box<dyn Authenticator>> {
|
||||
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<AuthConfig> {
|
||||
Ok(self.config.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-keychain that tries multiple keychains in order
|
||||
pub struct MultiKeychain {
|
||||
keychains: Vec<Box<dyn Keychain>>,
|
||||
}
|
||||
|
||||
impl MultiKeychain {
|
||||
/// Create a new MultiKeychain
|
||||
#[allow(dead_code)]
|
||||
pub fn new(keychains: Vec<Box<dyn Keychain>>) -> Self {
|
||||
Self { keychains }
|
||||
}
|
||||
}
|
||||
|
||||
impl Keychain for MultiKeychain {
|
||||
fn resolve(&self, resource: &str) -> Result<Box<dyn Authenticator>> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
237
src/auth/mod.rs
Normal file
237
src/auth/mod.rs
Normal file
|
|
@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub registry_token: Option<String>,
|
||||
}
|
||||
|
||||
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<Option<String>> {
|
||||
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<AuthConfig>;
|
||||
}
|
||||
|
||||
/// Anonymous authenticator
|
||||
pub struct Anonymous;
|
||||
|
||||
impl Authenticator for Anonymous {
|
||||
fn authorization(&self) -> Result<AuthConfig> {
|
||||
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<AuthConfig> {
|
||||
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<AuthConfig> {
|
||||
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<String, DockerAuthEntry>,
|
||||
#[serde(rename = "credHelpers", default)]
|
||||
pub cred_helpers: HashMap<String, String>,
|
||||
#[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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
#[serde(rename = "identitytoken", skip_serializing_if = "Option::is_none")]
|
||||
pub identity_token: Option<String>,
|
||||
#[serde(rename = "registrytoken", skip_serializing_if = "Option::is_none")]
|
||||
pub registry_token: Option<String>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
169
src/auth/tests.rs
Normal file
169
src/auth/tests.rs
Normal file
|
|
@ -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<Box<dyn Authenticator>> {
|
||||
Ok(Box::new(Anonymous))
|
||||
}
|
||||
}
|
||||
|
||||
// Create a keychain that returns basic auth
|
||||
struct AlwaysBasic;
|
||||
impl Keychain for AlwaysBasic {
|
||||
fn resolve(&self, _: &str) -> Result<Box<dyn Authenticator>> {
|
||||
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<dyn Keychain>,
|
||||
Box::new(AlwaysBasic) as Box<dyn Keychain>,
|
||||
]);
|
||||
|
||||
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()));
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod auth;
|
||||
pub mod builder;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
|
|
|
|||
34
src/main.rs
34
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
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ mod tests;
|
|||
|
||||
pub struct RegistryClient {
|
||||
client: Client,
|
||||
#[allow(dead_code)]
|
||||
auth: RegistryAuth,
|
||||
}
|
||||
|
||||
impl RegistryClient {
|
||||
pub fn new(auth: RegistryAuth) -> Result<Self> {
|
||||
pub fn new() -> Result<Self> {
|
||||
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<u8>,
|
||||
layers: Vec<(Vec<u8>, 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<crate::manifest::ManifestDescriptor>,
|
||||
auth: &RegistryAuth,
|
||||
) -> Result<String> {
|
||||
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<Vec<String>> {
|
||||
pub async fn get_image_platforms(
|
||||
&mut self,
|
||||
image_ref: &str,
|
||||
auth: &RegistryAuth,
|
||||
) -> Result<Vec<String>> {
|
||||
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")?;
|
||||
|
||||
|
|
|
|||
76
tests/auth_integration_test.rs
Normal file
76
tests/auth_integration_test.rs
Normal file
|
|
@ -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 "));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue