From 214cd1506b7946d27ae9ad93a952b5743923e373 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 3 Sep 2026 20:21:14 +0200 Subject: [PATCH] Page through large registry tag lists Gitea publishes thousands of tags; the client stopped after ten pages of 200 and never saw a version newer than the running one. Co-Authored-By: Claude Opus 5 --- backend/crates/infrastructure/src/registry.rs | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/backend/crates/infrastructure/src/registry.rs b/backend/crates/infrastructure/src/registry.rs index ccbc049..3eaf832 100644 --- a/backend/crates/infrastructure/src/registry.rs +++ b/backend/crates/infrastructure/src/registry.rs @@ -10,6 +10,11 @@ use domain::DomainError; use crate::host::CommandRunner; +/// Tags requested per page; registries may return fewer. +const PAGE_SIZE: usize = 1000; +/// Upper bound on pages, so a registry that keeps offering a next page cannot hang a scan. +pub const MAX_PAGES: usize = 1000; + pub struct CurlImageRegistry { runner: Arc, } @@ -124,11 +129,14 @@ impl ImageRegistry for CurlImageRegistry { let parsed = ImageRef::parse(image) .ok_or_else(|| DomainError::Validation(format!("cannot parse image '{image}'")))?; let host = registry_host(&parsed.registry).to_string(); - let mut url = format!("https://{host}/v2/{}/tags/list?n=200", parsed.repository); + let mut url = format!( + "https://{host}/v2/{}/tags/list?n={PAGE_SIZE}", + parsed.repository + ); let mut token: Option = None; let mut tags = Vec::new(); - for _ in 0..10 { + for _ in 0..MAX_PAGES { let (mut status, mut headers, mut body) = self.get(&url, token.as_deref()).await?; if status == 401 { let challenge = parse_auth_challenge(&headers).ok_or_else(|| { @@ -278,6 +286,76 @@ mod tests { } } + /// A registry that always offers another page, to prove the client terminates. + #[derive(Default)] + struct Endless { + pages: Mutex, + } + + #[async_trait] + impl CommandRunner for Endless { + async fn run(&self, _p: &str, args: &[&str]) -> Result { + let mut n = self.pages.lock().unwrap(); + *n += 1; + let tag = format!("1.0.{n}"); + Ok(crate::host::Output { + stdout: format!( + "HTTP/1.1 200 OK\r\nLink: ; rel=\"next\"\r\n\r\n{{\"tags\":[\"{tag}\"]}}" + ), + success: true, + ..Default::default() + }) + .map(|o| { + let _ = args; + o + }) + } + async fn run_env( + &self, + p: &str, + a: &[&str], + _: &[(&str, &str)], + ) -> Result { + self.run(p, a).await + } + async fn run_to_file( + &self, + _: &str, + _: &[&str], + _: &std::path::Path, + ) -> Result { + unreachable!() + } + async fn read_file(&self, _: &str) -> Result, DomainError> { + Ok(None) + } + async fn run_streaming( + &self, + _: &str, + _: &[&str], + _: &dyn LineSink, + ) -> Result { + Ok(true) + } + } + + #[tokio::test] + async fn asks_for_large_pages_and_stops_at_the_page_limit() { + let runner = Arc::new(Endless::default()); + let tags = CurlImageRegistry::new(runner.clone()) + .tags("example.com/app:1.0.0") + .await + .unwrap(); + // repositories with thousands of tags (gitea has ~4000) must be paged through + assert!(tags.len() >= 1000, "got {} tags", tags.len()); + assert_eq!( + tags.len(), + *runner.pages.lock().unwrap(), + "one tag per page" + ); + assert!(tags.len() <= MAX_PAGES, "stops instead of paging forever"); + } + #[tokio::test] async fn authenticates_and_follows_pagination() { let runner = Arc::new(Fake::default());