use anyhow::{Context, Result};
use oci_spec::distribution::Reference;
use serde::{Deserialize, Serialize};
use std::{borrow::Cow, fmt, str::FromStr};
const DEFAULT_TAG: &str = "latest";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ImageRef(Reference);
impl ImageRef {
pub fn parse(input: &str) -> Result<Self> {
Self::from_str(input)
}
pub fn registry(&self) -> &str {
self.0.registry()
}
pub fn name(&self) -> &str {
self.0.repository()
}
pub fn reference(&self) -> &str {
self.0
.digest()
.or_else(|| self.0.tag())
.unwrap_or(DEFAULT_TAG)
}
pub(crate) fn repository_key(&self) -> String {
format!("{}/{}", self.registry(), self.name())
}
pub(crate) fn as_inner(&self) -> &Reference {
&self.0
}
pub(crate) fn from_repository_and_reference(name: &str, reference: &str) -> Result<Self> {
let separator = if reference.contains(':') { '@' } else { ':' };
Self::parse(&format!("{name}{separator}{reference}"))
}
}
impl FromStr for ImageRef {
type Err = anyhow::Error;
fn from_str(input: &str) -> Result<Self> {
let canonical = canonicalize_legacy_docker_hub_host(input);
let reference = canonical
.parse::<Reference>()
.with_context(|| format!("Invalid image reference: {input}"))?;
Ok(Self(reference))
}
}
fn canonicalize_legacy_docker_hub_host(input: &str) -> Cow<'_, str> {
if let Some(rest) = input.strip_prefix("registry-1.docker.io/") {
Cow::Owned(format!("docker.io/{rest}"))
} else {
Cow::Borrowed(input)
}
}
impl fmt::Display for ImageRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl Serialize for ImageRef {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for ImageRef {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::parse(&raw).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_form() {
let r = ImageRef::parse("ghcr.io/jij-inc/ommx/demo:v1").unwrap();
assert_eq!(r.registry(), "ghcr.io");
assert_eq!(r.name(), "jij-inc/ommx/demo");
assert_eq!(r.reference(), "v1");
assert_eq!(r.to_string(), "ghcr.io/jij-inc/ommx/demo:v1");
}
#[test]
fn parses_with_port() {
let r = ImageRef::parse("localhost:5000/test:tag1").unwrap();
assert_eq!(r.registry(), "localhost:5000");
assert_eq!(r.name(), "test");
assert_eq!(r.reference(), "tag1");
assert_eq!(r.to_string(), "localhost:5000/test:tag1");
}
#[test]
fn defaults_bare_name_to_docker_hub_with_library_prefix() {
let r = ImageRef::parse("alpine").unwrap();
assert_eq!(r.registry(), "docker.io");
assert_eq!(r.name(), "library/alpine");
assert_eq!(r.reference(), "latest");
}
#[test]
fn docker_namespaced_refs_default_to_docker_hub() {
for (input, name) in [
("library/ubuntu:20.04", "library/ubuntu"),
("jij-inc/ommx:latest", "jij-inc/ommx"),
] {
let r = ImageRef::parse(input).unwrap();
assert_eq!(
r.registry(),
"docker.io",
"{input} should default the registry",
);
assert_eq!(r.name(), name, "{input} should keep the namespace");
}
}
#[test]
fn host_heuristic_activates_on_dot_port_or_localhost() {
assert_eq!(
ImageRef::parse("ghcr.io/jij-inc/ommx:v1")
.unwrap()
.registry(),
"ghcr.io"
);
let with_port = ImageRef::parse("localhost:5000/repo:tag").unwrap();
assert_eq!(with_port.registry(), "localhost:5000");
assert_eq!(
ImageRef::parse("localhost/repo:tag").unwrap().registry(),
"localhost",
);
}
#[test]
fn accepts_at_digest_and_round_trips() {
let s = "ghcr.io/jij-inc/ommx@sha256:0011223344556677889900112233445566778899001122334455667788990011";
let r = ImageRef::parse(s).unwrap();
assert_eq!(r.name(), "jij-inc/ommx");
assert_eq!(
r.reference(),
"sha256:0011223344556677889900112233445566778899001122334455667788990011"
);
assert_eq!(r.to_string(), s);
}
#[test]
fn tag_references_keep_colon_separator_on_display() {
let r = ImageRef::parse("ghcr.io/jij-inc/ommx:v1").unwrap();
assert_eq!(r.to_string(), "ghcr.io/jij-inc/ommx:v1");
}
#[test]
fn rejects_short_digest() {
assert!(ImageRef::parse("ghcr.io/foo@sha256:abc").is_err());
}
#[test]
fn rejects_ipv6_host_syntax() {
for input in [
"[::1]/repo:tag",
"[::1]:5000/repo:tag",
"[2001:db8::1]/repo:tag",
"::1/repo:tag",
] {
assert!(
ImageRef::parse(input).is_err(),
"expected oci_spec to reject IPv6 host {input}; revisit protocol_for if it starts accepting them",
);
}
}
#[test]
fn rejects_invalid_capital_in_name() {
assert!(ImageRef::parse("ghcr.io/Foo:v1").is_err());
}
#[test]
fn rejects_at_sign_in_non_digest_reference() {
assert!(ImageRef::parse("ghcr.io/foo@nottag").is_err());
}
#[test]
fn serde_round_trips_through_string() {
let r = ImageRef::parse("ghcr.io/jij-inc/ommx/demo:v1").unwrap();
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, "\"ghcr.io/jij-inc/ommx/demo:v1\"");
let r2: ImageRef = serde_json::from_str(&json).unwrap();
assert_eq!(r, r2);
}
#[test]
fn repository_key_format() {
let with_port = ImageRef::parse("localhost:5000/ommx/test:tag1").unwrap();
assert_eq!(with_port.repository_key(), "localhost:5000/ommx/test");
let no_port = ImageRef::parse("ghcr.io/jij-inc/ommx:tag1").unwrap();
assert_eq!(no_port.repository_key(), "ghcr.io/jij-inc/ommx");
}
#[test]
fn from_repository_and_reference_picks_at_for_digests() {
let tag = ImageRef::from_repository_and_reference("ghcr.io/jij-inc/ommx", "v1").unwrap();
assert_eq!(tag.to_string(), "ghcr.io/jij-inc/ommx:v1");
let digest = "sha256:0011223344556677889900112233445566778899001122334455667788990011";
let digest_ref =
ImageRef::from_repository_and_reference("ghcr.io/jij-inc/ommx", digest).unwrap();
assert_eq!(
digest_ref.to_string(),
format!("ghcr.io/jij-inc/ommx@{digest}"),
"digest reference must use `@`, not `:`, to survive oci_spec parsing",
);
}
#[test]
fn parse_collapses_ocipkg_docker_hub_host_to_canonical() {
let legacy = ImageRef::parse("registry-1.docker.io/alpine:latest").unwrap();
let bare = ImageRef::parse("alpine").unwrap();
let canonical = ImageRef::parse("docker.io/alpine:latest").unwrap();
assert_eq!(
legacy, bare,
"registry-1.docker.io/alpine:latest must parse to the same ImageRef as the bare name",
);
assert_eq!(
legacy, canonical,
"registry-1.docker.io/alpine:latest must parse to the same ImageRef as docker.io/alpine:latest",
);
assert_eq!(legacy.repository_key(), "docker.io/library/alpine");
assert_eq!(legacy.to_string(), "docker.io/library/alpine:latest");
}
#[test]
fn parse_collapses_multi_segment_docker_hub_host() {
let legacy = ImageRef::parse("registry-1.docker.io/jij-inc/ommx:v1").unwrap();
let canonical = ImageRef::parse("docker.io/jij-inc/ommx:v1").unwrap();
assert_eq!(legacy, canonical);
assert_eq!(legacy.repository_key(), "docker.io/jij-inc/ommx");
assert_eq!(legacy.to_string(), "docker.io/jij-inc/ommx:v1");
}
#[test]
fn parse_collapses_legacy_docker_hub_host_with_digest() {
let digest = "sha256:0011223344556677889900112233445566778899001122334455667788990011";
let legacy = ImageRef::parse(&format!("registry-1.docker.io/alpine@{digest}")).unwrap();
let canonical = ImageRef::parse(&format!("docker.io/alpine@{digest}")).unwrap();
assert_eq!(legacy, canonical);
assert_eq!(legacy.repository_key(), "docker.io/library/alpine");
}
#[test]
fn parse_does_not_rewrite_lookalike_hosts() {
let lookalike = ImageRef::parse("registry-1.docker.io.example/foo:v1").unwrap();
assert_eq!(
lookalike.registry(),
"registry-1.docker.io.example",
"substring-matching hostnames must not be rewritten to docker.io",
);
assert_eq!(lookalike.name(), "foo");
}
#[test]
fn parse_does_not_rewrite_bare_registry_host_string() {
let parsed = ImageRef::parse("registry-1.docker.io").unwrap();
assert_eq!(parsed.name(), "library/registry-1.docker.io");
assert_eq!(parsed.registry(), "docker.io");
}
#[test]
fn canonicalisation_invariant_collapses_every_docker_hub_spelling() {
let spellings = [
"alpine",
"alpine:latest",
"docker.io/alpine",
"docker.io/alpine:latest",
"docker.io/library/alpine:latest",
"index.docker.io/alpine:latest",
"index.docker.io/library/alpine:latest",
"registry-1.docker.io/alpine:latest",
"registry-1.docker.io/library/alpine:latest",
];
let canonical = ImageRef::parse(spellings[0]).unwrap();
for spelling in &spellings[1..] {
let parsed = ImageRef::parse(spelling)
.unwrap_or_else(|e| panic!("parse({spelling}) failed: {e}"));
assert_eq!(
parsed,
canonical,
"spelling {spelling} broke the canonicalisation invariant: \
produced {parsed} (repository_key={}) but canonical form is {canonical} \
(repository_key={})",
parsed.repository_key(),
canonical.repository_key(),
);
}
assert_eq!(canonical.repository_key(), "docker.io/library/alpine");
assert_eq!(canonical.reference(), "latest");
assert_eq!(canonical.to_string(), "docker.io/library/alpine:latest");
}
}