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:
@ -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<dyn CommandRunner>,
|
||||
}
|
||||
@ -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<String> = 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<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]
|
||||
async fn authenticates_and_follows_pagination() {
|
||||
let runner = Arc::new(Fake::default());
|
||||
|
||||
Reference in New Issue
Block a user