use crate::config::{Config, RegistryConfig, DEFAULT_AUTH_USERNAME}; use crate::version::VersionInfo; use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use reqwest::{header::HeaderMap, Client as HttpClient, Response, StatusCode}; use std::collections::{HashMap, HashSet}; use std::sync::{LazyLock, Mutex}; use std::time::Duration; use tokio::time::{sleep, sleep_until, Instant}; use tracing::{debug, warn}; // Registries implementing the distribution spec (Docker Hub, ghcr.io, lscr.io) // cap `n` at 1000; a larger value is clamped, so 1000 minimises the number of // requests (and thus 429s). const OCI_PAGE_SIZE: usize = 1000; // Upper bound on tags fetched per image, as a runaway guard only. Tag listings are // NOT ordered by version (Docker Hub's registry API is lexical, ghcr.io/lscr.io are // chronological), so we must scan the whole list to reliably find the highest // version rather than truncate and risk missing it. Real repos (e.g. // jellyfin/jellyfin at ~13.6k tags) finish far below this; hitting it means the // result may be incomplete and is logged loudly. const MAX_TAGS_SCANNED: usize = 50_000; const MAX_RETRY_ATTEMPTS: u32 = 5; const MAX_AUTH_ATTEMPTS: u32 = 2; const INITIAL_RETRY_DELAY_SECS: u64 = 1; const INITIAL_RATE_LIMIT_DELAY_SECS: u64 = 5; const HTTP_TIMEOUT_SECS: u64 = 30; const HTTP_CONNECT_TIMEOUT_SECS: u64 = 10; // Evict idle keep-alive connections well before registries (Docker Hub, GHCR, // lscr.io) close them server-side. Reusing a connection the server has already // dropped is what surfaces as "connection closed before message completed"; // recycling early makes that race rare, and `fetch_with_retry` covers the rest. const POOL_IDLE_TIMEOUT_SECS: u64 = 30; #[derive(Debug, Clone)] pub struct ImageRef { pub registry: String, pub namespace: Option, pub name: String, pub tag: String, } impl ImageRef { pub fn parse(image: &str) -> Result { let (image_part, tag) = if let Some(last_delim) = image.rfind(':') { (&image[..last_delim], &image[last_delim + 1..]) } else { (image, "latest") }; let registry_parts: Vec<&str> = image_part.split('/').collect(); let (registry, namespace, name) = match registry_parts.len() { 0 => return Err(anyhow!("Invalid image format: {}", image)), 1 => ("docker.io".to_string(), None, registry_parts[0].to_string()), 2 => { if registry_parts[0].contains('.') || registry_parts[0].contains(':') { ( registry_parts[0].to_string(), None, registry_parts[1].to_string(), ) } else { ( "docker.io".to_string(), Some(registry_parts[0].to_string()), registry_parts[1].to_string(), ) } } _ => { let registry = registry_parts[0].to_string(); let name = registry_parts[registry_parts.len() - 1].to_string(); let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/")); (registry, namespace, name) } }; Ok(ImageRef { registry, namespace, name, tag: tag.to_string(), }) } } impl std::fmt::Display for ImageRef { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.registry == "docker.io" { match &self.namespace { Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag), None => write!(f, "{}:{}", self.name, self.tag), } } else { match &self.namespace { Some(ns) => write!(f, "{}/{}/{}:{}", self.registry, ns, self.name, self.tag), None => write!(f, "{}/{}:{}", self.registry, self.name, self.tag), } } } } /// A fully-read HTTP response. The body is read inside the retry scope so that a /// connection dropped mid-body is retried like any other transport failure; /// callers therefore receive the body as an owned string rather than a streaming /// `Response`, while retaining the status and headers they need. struct FetchResult { status: StatusCode, headers: HeaderMap, body: String, } pub struct Client { http_client: HttpClient, config: Config, /// Registries whose configured credentials have been rejected, which are from /// then on asked for anonymous tokens only. Re-offering credentials already /// known to be bad costs a full retry budget per image and, on Docker Hub, /// trips the failed-login throttle — whose 429s then delay the anonymous /// request that would have worked. Config is only read at startup, so a /// corrected token needs a restart anyway and this may live as long as the /// client. credentials_rejected: Mutex>, /// Shared across pages and images so a successful request does not reset /// pacing, and exhausting one image's retries does not bypass a cooldown. next_request_at: Mutex>, } impl Client { pub fn new(config: Config) -> Self { let http_client = HttpClient::builder() .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS)) .pool_idle_timeout(Duration::from_secs(POOL_IDLE_TIMEOUT_SECS)) .build() .expect("failed to build HTTP client"); Self { http_client, config, credentials_rejected: Mutex::new(HashSet::new()), next_request_at: Mutex::new(HashMap::new()), } } async fn wait_for_request_slot(&self, backend: &str) { loop { let deadline = { let mut next_requests = self.next_request_at.lock().unwrap(); let now = Instant::now(); let next = next_requests.entry(backend.to_string()).or_insert(now); if *next <= now { *next = now + Duration::from_millis(self.config.registry_request_interval_ms); return; } *next }; sleep_until(deadline).await; // Recheck in case another request extended the cooldown. } } fn defer_requests(&self, backend: &str, delay: Duration) { let deadline = Instant::now() + delay; let mut next_requests = self.next_request_at.lock().unwrap(); let next = next_requests.entry(backend.to_string()).or_insert(deadline); *next = (*next).max(deadline); } fn parse_retry_after(&self, retry_after: &str) -> Option { if let Ok(seconds) = retry_after.parse::() { return Some(Duration::from_secs(seconds)); } if let Ok(date) = DateTime::parse_from_rfc2822(retry_after) { let now = Utc::now(); let wait_time = date.signed_duration_since(now); if wait_time.num_seconds() > 0 { return Some(Duration::from_secs(wait_time.num_seconds() as u64)); } } None } async fn fetch_with_retry( &self, request_fn: F, operation_name: &str, request_url: &str, ) -> Result where F: Fn() -> Fut, Fut: std::future::Future>, { let mut attempt = 0; let mut delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS); let mut rate_limit_delay = Duration::from_secs(INITIAL_RATE_LIMIT_DELAY_SECS); let backend = registry_backend(request_url)?; let operation_name = format!("{operation_name} [{backend}]"); loop { self.wait_for_request_slot(&backend).await; // Send the request AND read the body as one retryable unit: a // connection dropped while streaming the body (`is_body`) is just as // transient as one dropped while sending the request (`is_request`), // and reading it here lets both be retried. let fetched = async { let response = request_fn().await?; let status = response.status(); let headers = response.headers().clone(); let body = response.text().await?; Ok::<_, reqwest::Error>(FetchResult { status, headers, body, }) } .await; let fetched = match fetched { Ok(fetched) => fetched, Err(err) => { attempt += 1; // Transient transport failures (connect timeouts, dropped // keep-alive connections) never reached this retry path // before and aborted the whole update run. Retry them with // the same bounded, exponential backoff used for 429s. if !is_transient_transport_error(&err) || attempt > MAX_RETRY_ATTEMPTS { return Err( anyhow::Error::new(err).context(format!("{operation_name} failed")) ); } warn!( "{} transient transport error ({}), attempt {}/{}, waiting {:?} before retry", operation_name, err, attempt, MAX_RETRY_ATTEMPTS, delay ); sleep(delay).await; delay = delay.saturating_mul(2); continue; } }; if fetched.status == StatusCode::TOO_MANY_REQUESTS { attempt += 1; let wait_duration = if let Some(retry_after) = fetched .headers .get("retry-after") .and_then(|h| h.to_str().ok()) { self.parse_retry_after(retry_after) .unwrap_or(rate_limit_delay) } else { rate_limit_delay }; self.defer_requests(&backend, wait_duration); if attempt > MAX_RETRY_ATTEMPTS { return Err(anyhow!( "{} failed: Rate limited (429) after {} retries; backend cooldown {:?}", operation_name, MAX_RETRY_ATTEMPTS, wait_duration )); } warn!( "{} rate limited (429), retry {}/{}, waiting {:?} before retry", operation_name, attempt, MAX_RETRY_ATTEMPTS, wait_duration ); rate_limit_delay = rate_limit_delay.saturating_mul(2); } else { return Ok(fetched); } } } fn get_registry_config(&self, registry: &str) -> Result<&RegistryConfig> { self.config .registries .get(registry) .ok_or_else(|| anyhow!("Unknown registry: {}", registry)) } fn build_repository_path(&self, image_ref: &ImageRef) -> String { match &image_ref.namespace { Some(namespace) => format!("{}/{}", namespace, image_ref.name), None if image_ref.registry == "docker.io" => { format!("library/{}", image_ref.name) } None => image_ref.name.clone(), } } /// Returns the semver-parseable versions on the page, the raw last tag (the /// `last=` cursor for the next page, regardless of whether it parsed as /// semver), and the raw number of tags on the page. fn parse_v2_response( &self, response_text: &str, ) -> Result<(Vec, Option, usize)> { debug!("Parsing registry v2 response: {}", response_text); let tags_response: serde_json::Value = serde_json::from_str(response_text) .map_err(|e| anyhow!("Failed to parse registry response: {}", e))?; let mut versions = Vec::new(); let mut raw_last_tag = None; let mut raw_count = 0; if let Some(tags) = tags_response.get("tags").and_then(|t| t.as_array()) { for tag in tags { if let Some(tag_str) = tag.as_str() { raw_count += 1; raw_last_tag = Some(tag_str.to_string()); if let Some(version_info) = VersionInfo::from_tag(tag_str) { versions.push(version_info); } } } } Ok((versions, raw_last_tag, raw_count)) } /// Lists every tag of an image via the registry v2 API, including Docker Hub: /// its hub.docker.com JSON API refuses anonymous requests past a 10,000-tag /// offset ("pagination offset too large for anonymous requests"), which large /// repos such as jellyfin/jellyfin (~13.6k tags) exceed. The v2 endpoint /// paginates by cursor instead of offset, so it has no such ceiling, and its /// 1000-tag pages need ~10x fewer requests. pub async fn get_available_versions(&self, image_ref: &ImageRef) -> Result> { self.get_registry_v2_versions(image_ref).await } async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result> { let registry_config = self.get_registry_config(&image_ref.registry)?; let repo_path = self.build_repository_path(image_ref); let mut results = Vec::new(); let mut last_tag: Option = None; let mut next_url: Option = None; let mut bearer_token: Option = None; let mut auth_attempts: u32 = 0; let mut tags_scanned: usize = 0; loop { let url = if let Some(next) = next_url.take() { if next.starts_with("http://") || next.starts_with("https://") { next } else { format!("{}{next}", registry_config.url) } } else { format!( "{}/v2/{repo_path}/tags/list?n={OCI_PAGE_SIZE}{}", registry_config.url, if let Some(ref last) = last_tag { format!("&last={last}") } else { String::new() } ) }; debug!("Registry API URL: {}", url); let url_clone = url.clone(); let bearer_token_clone = bearer_token.clone(); let fetched = self .fetch_with_retry( || async { let mut request_builder = self.http_client.get(&url_clone); if let Some(token) = &bearer_token_clone { request_builder = request_builder.bearer_auth(token); } request_builder.send().await }, &format!("Registry v2 tag listing for {image_ref}"), &url, ) .await?; let (new_tags, raw_last_tag, raw_count, link_next) = if fetched.status == reqwest::StatusCode::UNAUTHORIZED { if auth_attempts >= MAX_AUTH_ATTEMPTS { return Err(anyhow!( "Authentication failed after {} attempts for registry {}", auth_attempts, image_ref.registry )); } let Some(auth_header) = fetched.headers.get("www-authenticate") else { return Err(anyhow!( "Unauthorized request but no WWW-Authenticate header found" )); }; let auth_str = auth_header .to_str() .map_err(|e| anyhow!("Invalid WWW-Authenticate header encoding: {}", e))?; // Answer the challenge even with no token configured: public // repositories on Docker Hub and ghcr.io hand out a pull-scoped // token to unauthenticated callers, and the tag listing is // inaccessible without one. bearer_token = Some( self.fetch_registry_v2_token(auth_str, &image_ref.registry, registry_config) .await?, ); auth_attempts += 1; continue; } else if fetched.status.is_success() { let link_next = fetched .headers .get("link") .and_then(|h| h.to_str().ok()) .and_then(|h| self.parse_link_header(h)); // A page came back, so the current token works: give the next // 401 a fresh re-auth budget. Tokens expire (Docker Hub's last // 5 minutes) and a long scan can outlive one, which must not // exhaust the attempt cap and abandon the listing half-read. auth_attempts = 0; let (tags, raw_last_tag, raw_count) = self.parse_v2_response(&fetched.body)?; (tags, raw_last_tag, raw_count, link_next) } else { return Err(anyhow!( "Registry request failed with status {}: {}", fetched.status, fetched.body )); }; tags_scanned += raw_count; results.extend(new_tags); if tags_scanned >= MAX_TAGS_SCANNED { warn!( "Reached the {}-tag scan limit for {} before exhausting the registry; \ the newest version may have been missed", MAX_TAGS_SCANNED, image_ref ); break; } // Advance via the registry's Link header when present, else the raw // last-tag cursor. Stop on an empty page, a Link header that points // back at the current page, or a cursor that did not move. if let Some(next) = link_next { let resolved = if next.starts_with("http://") || next.starts_with("https://") { next } else { format!("{}{next}", registry_config.url) }; if resolved == url { break; } next_url = Some(resolved); } else if let Some(last) = raw_last_tag { if raw_count == 0 || last_tag.as_ref() == Some(&last) { break; } last_tag = Some(last); } else { break; } } Ok(results) } /// Exchanges a `WWW-Authenticate` challenge for a bearer token, using the /// registry's configured credentials when it has any. /// /// A rejected credential falls back to an anonymous token rather than failing /// the image: public repositories hand out pull-scoped tokens to anyone, which /// is all a tag listing needs. Docker Hub is why this matters — it validates /// the basic-auth username, so a `dckr_pat_…` configured without a matching /// `username` is refused outright, and without the fallback every Docker Hub /// image in the run would fail. async fn fetch_registry_v2_token( &self, auth_str: &str, registry: &str, registry_config: &RegistryConfig, ) -> Result { let realm = extract_auth_param(auth_str, "realm")?; let service = extract_auth_param(auth_str, "service")?; let scope = extract_auth_param(auth_str, "scope")?; let auth_url = format!( "{realm}?service={}&scope={}", urlencoding::encode(&service), urlencoding::encode(&scope), ); let credentials = registry_config .auth_token .as_ref() .filter(|_| !self.has_rejected_credentials(registry)) .map(|token| { ( registry_config .username .as_deref() .unwrap_or(DEFAULT_AUTH_USERNAME) .to_string(), token.clone(), ) }); if credentials.is_none() { return self.request_registry_v2_token(&auth_url, None).await; } match self.request_registry_v2_token(&auth_url, credentials).await { Ok(token) => Ok(token), Err(e) => { warn!( "Credentialed auth for {} failed ({:#}); using anonymous tokens for it \ from now on. Private repositories there will be unreadable until the \ registry's `auth_token` (and, for Docker Hub, `username`) is corrected", registry, e ); self.credentials_rejected .lock() .expect("credentials_rejected mutex poisoned") .insert(registry.to_string()); self.request_registry_v2_token(&auth_url, None).await } } } fn has_rejected_credentials(&self, registry: &str) -> bool { self.credentials_rejected .lock() .expect("credentials_rejected mutex poisoned") .contains(registry) } /// Requests a bearer token from a token endpoint, anonymously when /// `credentials` is `None`. async fn request_registry_v2_token( &self, auth_url: &str, credentials: Option<(String, String)>, ) -> Result { debug!( "Getting {} registry token from: {}", if credentials.is_some() { "authenticated" } else { "anonymous" }, auth_url ); let token_response = self .fetch_with_retry( || async { let mut request_builder = self.http_client.get(auth_url); if let Some((username, token)) = &credentials { request_builder = request_builder.basic_auth(username, Some(token)); } request_builder.send().await }, "Registry auth token request", auth_url, ) .await?; if !token_response.status.is_success() { return Err(anyhow!( "Failed to get token: {} - {}", token_response.status, token_response.body )); } let token_json: serde_json::Value = serde_json::from_str(&token_response.body) .map_err(|e| anyhow!("Failed to parse auth token response: {}", e))?; if let Some(registry_token) = token_json.get("token").and_then(|t| t.as_str()) { return Ok(registry_token.to_string()); } Err(anyhow!("Auth response missing 'token' field")) } fn parse_link_header(&self, link_header: &str) -> Option { for link in link_header.split(',') { let parts: Vec<&str> = link.trim().split(';').collect(); if parts.len() >= 2 { let url = parts[0].trim(); if url.starts_with('<') && url.ends_with('>') { let url = &url[1..url.len() - 1]; for param in &parts[1..] { let param = param.trim(); if param == "rel=\"next\"" || param == "rel=next" { return Some(url.to_string()); } } } } } None } } /// LinuxServer's gateway and GHCR serve the same image backend. Keep their /// request budget together even when compose files use both image prefixes. fn registry_backend(request_url: &str) -> Result { let url = reqwest::Url::parse(request_url)?; let host = url .host_str() .ok_or_else(|| anyhow!("Registry URL has no host"))?; let host = if host == "lscr.io" { "ghcr.io" } else { host }; Ok(match url.port() { Some(port) => format!("{host}:{port}"), None => host.to_string(), }) } static AUTH_PARAM_REGEX: LazyLock = LazyLock::new(|| regex::Regex::new(r#"(\w+)="([^"]+)""#).unwrap()); /// Returns true for transient transport-level failures worth retrying: /// connection-establishment failures (`tcp connect error: deadline has elapsed`), /// read/overall timeouts, request-send failures such as hyper's `IncompleteMessage` /// (`connection closed before message completed`), and body-read failures from a /// connection dropped mid-stream. Because `fetch_with_retry` reads the body inside /// the retry scope, `is_body` is meaningful here. Non-transient kinds (`is_decode`, /// redirect, builder, and HTTP status errors, which arrive as a successful /// `Response` rather than an `Err`) are not retried. fn is_transient_transport_error(err: &reqwest::Error) -> bool { err.is_connect() || err.is_timeout() || err.is_request() || err.is_body() } fn extract_auth_param(auth_str: &str, param: &str) -> Result { for caps in AUTH_PARAM_REGEX.captures_iter(auth_str) { if caps.get(1).map(|m| m.as_str()) == Some(param) { return Ok(caps[2].to_string()); } } Err(anyhow!( "Missing '{}' in auth challenge: {}", param, auth_str )) } #[cfg(test)] mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; fn response(status: &str, headers: &str, body: &str) -> String { format!( "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len() ) } async fn mock_registry( responses: Vec, ) -> (String, tokio::task::JoinHandle>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let server = tokio::spawn(async move { let mut requests = Vec::new(); for response in responses { let (mut stream, _) = listener.accept().await.unwrap(); let mut request = Vec::new(); let mut buf = [0; 1024]; while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { let len = stream.read(&mut buf).await.unwrap(); assert_ne!(len, 0, "connection closed before request headers"); request.extend_from_slice(&buf[..len]); } requests.push(String::from_utf8(request).unwrap()); stream.write_all(response.as_bytes()).await.unwrap(); } requests }); (url, server) } fn client_for_mock(url: &str, interval_ms: u64) -> Client { let mut config = Config { registry_request_interval_ms: interval_ms, ..Config::default() }; config.registries.insert( "test.registry".to_string(), RegistryConfig { url: url.to_string(), auth_token: None, username: None, }, ); Client::new(config) } #[tokio::test(start_paused = true)] async fn test_backend_pacing_shares_lscr_budget_and_isolates_other_registries() { let client = Client::new(Config::default()); let lscr = registry_backend("https://lscr.io/v2/linuxserver/prowlarr/tags/list").unwrap(); let ghcr = registry_backend("https://ghcr.io/token?scope=repository:x:pull").unwrap(); let docker = registry_backend("https://registry-1.docker.io/v2/library/nginx/tags/list").unwrap(); let start = Instant::now(); client.wait_for_request_slot(&lscr).await; client.wait_for_request_slot(&docker).await; assert_eq!(start.elapsed(), Duration::ZERO); client.wait_for_request_slot(&ghcr).await; assert_eq!(start.elapsed(), Duration::from_secs(1)); client.wait_for_request_slot(&lscr).await; assert_eq!(start.elapsed(), Duration::from_secs(2)); } #[tokio::test(start_paused = true)] async fn test_cooldown_survives_disabled_pacing_and_cannot_be_shortened() { let client = client_for_mock("http://localhost", 0); let start = Instant::now(); client.defer_requests("ghcr.io", Duration::from_secs(40)); client.defer_requests("ghcr.io", Duration::from_secs(5)); client.wait_for_request_slot("docker.io").await; assert_eq!(start.elapsed(), Duration::ZERO); client.wait_for_request_slot("ghcr.io").await; assert_eq!(start.elapsed(), Duration::from_secs(40)); client.wait_for_request_slot("ghcr.io").await; assert_eq!(start.elapsed(), Duration::from_secs(40)); } #[tokio::test] async fn test_pacing_covers_authentication_and_tag_pages() { let (url, server) = mock_registry(vec![ response("200 OK", "", r#"{"token":"test-token"}"#), response( "200 OK", "Link: ; rel=\"next\"\r\n", r#"{"tags":["1.0.0"]}"#, ), response("200 OK", "", r#"{"tags":["1.0.1"]}"#), response("200 OK", "", r#"{"tags":[]}"#), ]) .await; let client = client_for_mock(&url, 100); let start = Instant::now(); client .request_registry_v2_token(&format!("{url}/token"), None) .await .unwrap(); let image = ImageRef::parse("test.registry/org/app:1.0.0").unwrap(); let versions = client.get_available_versions(&image).await.unwrap(); let tags: Vec<_> = versions.iter().map(|v| v.original.as_str()).collect(); assert_eq!(tags, vec!["1.0.0", "1.0.1"]); assert!(start.elapsed() >= Duration::from_millis(300)); let requests = server.await.unwrap(); assert_eq!(requests.len(), 4); assert!(requests[0].starts_with("GET /token ")); assert!(requests[2].contains("last=1.0.0")); } #[tokio::test] async fn test_exhausted_retries_preserve_cooldown_for_next_image() { let mut responses = vec![ response("429 Too Many Requests", "Retry-After: 0\r\n", "{}"); MAX_RETRY_ATTEMPTS as usize ]; responses.push(response( "429 Too Many Requests", "Retry-After: 1\r\n", "{}", )); responses.push(response("200 OK", "", r#"{"tags":[]}"#)); let (url, server) = mock_registry(responses).await; let client = client_for_mock(&url, 0); let image = ImageRef::parse("test.registry/org/app:1.0.0").unwrap(); let error = client.get_available_versions(&image).await.unwrap_err(); let error = error.to_string(); assert!(error.contains("test.registry/org/app:1.0.0"), "{error}"); assert!(error.contains("after 5 retries"), "{error}"); assert!(error.contains("backend cooldown 1s"), "{error}"); let next_image = ImageRef::parse("test.registry/org/other:2.0.0").unwrap(); let start = Instant::now(); client.get_available_versions(&next_image).await.unwrap(); assert!(start.elapsed() >= Duration::from_millis(950)); assert_eq!(server.await.unwrap().len(), 7); } #[tokio::test] async fn test_rate_limit_without_retry_after_waits_before_retrying() { let (url, server) = mock_registry(vec![ response("429 Too Many Requests", "", "{}"), response("200 OK", "", r#"{"tags":[]}"#), ]) .await; let client = client_for_mock(&url, 0); let image = ImageRef::parse("test.registry/org/app:1.0.0").unwrap(); let start = Instant::now(); client.get_available_versions(&image).await.unwrap(); assert!(start.elapsed() >= Duration::from_secs(INITIAL_RATE_LIMIT_DELAY_SECS)); assert_eq!(server.await.unwrap().len(), 2); } #[test] fn test_retry_after_accepts_seconds_and_http_dates() { let client = Client::new(Config::default()); assert_eq!( client.parse_retry_after("45"), Some(Duration::from_secs(45)) ); assert_eq!(client.parse_retry_after("0"), Some(Duration::ZERO)); assert_eq!(client.parse_retry_after("invalid"), None); let date = (Utc::now() + chrono::Duration::seconds(60)).to_rfc2822(); let delay = client.parse_retry_after(&date).unwrap(); assert!(delay >= Duration::from_secs(58) && delay <= Duration::from_secs(60)); } #[test] fn test_image_ref_parsing() { let test_cases = vec![ ("nginx:1.21.0", "docker.io", None, "nginx", "1.21.0"), ( "ubuntu/nginx:1.21.0", "docker.io", Some("ubuntu"), "nginx", "1.21.0", ), ( "ghcr.io/user/app:v1.0.0", "ghcr.io", Some("user"), "app", "v1.0.0", ), ( "localhost:5000/myapp:latest", "localhost:5000", None, "myapp", "latest", ), ( "ghcr.io/schmelczer/fizika/fizika-admin:latest", "ghcr.io", Some("schmelczer/fizika"), "fizika-admin", "latest", ), ( "registry.example.com/org/team/project/image:v2.0.0", "registry.example.com", Some("org/team/project"), "image", "v2.0.0", ), ]; for (input, expected_registry, expected_namespace, expected_name, expected_tag) in test_cases { let image_ref = ImageRef::parse(input).unwrap(); assert_eq!( image_ref.registry, expected_registry, "Registry mismatch for {}", input ); assert_eq!( image_ref.namespace, expected_namespace.map(String::from), "Namespace mismatch for {}", input ); assert_eq!(image_ref.name, expected_name, "Name mismatch for {}", input); assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for {}", input); } } #[test] fn test_repository_path_building() { let config = Config::default(); let client = Client::new(config); // Official Docker Hub image (no namespace) let image_ref = ImageRef { registry: "docker.io".to_string(), namespace: None, name: "nginx".to_string(), tag: "latest".to_string(), }; assert_eq!(client.build_repository_path(&image_ref), "library/nginx"); // Docker Hub with namespace let image_ref = ImageRef { registry: "docker.io".to_string(), namespace: Some("bitnami".to_string()), name: "nginx".to_string(), tag: "latest".to_string(), }; assert_eq!(client.build_repository_path(&image_ref), "bitnami/nginx"); // Custom registry let image_ref = ImageRef { registry: "ghcr.io".to_string(), namespace: Some("user".to_string()), name: "app".to_string(), tag: "v1.0.0".to_string(), }; assert_eq!(client.build_repository_path(&image_ref), "user/app"); } #[tokio::test] async fn test_connect_failure_is_transient() { // 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed non-routable, so the // connect attempt fails (timeout or unreachable). Either way it must be // classified as a transient transport error so it gets retried. let client = HttpClient::builder() .connect_timeout(Duration::from_millis(50)) .timeout(Duration::from_millis(200)) .build() .unwrap(); let err = client .get("http://192.0.2.1:9/") .send() .await .expect_err("connecting to a non-routable address must fail"); assert!( is_transient_transport_error(&err), "connect failure should be retryable, got: {err:?}" ); } #[test] fn test_parse_v2_response_reports_raw_cursor_and_count() { let client = Client::new(Config::default()); // Mixed semver and non-semver tags. The raw cursor must be the literal // last tag (even though it is not semver) so pagination advances past it, // and the raw count must include every tag for the scan budget. let body = r#"{"name":"linuxserver/radarr","tags":["6.1.0","6.1.1","latest","amd64-nightly"]}"#; let (versions, raw_last_tag, raw_count) = client.parse_v2_response(body).unwrap(); assert_eq!(raw_count, 4); assert_eq!(raw_last_tag.as_deref(), Some("amd64-nightly")); let parsed: Vec<_> = versions.iter().map(|v| v.original.as_str()).collect(); assert_eq!(parsed, vec!["6.1.0", "6.1.1"]); } #[test] fn test_parse_v2_response_handles_empty_and_null_tags() { let client = Client::new(Config::default()); for body in [r#"{"name":"x","tags":[]}"#, r#"{"name":"x","tags":null}"#] { let (versions, raw_last_tag, raw_count) = client.parse_v2_response(body).unwrap(); assert!(versions.is_empty()); assert_eq!(raw_last_tag, None); assert_eq!(raw_count, 0, "body {body} should yield zero raw tags"); } } #[test] fn test_parse_link_header() { let config = Config::default(); let client = Client::new(config); // Test standard Link header with next rel let link_header = r#"; rel="next", ; rel="last""#; let next_url = client.parse_link_header(link_header); assert_eq!( next_url, Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string()) ); // Test Link header without quotes around rel value let link_header = r#"; rel=next"#; let next_url = client.parse_link_header(link_header); assert_eq!( next_url, Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string()) ); // Test Link header with no next relation let link_header = r#"; rel="prev", ; rel="last""#; let next_url = client.parse_link_header(link_header); assert_eq!(next_url, None); // Test empty Link header let next_url = client.parse_link_header(""); assert_eq!(next_url, None); // Test malformed Link header let link_header = "not-a-valid-link-header"; let next_url = client.parse_link_header(link_header); assert_eq!(next_url, None); } }