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 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:21:14 +02:00
parent b984cc8c9f
commit 214cd1506b

View File

@ -10,6 +10,11 @@ use domain::DomainError;
use crate::host::CommandRunner; 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 { pub struct CurlImageRegistry {
runner: Arc<dyn CommandRunner>, runner: Arc<dyn CommandRunner>,
} }
@ -124,11 +129,14 @@ impl ImageRegistry for CurlImageRegistry {
let parsed = ImageRef::parse(image) let parsed = ImageRef::parse(image)
.ok_or_else(|| DomainError::Validation(format!("cannot parse image '{image}'")))?; .ok_or_else(|| DomainError::Validation(format!("cannot parse image '{image}'")))?;
let host = registry_host(&parsed.registry).to_string(); 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<String> = None; let mut token: Option<String> = None;
let mut tags = Vec::new(); 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?; let (mut status, mut headers, mut body) = self.get(&url, token.as_deref()).await?;
if status == 401 { if status == 401 {
let challenge = parse_auth_challenge(&headers).ok_or_else(|| { 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<usize>,
}
#[async_trait]
impl CommandRunner for Endless {
async fn run(&self, _p: &str, args: &[&str]) -> Result<crate::host::Output, DomainError> {
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: </v2/x/tags/list?last={tag}>; 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<crate::host::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<crate::host::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None)
}
async fn run_streaming(
&self,
_: &str,
_: &[&str],
_: &dyn LineSink,
) -> Result<bool, DomainError> {
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] #[tokio::test]
async fn authenticates_and_follows_pagination() { async fn authenticates_and_follows_pagination() {
let runner = Arc::new(Fake::default()); let runner = Arc::new(Fake::default());