use tracing::warn;
use crate::docker::check::DockerCheck;
use crate::registry::{ImageRef, Registry, RegistryError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Fetched {
local: Option<String>,
latest: String,
},
Pinned,
AuthRequired,
CredentialsRejected,
NetworkUnavailable,
Error(String),
}
pub async fn probe_image(
docker: &impl DockerCheck,
registry: &impl Registry,
image: &str,
) -> ProbeOutcome {
if is_pinned(image) {
return ProbeOutcome::Pinned;
}
let local = match docker.inspect_image_repo_digests(image).await {
Ok(digests) => manifest_digest_for(image, &digests),
Err(e) => {
warn!(image = %image, error = %e, "image inspect failed; current digest will be unknown");
None
}
};
let image_ref = ImageRef::parse(image);
match registry.fetch_digest(&image_ref).await {
Ok(d) => ProbeOutcome::Fetched { local, latest: d.0 },
Err(RegistryError::NetworkUnavailable(reason)) => {
warn!(repo = %image_ref.repository, %reason, "network unavailable");
ProbeOutcome::NetworkUnavailable
}
Err(RegistryError::Auth(reason)) => {
warn!(repo = %image_ref.repository, %reason, "registry requires credentials");
ProbeOutcome::AuthRequired
}
Err(RegistryError::CredentialsRejected(host)) => {
warn!(repo = %image_ref.repository, %host, "configured credentials rejected; anonymous access also denied");
ProbeOutcome::CredentialsRejected
}
Err(e) => {
warn!(repo = %image_ref.repository, error = %e, "digest fetch failed");
ProbeOutcome::Error(e.to_string())
}
}
}
pub(crate) fn is_pinned(image: &str) -> bool {
image.starts_with("sha256:") || image.contains('@')
}
pub(crate) fn pinned_digest(image: &str) -> Option<&str> {
if let Some((_, digest)) = image.split_once('@') {
Some(digest)
} else if image.starts_with("sha256:") {
Some(image)
} else {
None
}
}
pub(crate) fn manifest_digest_for(image: &str, repo_digests: &[String]) -> Option<String> {
let want_repo = strip_tag(image.split('@').next()?);
repo_digests.iter().find_map(|rd| {
let (repo, digest) = rd.split_once('@')?;
(repo == want_repo).then(|| digest.to_owned())
})
}
pub(crate) fn strip_tag(image_no_digest: &str) -> &str {
match (image_no_digest.rfind(':'), image_no_digest.rfind('/')) {
(Some(colon), Some(slash)) if colon > slash => &image_no_digest[..colon],
(Some(colon), None) => &image_no_digest[..colon],
_ => image_no_digest,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docker::DockerError;
use crate::registry::Digest;
use async_trait::async_trait;
use bollard::models::ContainerSummary;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
const DIG_A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const DIG_B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
#[test]
fn extracts_manifest_digest_when_repo_matches() {
let image = "nginx:alpine";
let repo_digests = [
"nginx@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_owned(),
];
assert_eq!(
manifest_digest_for(image, &repo_digests).as_deref(),
Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
);
}
#[test]
fn extracts_manifest_digest_for_namespaced_repo() {
let image = "ghcr.io/owner/repo:v1";
let repo_digests = [
"other/thing@sha256:1111111111111111111111111111111111111111111111111111111111111111"
.to_owned(),
"ghcr.io/owner/repo@sha256:2222222222222222222222222222222222222222222222222222222222222222"
.to_owned(),
];
assert_eq!(
manifest_digest_for(image, &repo_digests).as_deref(),
Some("sha256:2222222222222222222222222222222222222222222222222222222222222222")
);
}
#[test]
fn returns_none_when_no_repo_digest_matches() {
let image = "nginx:alpine";
let repo_digests = ["redis@sha256:dead".to_owned()];
assert_eq!(manifest_digest_for(image, &repo_digests), None);
}
#[test]
fn returns_none_for_empty_repo_digests() {
assert_eq!(manifest_digest_for("nginx:alpine", &[]), None);
}
#[test]
fn handles_host_port_in_registry_reference() {
let image = "localhost:5000/repo:v1";
let repo_digests = [
"localhost:5000/repo@sha256:3333333333333333333333333333333333333333333333333333333333333333"
.to_owned(),
];
assert_eq!(
manifest_digest_for(image, &repo_digests).as_deref(),
Some("sha256:3333333333333333333333333333333333333333333333333333333333333333")
);
}
#[test]
fn handles_host_port_with_no_tag() {
let image = "localhost:5000/repo";
let repo_digests = [
"localhost:5000/repo@sha256:4444444444444444444444444444444444444444444444444444444444444444"
.to_owned(),
];
assert_eq!(
manifest_digest_for(image, &repo_digests).as_deref(),
Some("sha256:4444444444444444444444444444444444444444444444444444444444444444")
);
}
#[test]
fn handles_image_already_pinned_to_digest() {
let image = "nginx@sha256:beef";
let repo_digests = [
"nginx@sha256:beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef"
.to_owned(),
];
assert_eq!(
manifest_digest_for(image, &repo_digests).as_deref(),
Some("sha256:beefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef")
);
}
#[test]
fn detects_pinned_references() {
assert!(is_pinned("sha256:abcabc"));
assert!(is_pinned("alpine@sha256:abcabc"));
assert!(!is_pinned("alpine:3.19"));
assert!(!is_pinned("ghcr.io/owner/repo:v1"));
}
#[test]
fn pinned_digest_extracts_the_sha() {
assert_eq!(pinned_digest("sha256:abc"), Some("sha256:abc"));
assert_eq!(pinned_digest("alpine@sha256:abc"), Some("sha256:abc"));
assert_eq!(pinned_digest("alpine:3.19"), None);
}
struct FakeDocker {
repo_digests: HashMap<String, Vec<String>>,
inspect_calls: AtomicUsize,
}
impl FakeDocker {
fn new(repo_digests: &[(&str, &str)]) -> Self {
Self {
repo_digests: repo_digests
.iter()
.map(|(img, rd)| ((*img).to_owned(), vec![(*rd).to_owned()]))
.collect(),
inspect_calls: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl DockerCheck for FakeDocker {
async fn list_running(&self) -> Result<Vec<ContainerSummary>, DockerError> {
Ok(vec![])
}
async fn inspect_image_repo_digests(
&self,
image: &str,
) -> Result<Vec<String>, DockerError> {
self.inspect_calls.fetch_add(1, Ordering::SeqCst);
Ok(self.repo_digests.get(image).cloned().unwrap_or_default())
}
}
enum FakeResult {
Digest(String),
AuthRequired,
CredentialsRejected,
}
struct FakeRegistry {
result: FakeResult,
calls: AtomicUsize,
}
impl FakeRegistry {
fn new(digest: &str) -> Self {
Self::with(FakeResult::Digest(digest.to_owned()))
}
fn auth_required() -> Self {
Self::with(FakeResult::AuthRequired)
}
fn credentials_rejected() -> Self {
Self::with(FakeResult::CredentialsRejected)
}
fn with(result: FakeResult) -> Self {
Self {
result,
calls: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl Registry for FakeRegistry {
async fn fetch_digest(&self, _image: &ImageRef) -> Result<Digest, RegistryError> {
self.calls.fetch_add(1, Ordering::SeqCst);
match &self.result {
FakeResult::Digest(d) => Ok(Digest(d.clone())),
FakeResult::AuthRequired => {
Err(RegistryError::Auth("no credentials for registry".into()))
}
FakeResult::CredentialsRejected => {
Err(RegistryError::CredentialsRejected("docker.io".into()))
}
}
}
}
#[tokio::test]
async fn equal_digests_report_no_update() {
let docker = FakeDocker::new(&[("alpine:3.19", &format!("alpine@{DIG_A}"))]);
let registry = FakeRegistry::new(DIG_A);
let outcome = probe_image(&docker, ®istry, "alpine:3.19").await;
assert_eq!(
outcome,
ProbeOutcome::Fetched {
local: Some(DIG_A.to_owned()),
latest: DIG_A.to_owned(),
}
);
}
#[tokio::test]
async fn differing_digests_report_an_update() {
let docker = FakeDocker::new(&[("alpine:3.19", &format!("alpine@{DIG_A}"))]);
let registry = FakeRegistry::new(DIG_B);
let outcome = probe_image(&docker, ®istry, "alpine:3.19").await;
assert_eq!(
outcome,
ProbeOutcome::Fetched {
local: Some(DIG_A.to_owned()),
latest: DIG_B.to_owned(),
}
);
}
#[tokio::test]
async fn non_hub_image_is_fetched_via_the_registry() {
let docker = FakeDocker::new(&[]);
let registry = FakeRegistry::new(DIG_A);
let outcome = probe_image(&docker, ®istry, "ghcr.io/owner/repo:v1").await;
assert_eq!(
outcome,
ProbeOutcome::Fetched {
local: None,
latest: DIG_A.to_owned(),
}
);
assert_eq!(registry.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn registry_auth_error_maps_to_auth_required() {
let docker = FakeDocker::new(&[]);
let registry = FakeRegistry::auth_required();
let outcome = probe_image(&docker, ®istry, "ghcr.io/owner/private:v1").await;
assert_eq!(outcome, ProbeOutcome::AuthRequired);
assert_eq!(registry.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn registry_credentials_rejected_maps_to_credentials_rejected() {
let docker = FakeDocker::new(&[]);
let registry = FakeRegistry::credentials_rejected();
let outcome = probe_image(&docker, ®istry, "ghcr.io/owner/private:v1").await;
assert_eq!(outcome, ProbeOutcome::CredentialsRejected);
assert_eq!(registry.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn pinned_digest_ref_short_circuits_before_any_io() {
let docker = FakeDocker::new(&[]);
let registry = FakeRegistry::new(DIG_A);
for image in ["sha256:abcdef0123456789", "alpine@sha256:abcdef0123456789"] {
let outcome = probe_image(&docker, ®istry, image).await;
assert_eq!(outcome, ProbeOutcome::Pinned, "image={image}");
}
assert_eq!(
docker.inspect_calls.load(Ordering::SeqCst),
0,
"a pinned ref must not trigger an image inspect"
);
assert_eq!(
registry.calls.load(Ordering::SeqCst),
0,
"a pinned ref must not trigger a registry call (issue #27)"
);
}
}