mirror of
https://github.com/imjasonh/krust
synced 2026-07-08 06:45:32 +00:00
feat: Add credential helper support to oci-distribution
- Move Docker config parsing and credential helper execution from krust - Add automatic auth resolution methods (*_auto) to the client - Support standard Docker config locations and environment variables - Add comprehensive tests and examples - Simplify krust to use the new credential helper functionality
This commit is contained in:
parent
4af701e617
commit
a6ae83c3b2
12 changed files with 887 additions and 15 deletions
|
|
@ -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"
|
||||
|
|
|
|||
71
examples/auto_auth_demo.rs
Normal file
71
examples/auto_auth_demo.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Example demonstrating automatic authentication with oci-distribution
|
||||
//!
|
||||
//! This example shows how to use the automatic auth methods that resolve
|
||||
//! credentials from Docker config files and credential helpers.
|
||||
|
||||
use anyhow::Result;
|
||||
use oci_distribution::{client::Client, Reference};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Create a client
|
||||
let client = Client::default();
|
||||
|
||||
// Example 1: Pull a public image (anonymous auth)
|
||||
println!("Pulling public image alpine:latest...");
|
||||
let reference: Reference = "docker.io/library/alpine:latest".parse()?;
|
||||
|
||||
match client.pull_manifest_auto(&reference).await {
|
||||
Ok((manifest, digest)) => {
|
||||
println!("✓ Successfully pulled manifest");
|
||||
println!(" Digest: {}", digest);
|
||||
match manifest {
|
||||
oci_distribution::manifest::OciManifest::Image(img) => {
|
||||
println!(" Type: Single platform image");
|
||||
println!(" Config: {}", img.config.digest);
|
||||
}
|
||||
oci_distribution::manifest::OciManifest::ImageIndex(idx) => {
|
||||
println!(" Type: Multi-platform image");
|
||||
println!(" Platforms: {}", idx.manifests.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✗ Failed to pull: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 2: Get platforms for an image
|
||||
println!("\nDetecting platforms for rust:alpine...");
|
||||
let rust_ref: Reference = "docker.io/library/rust:alpine".parse()?;
|
||||
|
||||
match client.get_image_platforms_auto(&rust_ref).await {
|
||||
Ok(platforms) => {
|
||||
println!("✓ Found {} platforms:", platforms.len());
|
||||
for (os, arch) in platforms {
|
||||
println!(" - {}/{}", os, arch);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✗ Failed to get platforms: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 3: Private registry (requires auth)
|
||||
println!("\nNote: To test with a private registry, ensure you have credentials in:");
|
||||
println!(" - ~/.docker/config.json");
|
||||
println!(" - Or DOCKER_CONFIG environment variable");
|
||||
println!(" - Or using a credential helper");
|
||||
|
||||
// Uncomment to test with a private registry:
|
||||
// let private_ref: Reference = "ghcr.io/your-username/your-image:latest".parse()?;
|
||||
// match client.pull_manifest_auto(&private_ref).await {
|
||||
// Ok((_, digest)) => println!("✓ Successfully authenticated and pulled private image: {}", digest),
|
||||
// Err(e) => println!("✗ Failed to pull private image: {}", e),
|
||||
// }
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
11
src/auth/simple.rs
Normal file
11
src/auth/simple.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! Simple authentication wrapper using oci-distribution's credential helper
|
||||
|
||||
use anyhow::Result;
|
||||
use oci_distribution::credential_helper::resolve_docker_auth;
|
||||
use oci_distribution::secrets::RegistryAuth;
|
||||
|
||||
/// Resolve authentication for a given resource using Docker config and credential helpers
|
||||
pub fn resolve_auth(resource: &str) -> Result<RegistryAuth> {
|
||||
resolve_docker_auth(resource)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to resolve auth: {}", e))
|
||||
}
|
||||
20
src/main.rs
20
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)
|
||||
|
|
|
|||
111
tests/credential_helper_integration_test.rs
Normal file
111
tests/credential_helper_integration_test.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Integration tests for credential helper functionality
|
||||
|
||||
use anyhow::Result;
|
||||
use krust::auth::resolve_auth;
|
||||
use oci_distribution::secrets::RegistryAuth;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_auth_anonymous() -> Result<()> {
|
||||
// Create empty temp directory for Docker config
|
||||
let tmp_dir = TempDir::new()?;
|
||||
|
||||
// Save current env vars
|
||||
let old_docker_config = env::var("DOCKER_CONFIG").ok();
|
||||
let old_registry_auth = env::var("REGISTRY_AUTH_FILE").ok();
|
||||
|
||||
// Set to empty directory
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
env::remove_var("REGISTRY_AUTH_FILE");
|
||||
|
||||
// Should resolve to anonymous
|
||||
let auth = resolve_auth("docker.io/library/alpine")?;
|
||||
assert!(matches!(auth, RegistryAuth::Anonymous));
|
||||
|
||||
// Restore env vars
|
||||
if let Some(val) = old_docker_config {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
if let Some(val) = old_registry_auth {
|
||||
env::set_var("REGISTRY_AUTH_FILE", val);
|
||||
} else {
|
||||
env::remove_var("REGISTRY_AUTH_FILE");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_auth_from_config() -> Result<()> {
|
||||
let tmp_dir = TempDir::new()?;
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
|
||||
// Create a test config
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"test.registry.io": {
|
||||
"username": "testuser",
|
||||
"password": "testpass"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, config)?;
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("DOCKER_CONFIG").ok();
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
|
||||
// Should resolve to basic auth
|
||||
let auth = resolve_auth("test.registry.io/myimage")?;
|
||||
assert!(matches!(auth, RegistryAuth::Basic(user, pass)
|
||||
if user == "testuser" && pass == "testpass"));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_auth_bearer_token() -> Result<()> {
|
||||
let tmp_dir = TempDir::new()?;
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
|
||||
// Create a test config with bearer token
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"ghcr.io": {
|
||||
"registrytoken": "test-bearer-token"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, config)?;
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("DOCKER_CONFIG").ok();
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
|
||||
// Should resolve to bearer auth
|
||||
let auth = resolve_auth("ghcr.io/user/image")?;
|
||||
assert!(matches!(auth, RegistryAuth::Bearer(token)
|
||||
if token == "test-bearer-token"));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
2
vendor/oci-distribution/Cargo.toml
vendored
2
vendor/oci-distribution/Cargo.toml
vendored
|
|
@ -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 }
|
||||
|
|
|
|||
39
vendor/oci-distribution/src/client.rs
vendored
39
vendor/oci-distribution/src/client.rs
vendored
|
|
@ -918,6 +918,45 @@ impl Client {
|
|||
})
|
||||
}
|
||||
|
||||
/// Pull a manifest with automatic auth resolution using Docker config and credential helpers
|
||||
pub async fn pull_manifest_auto(
|
||||
&self,
|
||||
image: &Reference,
|
||||
) -> Result<(OciManifest, String)> {
|
||||
let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?;
|
||||
self.pull_manifest(image, &auth).await
|
||||
}
|
||||
|
||||
/// Pull an image with automatic auth resolution using Docker config and credential helpers
|
||||
pub async fn pull_auto(
|
||||
&self,
|
||||
image: &Reference,
|
||||
) -> Result<ImageData> {
|
||||
let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?;
|
||||
self.pull(image, &auth, vec![]).await
|
||||
}
|
||||
|
||||
/// Push an image with automatic auth resolution using Docker config and credential helpers
|
||||
pub async fn push_auto(
|
||||
&self,
|
||||
image_ref: &Reference,
|
||||
layers: &[ImageLayer],
|
||||
config: Config,
|
||||
manifest: Option<OciImageManifest>,
|
||||
) -> Result<PushResponse> {
|
||||
let auth = crate::credential_helper::resolve_docker_auth(&image_ref.to_string())?;
|
||||
self.push(image_ref, layers, config, &auth, manifest).await
|
||||
}
|
||||
|
||||
/// Get image platforms with automatic auth resolution
|
||||
pub async fn get_image_platforms_auto(
|
||||
&self,
|
||||
image: &Reference,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let auth = crate::credential_helper::resolve_docker_auth(&image.to_string())?;
|
||||
self.get_image_platforms(image, &auth).await
|
||||
}
|
||||
|
||||
async fn _pull_manifest_and_config(
|
||||
&self,
|
||||
image: &Reference,
|
||||
|
|
|
|||
165
vendor/oci-distribution/src/credential_helper.rs
vendored
Normal file
165
vendor/oci-distribution/src/credential_helper.rs
vendored
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! Docker credential helper support
|
||||
|
||||
use crate::docker_config::{DockerAuthEntry, DockerConfig, extract_registry, load_docker_config, normalize_registry};
|
||||
use crate::errors::{OciDistributionError, Result};
|
||||
use crate::secrets::RegistryAuth;
|
||||
use serde::Deserialize;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Response from a Docker credential helper
|
||||
#[derive(Deserialize)]
|
||||
struct HelperResponse {
|
||||
#[serde(rename = "Username")]
|
||||
username: Option<String>,
|
||||
#[serde(rename = "Secret")]
|
||||
secret: Option<String>,
|
||||
#[serde(rename = "ServerURL")]
|
||||
_server_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Execute a credential helper to get credentials
|
||||
pub fn execute_credential_helper(helper: &str, registry: &str) -> Result<(String, String)> {
|
||||
let helper_name = format!("docker-credential-{}", helper);
|
||||
|
||||
debug!("Executing credential helper: {} for {}", helper_name, registry);
|
||||
|
||||
let mut child = Command::new(&helper_name)
|
||||
.arg("get")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| OciDistributionError::GenericError(Some(
|
||||
format!("Failed to spawn credential helper {}: {}", helper_name, e)
|
||||
)))?;
|
||||
|
||||
// Write registry URL to stdin
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(registry.as_bytes()).map_err(|e| {
|
||||
OciDistributionError::IoError(e)
|
||||
})?;
|
||||
stdin.write_all(b"\n").map_err(|e| {
|
||||
OciDistributionError::IoError(e)
|
||||
})?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().map_err(|e| {
|
||||
OciDistributionError::IoError(e)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(OciDistributionError::GenericError(Some(
|
||||
format!("Credential helper {} failed: {}", helper_name, stderr)
|
||||
)));
|
||||
}
|
||||
|
||||
// Parse output as JSON
|
||||
let response: HelperResponse = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|e| OciDistributionError::GenericError(Some(
|
||||
format!("Failed to parse credential helper response: {}", e)
|
||||
)))?;
|
||||
|
||||
match (response.username, response.secret) {
|
||||
(Some(username), Some(password)) => Ok((username, password)),
|
||||
_ => Err(OciDistributionError::GenericError(Some(
|
||||
"Credential helper did not return username and password".to_string()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find auth entry for a registry in Docker config
|
||||
fn find_auth_entry(config: &DockerConfig, registry: &str) -> Option<DockerAuthEntry> {
|
||||
let variants = normalize_registry(registry);
|
||||
|
||||
for variant in variants {
|
||||
if let Some(entry) = config.auths.get(&variant) {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get credential helper for a registry
|
||||
fn get_credential_helper(config: &DockerConfig, registry: &str) -> Option<String> {
|
||||
// Check specific credential helper for registry
|
||||
if let Some(helper) = config.cred_helpers.get(registry) {
|
||||
return Some(helper.clone());
|
||||
}
|
||||
|
||||
// Check default credential store
|
||||
config.creds_store.clone()
|
||||
}
|
||||
|
||||
/// Resolve authentication for a given resource using Docker config and credential helpers
|
||||
pub fn resolve_docker_auth(resource: &str) -> Result<RegistryAuth> {
|
||||
let config = load_docker_config()?;
|
||||
let registry = extract_registry(resource);
|
||||
|
||||
debug!("Resolving auth for resource: {} (registry: {})", resource, registry);
|
||||
|
||||
// Try to find auth entry in config
|
||||
if let Some(auth_entry) = find_auth_entry(&config, registry) {
|
||||
debug!("Found auth entry for {}", registry);
|
||||
|
||||
// Check if it's anonymous
|
||||
if auth_entry.is_anonymous() {
|
||||
return Ok(RegistryAuth::Anonymous);
|
||||
}
|
||||
|
||||
// Check for bearer tokens first
|
||||
if let Some(token) = auth_entry.registry_token {
|
||||
return Ok(RegistryAuth::Bearer(token));
|
||||
}
|
||||
|
||||
if let Some(token) = auth_entry.identity_token {
|
||||
return Ok(RegistryAuth::Bearer(token));
|
||||
}
|
||||
|
||||
// Then check for basic auth
|
||||
if let (Some(username), Some(password)) = (&auth_entry.username, &auth_entry.password) {
|
||||
return Ok(RegistryAuth::Basic(username.clone(), password.clone()));
|
||||
}
|
||||
|
||||
// Try to decode base64 auth string
|
||||
if let Some(auth) = &auth_entry.auth {
|
||||
use base64::Engine;
|
||||
match base64::engine::general_purpose::STANDARD.decode(auth) {
|
||||
Ok(decoded) => {
|
||||
if let Ok(decoded_str) = String::from_utf8(decoded) {
|
||||
if let Some((user, pass)) = decoded_str.split_once(':') {
|
||||
return Ok(RegistryAuth::Basic(user.to_string(), pass.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to decode auth for {}: {}", registry, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try credential helper
|
||||
if let Some(helper) = get_credential_helper(&config, registry) {
|
||||
debug!("Trying credential helper: {} for {}", helper, registry);
|
||||
match execute_credential_helper(&helper, registry) {
|
||||
Ok((username, password)) => {
|
||||
return Ok(RegistryAuth::Basic(username, password));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Credential helper failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to anonymous
|
||||
debug!("No credentials found for {}, using anonymous", registry);
|
||||
Ok(RegistryAuth::Anonymous)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "credential_helper_tests.rs"]
|
||||
mod tests;
|
||||
295
vendor/oci-distribution/src/credential_helper_tests.rs
vendored
Normal file
295
vendor/oci-distribution/src/credential_helper_tests.rs
vendored
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
//! Tests for credential helper functionality
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::credential_helper::*;
|
||||
use crate::secrets::RegistryAuth;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_anonymous_when_no_config() {
|
||||
// Create a temp directory for DOCKER_CONFIG that doesn't have any config
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Save current env vars
|
||||
let old_docker_config = env::var("DOCKER_CONFIG").ok();
|
||||
let old_registry_auth = env::var("REGISTRY_AUTH_FILE").ok();
|
||||
|
||||
// Set to our empty temp directory
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
env::remove_var("REGISTRY_AUTH_FILE");
|
||||
|
||||
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Anonymous);
|
||||
|
||||
// Restore env vars
|
||||
if let Some(val) = old_docker_config {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
if let Some(val) = old_registry_auth {
|
||||
env::set_var("REGISTRY_AUTH_FILE", val);
|
||||
} else {
|
||||
env::remove_var("REGISTRY_AUTH_FILE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_basic_auth_from_config() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"docker.io": {
|
||||
"username": "testuser",
|
||||
"password": "testpass"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, config).unwrap();
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("DOCKER_CONFIG").ok();
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
|
||||
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Basic("testuser".to_string(), "testpass".to_string()));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_bearer_token_from_config() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"ghcr.io": {
|
||||
"registrytoken": "ghp_abc123token"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, config).unwrap();
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("DOCKER_CONFIG").ok();
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
|
||||
let auth = resolve_docker_auth("ghcr.io/user/image").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Bearer("ghp_abc123token".to_string()));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_base64_auth_from_config() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
|
||||
// Base64 encoded "testuser:testpass"
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"docker.io": {
|
||||
"auth": "dGVzdHVzZXI6dGVzdHBhc3M="
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, config).unwrap();
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("DOCKER_CONFIG").ok();
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
|
||||
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Basic("testuser".to_string(), "testpass".to_string()));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_normalization() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
|
||||
// Config has index.docker.io but we query with docker.io
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"index.docker.io": {
|
||||
"username": "testuser",
|
||||
"password": "testpass"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, config).unwrap();
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("DOCKER_CONFIG").ok();
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
|
||||
// Should find auth even though we use docker.io
|
||||
let auth = resolve_docker_auth("docker.io/library/alpine").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Basic("testuser".to_string(), "testpass".to_string()));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_helper_mock() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a mock credential helper script
|
||||
let helper_path = tmp_dir.path().join("docker-credential-mock");
|
||||
let mut file = fs::File::create(&helper_path).unwrap();
|
||||
writeln!(file, "#!/bin/sh").unwrap();
|
||||
writeln!(file, "echo '{{\"Username\":\"helper-user\",\"Secret\":\"helper-pass\"}}'").unwrap();
|
||||
|
||||
// Make it executable
|
||||
let mut perms = fs::metadata(&helper_path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&helper_path, perms).unwrap();
|
||||
|
||||
// Create config that uses the helper
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
let config = r#"{
|
||||
"credHelpers": {
|
||||
"mock.registry.io": "mock"
|
||||
}
|
||||
}"#;
|
||||
fs::write(&config_path, config).unwrap();
|
||||
|
||||
// Save current env vars
|
||||
let old_config = env::var("DOCKER_CONFIG").ok();
|
||||
let old_path = env::var("PATH").ok();
|
||||
|
||||
// Set our temp dir in PATH and DOCKER_CONFIG
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
let new_path = format!("{}:{}", tmp_dir.path().display(), env::var("PATH").unwrap_or_default());
|
||||
env::set_var("PATH", new_path);
|
||||
|
||||
// Test the credential helper
|
||||
let auth = resolve_docker_auth("mock.registry.io/test/image").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Basic("helper-user".to_string(), "helper-pass".to_string()));
|
||||
|
||||
// Restore env vars
|
||||
if let Some(val) = old_config {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
if let Some(val) = old_path {
|
||||
env::set_var("PATH", val);
|
||||
} else {
|
||||
env::remove_var("PATH");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_credential_store_mock() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a mock credential helper script
|
||||
let helper_path = tmp_dir.path().join("docker-credential-defaultstore");
|
||||
let mut file = fs::File::create(&helper_path).unwrap();
|
||||
writeln!(file, "#!/bin/sh").unwrap();
|
||||
writeln!(file, "echo '{{\"Username\":\"store-user\",\"Secret\":\"store-pass\"}}'").unwrap();
|
||||
|
||||
// Make it executable
|
||||
let mut perms = fs::metadata(&helper_path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&helper_path, perms).unwrap();
|
||||
|
||||
// Create config that uses default creds store
|
||||
let config_path = tmp_dir.path().join("config.json");
|
||||
let config = r#"{
|
||||
"credsStore": "defaultstore"
|
||||
}"#;
|
||||
fs::write(&config_path, config).unwrap();
|
||||
|
||||
// Save current env vars
|
||||
let old_config = env::var("DOCKER_CONFIG").ok();
|
||||
let old_path = env::var("PATH").ok();
|
||||
|
||||
// Set our temp dir in PATH and DOCKER_CONFIG
|
||||
env::set_var("DOCKER_CONFIG", tmp_dir.path());
|
||||
let new_path = format!("{}:{}", tmp_dir.path().display(), env::var("PATH").unwrap_or_default());
|
||||
env::set_var("PATH", new_path);
|
||||
|
||||
// Test the credential helper
|
||||
let auth = resolve_docker_auth("any.registry.io/test/image").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Basic("store-user".to_string(), "store-pass".to_string()));
|
||||
|
||||
// Restore env vars
|
||||
if let Some(val) = old_config {
|
||||
env::set_var("DOCKER_CONFIG", val);
|
||||
} else {
|
||||
env::remove_var("DOCKER_CONFIG");
|
||||
}
|
||||
if let Some(val) = old_path {
|
||||
env::set_var("PATH", val);
|
||||
} else {
|
||||
env::remove_var("PATH");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_auth_file_env() {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
let auth_file = tmp_dir.path().join("auth.json");
|
||||
|
||||
let config = r#"{
|
||||
"auths": {
|
||||
"special.registry.io": {
|
||||
"username": "authfile-user",
|
||||
"password": "authfile-pass"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&auth_file, config).unwrap();
|
||||
|
||||
// Save current env var
|
||||
let old_val = env::var("REGISTRY_AUTH_FILE").ok();
|
||||
env::set_var("REGISTRY_AUTH_FILE", auth_file.to_str().unwrap());
|
||||
|
||||
let auth = resolve_docker_auth("special.registry.io/image").unwrap();
|
||||
assert_eq!(auth, RegistryAuth::Basic("authfile-user".to_string(), "authfile-pass".to_string()));
|
||||
|
||||
// Restore env var
|
||||
if let Some(val) = old_val {
|
||||
env::set_var("REGISTRY_AUTH_FILE", val);
|
||||
} else {
|
||||
env::remove_var("REGISTRY_AUTH_FILE");
|
||||
}
|
||||
}
|
||||
}
|
||||
180
vendor/oci-distribution/src/docker_config.rs
vendored
Normal file
180
vendor/oci-distribution/src/docker_config.rs
vendored
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
//! Docker config file parsing and credential helper support
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Docker config file structure
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DockerConfig {
|
||||
/// Registry authentication entries
|
||||
#[serde(default)]
|
||||
pub auths: HashMap<String, DockerAuthEntry>,
|
||||
/// Registry-specific credential helpers
|
||||
#[serde(rename = "credHelpers", default)]
|
||||
pub cred_helpers: HashMap<String, String>,
|
||||
/// Default credential store to use
|
||||
#[serde(rename = "credsStore", skip_serializing_if = "Option::is_none")]
|
||||
pub creds_store: Option<String>,
|
||||
}
|
||||
|
||||
/// Entry in the Docker config auths section
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DockerAuthEntry {
|
||||
/// Base64-encoded username:password
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<String>,
|
||||
/// Username for authentication
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
/// Password for authentication
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
/// Identity token for registry authentication
|
||||
#[serde(rename = "identitytoken", skip_serializing_if = "Option::is_none")]
|
||||
pub identity_token: Option<String>,
|
||||
/// Registry token for bearer authentication
|
||||
#[serde(rename = "registrytoken", skip_serializing_if = "Option::is_none")]
|
||||
pub registry_token: Option<String>,
|
||||
}
|
||||
|
||||
impl DockerAuthEntry {
|
||||
/// Check if this is anonymous authentication
|
||||
pub fn is_anonymous(&self) -> bool {
|
||||
self.username.is_none()
|
||||
&& self.password.is_none()
|
||||
&& self.auth.is_none()
|
||||
&& self.identity_token.is_none()
|
||||
&& self.registry_token.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get paths to check for Docker config
|
||||
pub fn config_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Check DOCKER_CONFIG environment variable
|
||||
if let Ok(docker_config) = std::env::var("DOCKER_CONFIG") {
|
||||
paths.push(PathBuf::from(docker_config).join("config.json"));
|
||||
}
|
||||
|
||||
// Check REGISTRY_AUTH_FILE environment variable
|
||||
if let Ok(auth_file) = std::env::var("REGISTRY_AUTH_FILE") {
|
||||
paths.push(PathBuf::from(auth_file));
|
||||
}
|
||||
|
||||
// Check XDG_RUNTIME_DIR for containers auth
|
||||
if let Ok(xdg_runtime) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
paths.push(PathBuf::from(xdg_runtime).join("containers/auth.json"));
|
||||
}
|
||||
|
||||
// Check default Docker config location
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
paths.push(home.join(".docker/config.json"));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Load Docker config from disk
|
||||
pub fn load_docker_config() -> crate::errors::Result<DockerConfig> {
|
||||
// Try each config path
|
||||
for path in config_paths() {
|
||||
if path.exists() {
|
||||
debug!("Checking Docker config at: {}", path.display());
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<DockerConfig>(&content) {
|
||||
Ok(config) => {
|
||||
debug!("Loaded Docker config from: {}", path.display());
|
||||
return Ok(config);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse Docker config at {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to read Docker config at {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty config if no valid config found
|
||||
Ok(DockerConfig {
|
||||
auths: HashMap::new(),
|
||||
cred_helpers: HashMap::new(),
|
||||
creds_store: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract registry from image reference
|
||||
pub fn extract_registry(image_ref: &str) -> &str {
|
||||
// Handle different image reference formats:
|
||||
// - docker.io/library/ubuntu:latest -> docker.io
|
||||
// - gcr.io/project/image:tag -> gcr.io
|
||||
// - localhost:5000/image -> localhost:5000
|
||||
// - ubuntu:latest -> docker.io (implicit)
|
||||
|
||||
if let Some(slash_pos) = image_ref.find('/') {
|
||||
let registry_part = &image_ref[..slash_pos];
|
||||
|
||||
// Check if this looks like a registry (contains . or :)
|
||||
if registry_part.contains('.') || registry_part.contains(':') {
|
||||
return registry_part;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Docker Hub
|
||||
"index.docker.io"
|
||||
}
|
||||
|
||||
/// Normalize registry URL for matching
|
||||
pub fn normalize_registry(registry: &str) -> Vec<String> {
|
||||
let mut variants = vec![registry.to_string()];
|
||||
|
||||
// Add common variants
|
||||
if registry == "docker.io" || registry == "index.docker.io" {
|
||||
variants.push("docker.io".to_string());
|
||||
variants.push("index.docker.io".to_string());
|
||||
variants.push("https://index.docker.io/v1/".to_string());
|
||||
variants.push("https://index.docker.io/v2/".to_string());
|
||||
} else if !registry.starts_with("http://") && !registry.starts_with("https://") {
|
||||
// Add protocol variants
|
||||
variants.push(format!("https://{}", registry));
|
||||
variants.push(format!("http://{}", registry));
|
||||
|
||||
// Add /v1/ and /v2/ variants
|
||||
variants.push(format!("https://{}/v1/", registry));
|
||||
variants.push(format!("https://{}/v2/", registry));
|
||||
}
|
||||
|
||||
variants
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_registry() {
|
||||
assert_eq!(extract_registry("docker.io/library/ubuntu:latest"), "docker.io");
|
||||
assert_eq!(extract_registry("gcr.io/project/image:tag"), "gcr.io");
|
||||
assert_eq!(extract_registry("localhost:5000/image"), "localhost:5000");
|
||||
assert_eq!(extract_registry("ubuntu:latest"), "index.docker.io");
|
||||
assert_eq!(extract_registry("user/image:tag"), "index.docker.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_registry() {
|
||||
let variants = normalize_registry("docker.io");
|
||||
assert!(variants.contains(&"docker.io".to_string()));
|
||||
assert!(variants.contains(&"index.docker.io".to_string()));
|
||||
|
||||
let variants = normalize_registry("gcr.io");
|
||||
assert!(variants.contains(&"gcr.io".to_string()));
|
||||
assert!(variants.contains(&"https://gcr.io".to_string()));
|
||||
}
|
||||
}
|
||||
2
vendor/oci-distribution/src/lib.rs
vendored
2
vendor/oci-distribution/src/lib.rs
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue