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;
|
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());
|
||||||
|
|||||||
Reference in New Issue
Block a user