Fix Docker Hub authentication and reject build-id tags as candidates

Docker Hub answers with both token and access_token, which made the
serde alias fail as a duplicate field, so every Hub image reported that
no token was returned. Candidates now also have to match the shape of
the running tag; cert-manager v1.14.5 was otherwise offered an upgrade
to the build id 608111629.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:24:50 +02:00
parent 214cd1506b
commit b161bf9cbd
2 changed files with 59 additions and 3 deletions

View File

@ -89,18 +89,24 @@ fn is_prerelease(suffix: &str) -> bool {
.any(|m| s.starts_with(m) || s.contains(&format!("-{m}"))) .any(|m| s.starts_with(m) || s.contains(&format!("-{m}")))
} }
/// Tags from the registry that are newer than `current`, oldest first. Only tags with the /// Tags from the registry that are newer than `current`, oldest first.
/// same suffix (`-rootless`, `-debian-12` …) are considered, so the variant stays the same. ///
/// A candidate has to be shaped like the running tag: the same variant suffix
/// (`-rootless`, `-debian-12` …), the same `v` prefix and the same number of version
/// parts. That keeps build ids and dates (`608111629`, `20260417`) out of the suggestion.
pub fn newer_tags(current: &str, available: &[String]) -> Vec<String> { pub fn newer_tags(current: &str, available: &[String]) -> Vec<String> {
let Some((now, suffix)) = version_parts(current) else { let Some((now, suffix)) = version_parts(current) else {
return Vec::new(); return Vec::new();
}; };
let prefixed = current.starts_with('v');
let mut newer: Vec<(Vec<u64>, String)> = available let mut newer: Vec<(Vec<u64>, String)> = available
.iter() .iter()
.filter_map(|t| { .filter_map(|t| {
let (v, s) = version_parts(t)?; let (v, s) = version_parts(t)?;
(s == suffix (s == suffix
&& !is_prerelease(&s) && !is_prerelease(&s)
&& t.starts_with('v') == prefixed
&& v.len() == now.len()
&& cmp_version(&v, &now) == std::cmp::Ordering::Greater) && cmp_version(&v, &now) == std::cmp::Ordering::Greater)
.then_some((v, t.clone())) .then_some((v, t.clone()))
}) })
@ -218,6 +224,31 @@ mod tests {
assert_eq!(newest_tag("latest", &tags), None, "no version to compare"); assert_eq!(newest_tag("latest", &tags), None, "no version to compare");
} }
#[test]
fn ignores_tags_that_are_not_shaped_like_the_running_one() {
// build ids and dates are numerically larger but are not a newer release
let tags: Vec<String> = [
"v1.14.5",
"608111629",
"20260417",
"v1.15.0",
"v1.14.6",
"1.16.0",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(newer_tags("v1.14.5", &tags), vec!["v1.14.6", "v1.15.0"]);
assert_eq!(newest_tag("v1.14.5", &tags).as_deref(), Some("v1.15.0"));
// the number of version parts has to match, so 1.11 does not jump to 1.11.0.1
let tags: Vec<String> = ["1.11", "1.12", "1.12.0", "1.12.0.1"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(newer_tags("1.11", &tags), vec!["1.12"]);
}
#[test] #[test]
fn compares_versions_by_number_not_by_text() { fn compares_versions_by_number_not_by_text() {
let tags: Vec<String> = ["1.9.0", "1.10.0", "1.10", "2.0.0"] let tags: Vec<String> = ["1.9.0", "1.10.0", "1.10", "2.0.0"]
@ -225,7 +256,11 @@ mod tests {
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect(); .collect();
assert_eq!(newest_tag("1.9.0", &tags).as_deref(), Some("2.0.0")); assert_eq!(newest_tag("1.9.0", &tags).as_deref(), Some("2.0.0"));
assert_eq!(newer_tags("1.9.0", &tags), vec!["1.10", "1.10.0", "2.0.0"]); assert_eq!(
newer_tags("1.9.0", &tags),
vec!["1.10.0", "2.0.0"],
"1.10 has fewer parts"
);
assert_eq!( assert_eq!(
newest_tag("v0.6.3", &["v0.7.0".to_string(), "v0.6.4".to_string()]).as_deref(), newest_tag("v0.6.3", &["v0.7.0".to_string(), "v0.6.4".to_string()]).as_deref(),
Some("v0.7.0") Some("v0.7.0")

View File

@ -56,6 +56,15 @@ pub fn parse_auth_challenge(headers: &str) -> Option<String> {
Some(url.trim_end_matches(['&', '?']).to_string()) Some(url.trim_end_matches(['&', '?']).to_string())
} }
/// Registries answer with `token`, `access_token`, or both (Docker Hub sends both).
pub fn parse_token(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
["token", "access_token"]
.iter()
.find_map(|k| value.get(k).and_then(|v| v.as_str()))
.map(|t| t.to_string())
}
/// `{"tags": ["1.0", "1.1"]}`; a missing or null list means no tags. /// `{"tags": ["1.0", "1.1"]}`; a missing or null list means no tags.
pub fn parse_tags(body: &str) -> Result<Vec<String>, DomainError> { pub fn parse_tags(body: &str) -> Result<Vec<String>, DomainError> {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@ -217,6 +226,18 @@ mod tests {
assert_eq!(parse_next_link("HTTP/1.1 200 OK\r\n"), None); assert_eq!(parse_next_link("HTTP/1.1 200 OK\r\n"), None);
} }
#[test]
fn reads_the_token_whichever_field_carries_it() {
// Docker Hub sends both fields, which must not be treated as a duplicate
assert_eq!(
parse_token(r#"{"token":"a","access_token":"a","expires_in":300}"#).as_deref(),
Some("a")
);
assert_eq!(parse_token(r#"{"access_token":"b"}"#).as_deref(), Some("b"));
assert_eq!(parse_token(r#"{"errors":[]}"#), None);
assert_eq!(parse_token("not json"), None);
}
#[test] #[test]
fn reads_the_tag_list() { fn reads_the_tag_list() {
assert_eq!( assert_eq!(