Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/wassette/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/wassette/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions crates/wassette/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
217 changes: 217 additions & 0 deletions crates/wassette/src/oci_auth.rs
Original file line number Diff line number Diff line change
@@ -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<RegistryAuth> {
// 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");
}
}
19 changes: 12 additions & 7 deletions crates/wassette/src/oci_multi_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultiLayerArtifact> {
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<MultiLayerArtifact> {
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")?;

Expand Down Expand Up @@ -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<Vec<u8>> {
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<Vec<u8>> {
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");
Expand All @@ -298,10 +302,11 @@ pub async fn pull_wasm_only(reference: &Reference, client: &Client) -> Result<Ve
pub async fn pull_multi_layer_artifact_secure(
reference: &Reference,
client: &Client,
auth: &oci_client::secrets::RegistryAuth,
) -> Result<MultiLayerArtifact> {
// 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)]
Expand Down
4 changes: 3 additions & 1 deletion tests/oci_integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading