Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "harbor-router"
version = "1.4.2"
version = "1.4.3"
edition = "2021"

[[bin]]
Expand Down
52 changes: 49 additions & 3 deletions src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use axum::{
use bytes::Bytes;
use futures::stream::{self, StreamExt};
use std::{
borrow::Cow,
sync::Arc,
time::{Duration, Instant},
};
Expand Down Expand Up @@ -135,19 +136,24 @@ pub async fn registry_handler(
error_response(StatusCode::BAD_REQUEST, "UNSUPPORTED", &e.to_string())
}
Ok((image, PathKind::Manifests, reference)) => {
let image = normalize_docker_library_image(image);
handle_manifest(
&state,
image,
&image,
reference,
auth_header.as_deref(),
&accept_headers,
)
.await
}
Ok((image, PathKind::Blobs, digest)) => {
handle_blob(&state, image, digest, auth_header).await
let image = normalize_docker_library_image(image);
handle_blob(&state, &image, digest, auth_header).await
}
Ok((image, PathKind::Tags, _)) => {
let image = normalize_docker_library_image(image);
handle_tags(&state, &image, auth_header.as_deref()).await
}
Ok((image, PathKind::Tags, _)) => handle_tags(&state, image, auth_header.as_deref()).await,
}
}

Expand Down Expand Up @@ -557,6 +563,18 @@ fn is_safe_reference(reference: &str) -> bool {
&& reference == reference.trim()
}

/// Docker Hub requires `library/` prefix for official single-segment images
/// (e.g. `nginx` → `library/nginx`). Docker CLI adds this automatically when
/// pulling from Docker Hub directly, but not through custom registries.
#[inline]
fn normalize_docker_library_image(image: &str) -> Cow<'_, str> {
if image.contains('/') {
Cow::Borrowed(image)
} else {
Cow::Owned(format!("library/{image}"))
}
}

/// Parses a remainder like `grafana/grafana/manifests/latest` into
/// `(image, kind, reference)` without unnecessary allocations.
#[inline]
Expand Down Expand Up @@ -907,6 +925,34 @@ mod tests {
);
}

#[test]
fn test_normalize_docker_library_image_single_segment() {
let result = normalize_docker_library_image("nginx");
assert_eq!(result, "library/nginx");
assert!(matches!(result, Cow::Owned(_)));
}

#[test]
fn test_normalize_docker_library_image_multi_segment() {
let result = normalize_docker_library_image("grafana/grafana");
assert_eq!(result, "grafana/grafana");
assert!(matches!(result, Cow::Borrowed(_)));
}

#[test]
fn test_normalize_docker_library_image_deeply_nested() {
let result = normalize_docker_library_image("library/redis/alpine");
assert_eq!(result, "library/redis/alpine");
assert!(matches!(result, Cow::Borrowed(_)));
}

#[test]
fn test_normalize_docker_library_image_already_prefixed() {
let result = normalize_docker_library_image("library/nginx");
assert_eq!(result, "library/nginx");
assert!(matches!(result, Cow::Borrowed(_)));
}

#[test]
fn test_is_safe_image_name() {
assert!(is_safe_image_name("nginx"));
Expand Down
117 changes: 95 additions & 22 deletions src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,15 +496,32 @@ impl Resolver {
// first matching 200 response arrives, cancelling remaining futures.
// If no content-type matches the client's Accept header, fall back to
// the first 200 response seen (graceful degradation).
// Grace period: after the first 200 with content-type mismatch,
// wait up to 200ms for a better match before returning the fallback.
const FALLBACK_GRACE: Duration = Duration::from_millis(200);

let count = futures.len();
let mut results = stream::iter(futures).buffer_unordered(count);
let mut last_err: Option<anyhow::Error> = None;
let mut fallback: Option<ResolveResult> = None;
while let Some(res) = results.next().await {
match res {
let mut grace_deadline: Option<tokio::time::Instant> = None;

loop {
let next = if let Some(deadline) = grace_deadline {
match tokio::time::timeout_at(deadline, results.next()).await {
Ok(Some(res)) => res,
// Grace period expired or stream exhausted — return fallback
Ok(None) | Err(_) => break,
}
} else {
match results.next().await {
Some(res) => res,
None => break,
}
};

match next {
Ok(r) if r.status == 200 => {
// Check if this response's Content-Type matches the client's Accept header.
// Empty accept list means no preference — accept any 200.
let ct = r
.headers
.get("content-type")
Expand All @@ -522,7 +539,6 @@ impl Resolver {
);
return Ok(r);
}
// No content-type match — save first 200 as fallback.
debug!(
event = "fanout",
project = r.project,
Expand All @@ -532,6 +548,7 @@ impl Resolver {
);
if fallback.is_none() {
fallback = Some(r);
grace_deadline = Some(tokio::time::Instant::now() + FALLBACK_GRACE);
}
}
Ok(r) => {
Expand All @@ -549,15 +566,14 @@ impl Resolver {
}
}

// Graceful degradation: return fallback 200 even if content-type didn't match.
if let Some(r) = fallback {
info!(
event = "fanout",
image,
reference,
project = r.project,
result = "fallback",
"returning fallback (no content-type match)"
"returning fallback after grace period"
);
return Ok(r);
}
Expand Down Expand Up @@ -1020,30 +1036,37 @@ fn decode_cache_value(value: &str) -> (&str, Option<u64>) {
/// Checks if a response Content-Type matches any of the client's Accept values.
/// Strips parameters (after `;`) and compares media type only.
/// Supports `*/*` (matches everything) and `application/*` (matches any `application/` type).
/// Handles comma-separated media types within a single Accept header value (RFC 7231 §5.3.2).
#[inline]
fn content_type_matches(response_ct: &str, accept_values: &[String]) -> bool {
if accept_values.is_empty() {
return false;
}
// Strip parameters after `;` from response Content-Type
let ct = response_ct.split(';').next().unwrap_or("").trim();
for accept in accept_values {
// Strip parameters after `;` from Accept value
let av = accept.split(';').next().unwrap_or("").trim();
if av == "*/*" {
return true;
}
// Handle type/* wildcards (e.g. application/*)
if let Some(prefix) = av.strip_suffix("/*") {
if let Some(ct_type) = ct.split('/').next() {
if ct_type == prefix {
return true;
for accept_header in accept_values {
// Accept header may contain comma-separated media types (RFC 7231 §5.3.2)
for accept in accept_header.split(',') {
// Strip parameters after `;` (quality factor, charset, etc.)
let av = accept.split(';').next().unwrap_or("").trim();
if av.is_empty() {
continue;
}
if av == "*/*" {
return true;
}
// Handle type/* wildcards (e.g. application/*)
if let Some(prefix) = av.strip_suffix("/*") {
if let Some(ct_type) = ct.split('/').next() {
if ct_type == prefix {
return true;
}
}
}
}
// Exact media type match
if ct.eq_ignore_ascii_case(av) {
return true;
// Exact media type match
if ct.eq_ignore_ascii_case(av) {
return true;
}
}
}
false
Expand Down Expand Up @@ -2028,6 +2051,56 @@ mod tests {
content_type_matches(oci_ct, &accept_multi),
"should match any in list"
);

// Comma-separated Accept header (real Docker client behavior)
let accept_csv = vec![format!("{docker_ct}, {oci_ct}")];
assert!(
content_type_matches(docker_ct, &accept_csv),
"comma-separated: should match first type"
);
assert!(
content_type_matches(oci_ct, &accept_csv),
"comma-separated: should match second type"
);
assert!(
!content_type_matches("text/plain", &accept_csv),
"comma-separated: should not match unrelated type"
);

// Comma-separated with quality parameters
let accept_csv_q = vec![format!("{docker_ct};q=0.9, {oci_ct};q=1.0")];
assert!(
content_type_matches(docker_ct, &accept_csv_q),
"comma-separated with quality: should match"
);
assert!(
content_type_matches(oci_ct, &accept_csv_q),
"comma-separated with quality: should match"
);

// Real-world Docker manifest pull Accept header
let real_docker_accept = vec![
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, */*".to_string()
];
assert!(
content_type_matches(docker_ct, &real_docker_accept),
"real Docker Accept: should match docker manifest v2"
);
assert!(
content_type_matches(oci_ct, &real_docker_accept),
"real Docker Accept: should match OCI manifest v1"
);
assert!(
content_type_matches("text/plain", &real_docker_accept),
"real Docker Accept: */* should match anything"
);

// Comma-separated with wildcard
let accept_csv_wildcard = vec![format!("{docker_ct}, */*")];
assert!(
content_type_matches("anything/at-all", &accept_csv_wildcard),
"comma-separated */* should match anything"
);
}

#[tokio::test]
Expand Down