diff --git a/Cargo.lock b/Cargo.lock index dc40e2ce..cb12c6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4845,6 +4845,7 @@ version = "0.1.0" dependencies = [ "anyhow", "component2json", + "docker_credential", "etcetera", "futures", "hex", diff --git a/crates/wassette/Cargo.toml b/crates/wassette/Cargo.toml index 5b506b41..88b0bc7f 100644 --- a/crates/wassette/Cargo.toml +++ b/crates/wassette/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] anyhow = { workspace = true } component2json = { path = "../component2json" } +docker_credential = "1.3" etcetera = { workspace = true } futures = { workspace = true } hex = "0.4" diff --git a/crates/wassette/src/lib.rs b/crates/wassette/src/lib.rs index 8a9b24e3..963f23cd 100644 --- a/crates/wassette/src/lib.rs +++ b/crates/wassette/src/lib.rs @@ -30,6 +30,7 @@ mod component_storage; mod config; mod http; mod loader; +pub mod oci_auth; pub mod oci_multi_layer; mod policy_internal; mod runtime_context; diff --git a/crates/wassette/src/loader.rs b/crates/wassette/src/loader.rs index e6a04d90..71a241ee 100644 --- a/crates/wassette/src/loader.rs +++ b/crates/wassette/src/loader.rs @@ -200,11 +200,13 @@ impl Loadable for ComponentResource { eprintln!("Downloading component from {}...", reference); } + // Get authentication credentials for this registry + let auth = crate::oci_auth::get_registry_auth(&reference) + .context("Failed to get registry authentication")?; + // First try oci-wasm for backwards compatibility with single-layer artifacts let wasm_client = oci_wasm::WasmClient::from(oci_client.clone()); - let result = wasm_client - .pull(&reference, &oci_client::secrets::RegistryAuth::Anonymous) - .await; + let result = wasm_client.pull(&reference, &auth).await; match result { Ok(data) => { @@ -237,6 +239,7 @@ impl Loadable for ComponentResource { let artifact = crate::oci_multi_layer::pull_multi_layer_artifact_with_progress( &reference, oci_client, + &auth, show_progress, ) .await diff --git a/crates/wassette/src/oci_auth.rs b/crates/wassette/src/oci_auth.rs new file mode 100644 index 00000000..fc26181e --- /dev/null +++ b/crates/wassette/src/oci_auth.rs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! OCI registry authentication support +//! +//! This module provides authentication for OCI registries by reading Docker config files +//! and extracting credentials. It supports both username/password and identity token +//! authentication methods. + +use anyhow::{Context, Result}; +use docker_credential::{CredentialRetrievalError, DockerCredential}; +use oci_client::secrets::RegistryAuth; +use oci_client::Reference; +use tracing::{debug, warn}; + +/// Get authentication credentials for an OCI registry reference +/// +/// This function attempts to read credentials from the Docker config file +/// (typically `~/.docker/config.json`). It follows the standard Docker credential +/// resolution process: +/// +/// 1. Check `$DOCKER_CONFIG/config.json` if the env var is set +/// 2. Check `~/.docker/config.json` as the default location +/// 3. Fall back to `Anonymous` if no config or credentials are found +/// +/// # Arguments +/// +/// * `reference` - The OCI reference to get credentials for +/// +/// # Returns +/// +/// Returns a `RegistryAuth` enum that can be one of: +/// - `Anonymous` - No credentials found or config doesn't exist +/// - `Basic(username, password)` - Username/password credentials +/// - `Bearer(token)` - Not currently supported, falls back to Anonymous +/// +/// # Errors +/// +/// Returns an error if the Docker config file exists but cannot be parsed +/// or if credential retrieval fails for reasons other than missing config. +pub fn get_registry_auth(reference: &Reference) -> Result { + // Get the registry server address from the reference + // Strip trailing slash if present for consistent matching + let server = reference + .resolve_registry() + .strip_suffix('/') + .unwrap_or_else(|| reference.resolve_registry()); + + debug!("Looking up credentials for registry: {}", server); + + // Attempt to retrieve credentials using docker_credential crate + match docker_credential::get_credential(server) { + Ok(DockerCredential::UsernamePassword(username, password)) => { + debug!("Found Docker credentials for registry: {}", server); + Ok(RegistryAuth::Basic(username, password)) + } + Ok(DockerCredential::IdentityToken(_)) => { + // Identity tokens are not supported by oci-client yet + warn!( + "Identity token authentication found for {} but is not supported. Using anonymous access.", + server + ); + Ok(RegistryAuth::Anonymous) + } + Err(CredentialRetrievalError::ConfigNotFound) => { + debug!("Docker config file not found, using anonymous authentication"); + Ok(RegistryAuth::Anonymous) + } + Err(CredentialRetrievalError::ConfigReadError) => { + debug!("Unable to read Docker config file, using anonymous authentication"); + Ok(RegistryAuth::Anonymous) + } + Err(CredentialRetrievalError::NoCredentialConfigured) => { + debug!( + "No credentials configured for registry {}, using anonymous authentication", + server + ); + Ok(RegistryAuth::Anonymous) + } + Err(e) => { + // For other errors (helper failures, decoding errors, etc.), return the error + Err(e).context(format!( + "Failed to retrieve credentials for registry {}", + server + )) + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + + fn create_test_docker_config(dir: &TempDir, config_content: &str) -> PathBuf { + let config_dir = dir.path().join(".docker"); + fs::create_dir_all(&config_dir).unwrap(); + let config_path = config_dir.join("config.json"); + fs::write(&config_path, config_content).unwrap(); + config_path + } + + #[test] + fn test_get_registry_auth_with_basic_credentials() { + use temp_env; + + let temp_dir = TempDir::new().unwrap(); + + // Create a test Docker config with basic auth + let config_content = r#"{ + "auths": { + "ghcr.io": { + "auth": "dGVzdHVzZXI6dGVzdHBhc3M=" + } + } + }"#; + + let config_path = create_test_docker_config(&temp_dir, config_content); + + // Set DOCKER_CONFIG to point to our test directory + let docker_config_dir = config_path.parent().unwrap(); + + temp_env::with_var("DOCKER_CONFIG", Some(docker_config_dir), || { + let reference: Reference = "ghcr.io/test/image:latest".parse().unwrap(); + let auth = get_registry_auth(&reference).unwrap(); + + match auth { + RegistryAuth::Basic(username, password) => { + assert_eq!(username, "testuser"); + assert_eq!(password, "testpass"); + } + _ => panic!("Expected Basic auth, got: {:?}", auth), + } + }); + } + + #[test] + fn test_get_registry_auth_no_config() { + use temp_env; + + let temp_dir = TempDir::new().unwrap(); + + // Set DOCKER_CONFIG to empty temp dir (no config.json) + temp_env::with_var("DOCKER_CONFIG", Some(temp_dir.path()), || { + let reference: Reference = "docker.io/library/nginx:latest".parse().unwrap(); + let auth = get_registry_auth(&reference).unwrap(); + + assert!( + matches!(auth, RegistryAuth::Anonymous), + "Expected Anonymous auth when config not found" + ); + }); + } + + #[test] + fn test_get_registry_auth_no_credentials_for_registry() { + use temp_env; + + let temp_dir = TempDir::new().unwrap(); + + // Create config with credentials for a different registry + let config_content = r#"{ + "auths": { + "other-registry.io": { + "auth": "dGVzdHVzZXI6dGVzdHBhc3M=" + } + } + }"#; + + let config_path = create_test_docker_config(&temp_dir, config_content); + let docker_config_dir = config_path.parent().unwrap(); + + temp_env::with_var("DOCKER_CONFIG", Some(docker_config_dir), || { + // Try to get auth for a registry not in the config + let reference: Reference = "docker.io/library/nginx:latest".parse().unwrap(); + let auth = get_registry_auth(&reference).unwrap(); + + assert!( + matches!(auth, RegistryAuth::Anonymous), + "Expected Anonymous auth when no credentials for registry" + ); + }); + } + + #[test] + fn test_reference_parsing() { + // Test that various OCI references parse correctly + let test_cases = vec![ + "ghcr.io/microsoft/wassette:latest", + "docker.io/library/nginx:1.0", + "localhost:5000/myimage:v1", + ]; + + for reference_str in test_cases { + let reference: Reference = reference_str.parse().unwrap(); + let registry = reference.resolve_registry(); + assert!(!registry.is_empty(), "Registry should not be empty"); + } + } + + #[test] + fn test_registry_server_stripping() { + // Test that trailing slashes are handled correctly + let reference: Reference = "ghcr.io/test/image:latest".parse().unwrap(); + let server = reference + .resolve_registry() + .strip_suffix('/') + .unwrap_or_else(|| reference.resolve_registry()); + + // Should not have trailing slash + assert!(!server.ends_with('/'), "Server should not end with slash"); + } +} diff --git a/crates/wassette/src/oci_multi_layer.rs b/crates/wassette/src/oci_multi_layer.rs index 8599d45e..35489d79 100644 --- a/crates/wassette/src/oci_multi_layer.rs +++ b/crates/wassette/src/oci_multi_layer.rs @@ -97,25 +97,25 @@ fn verify_digest(data: &[u8], expected_digest: &str) -> Result<()> { pub async fn pull_multi_layer_artifact( reference: &Reference, client: &Client, + auth: &oci_client::secrets::RegistryAuth, ) -> Result { - pull_multi_layer_artifact_with_progress(reference, client, false).await + pull_multi_layer_artifact_with_progress(reference, client, auth, false).await } /// Pull a multi-layer OCI artifact and extract all relevant layers with optional progress reporting pub async fn pull_multi_layer_artifact_with_progress( reference: &Reference, client: &Client, + auth: &oci_client::secrets::RegistryAuth, show_progress: bool, ) -> Result { - let auth = oci_client::secrets::RegistryAuth::Anonymous; - // Pull just the manifest first if show_progress { eprintln!("Pulling manifest for {}...", reference); } info!("Pulling OCI manifest: {}", reference); let (manifest, manifest_digest) = client - .pull_manifest(reference, &auth) + .pull_manifest(reference, auth) .await .context("Failed to pull OCI manifest")?; @@ -276,8 +276,12 @@ pub async fn pull_multi_layer_artifact_with_progress( /// Pull just the WASM component from a multi-layer OCI artifact /// This is a compatibility function that ignores non-WASM layers -pub async fn pull_wasm_only(reference: &Reference, client: &Client) -> Result> { - let artifact = pull_multi_layer_artifact(reference, client).await?; +pub async fn pull_wasm_only( + reference: &Reference, + client: &Client, + auth: &oci_client::secrets::RegistryAuth, +) -> Result> { + let artifact = pull_multi_layer_artifact(reference, client, auth).await?; if artifact.policy_data.is_some() { info!("Note: Policy layer found but will not be processed in this context"); @@ -298,10 +302,11 @@ pub async fn pull_wasm_only(reference: &Reference, client: &Client) -> Result Result { // This uses the same implementation as pull_multi_layer_artifact // since we've already added digest verification there - pull_multi_layer_artifact_with_progress(reference, client, false).await + pull_multi_layer_artifact_with_progress(reference, client, auth, false).await } #[cfg(test)] diff --git a/tests/oci_integration_test.rs b/tests/oci_integration_test.rs index 42e8e7f7..256858a9 100644 --- a/tests/oci_integration_test.rs +++ b/tests/oci_integration_test.rs @@ -252,8 +252,10 @@ mod multi_layer_oci_tests { ..Default::default() }); + let auth = oci_client::secrets::RegistryAuth::Anonymous; let artifact = - wassette::oci_multi_layer::pull_multi_layer_artifact(&reference, &client).await?; + wassette::oci_multi_layer::pull_multi_layer_artifact(&reference, &client, &auth) + .await?; // Verify WASM component was downloaded assert!(!artifact.wasm_data.is_empty());