#[derive(Clone, Debug, PartialEq)]
pub enum Image {
OfficialRelease {
major: Major,
minor: Minor,
os: OS,
digest: Option<Digest>,
},
OfficialReleaseCandidate {
major: Major,
number: ReleaseCandidateNumber,
os: OS,
digest: Option<Digest>,
},
OfficialLatest { os: OS, digest: Option<Digest> },
Explicit(ociman::image::Reference),
}
impl std::default::Default for Image {
fn default() -> Self {
Self::OfficialLatest {
os: OS::Default,
digest: None,
}
}
}
impl std::fmt::Display for Image {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::OfficialRelease {
major,
minor,
os,
digest,
} => {
write!(formatter, "{major}{minor}{os}")?;
if let Some(digest) = digest {
write!(formatter, "@{digest}")?;
}
Ok(())
}
Self::OfficialReleaseCandidate {
major,
number,
os,
digest,
} => {
write!(formatter, "{major}rc{number}{os}")?;
if let Some(digest) = digest {
write!(formatter, "@{digest}")?;
}
Ok(())
}
Self::OfficialLatest { os, digest } => {
match os {
OS::Default => write!(formatter, "latest")?,
OS::Explicit(value) => write!(formatter, "{value}")?,
}
if let Some(digest) = digest {
write!(formatter, "@{digest}")?;
}
Ok(())
}
Self::Explicit(image) => write!(formatter, "{image}"),
}
}
}
impl std::str::FromStr for Image {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
use nom::{
Finish, IResult, Parser,
branch::alt,
bytes::complete::{tag, take_while_m_n, take_while1},
character::complete::digit1,
combinator::{cut, opt, recognize},
error::context,
sequence::{pair, preceded},
};
use nom_language::error::VerboseError;
type ParseResult<'a, O> = IResult<&'a str, O, VerboseError<&'a str>>;
fn os_name(input: &str) -> ParseResult<'_, &str> {
context(
"OS name",
recognize(pair(
take_while_m_n(1, 1, |ch: char| ch.is_ascii_lowercase()),
take_while1(|ch: char| {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.'
}),
)),
)
.parse(input)
}
fn os_suffix(input: &str) -> ParseResult<'_, OS> {
context(
"OS suffix",
preceded(tag("-"), os_name).map(|name: &str| OS::Explicit(name.to_string())),
)
.parse(input)
}
fn digest(input: &str) -> ParseResult<'_, Digest> {
context(
"digest",
preceded(
tag("@sha256:"),
cut(take_while_m_n(64, 64, |ch: char| ch.is_ascii_hexdigit())),
)
.map_res(|hash: &str| {
hex::decode(hash)
.map_err(|err| format!("invalid hex: {err}"))
.and_then(|bytes| {
bytes
.try_into()
.map(Digest)
.map_err(|_| "hash must be exactly 32 bytes".to_string())
})
}),
)
.parse(input)
}
fn latest(input: &str) -> ParseResult<'_, Image> {
context(
"latest image",
(tag("latest"), opt(digest)).map(|(_, digest)| Image::OfficialLatest {
os: OS::Default,
digest,
}),
)
.parse(input)
}
fn os_only(input: &str) -> ParseResult<'_, Image> {
context(
"OS-only image",
(os_name, opt(digest)).map(|(os, digest)| Image::OfficialLatest {
os: OS::Explicit(os.to_string()),
digest,
}),
)
.parse(input)
}
fn release_candidate(input: &str) -> ParseResult<'_, Image> {
context(
"release candidate image",
(
digit1.map_res(|digits: &str| digits.parse::<u8>().map(Major)),
preceded(
tag("rc"),
digit1.map_res(|digits: &str| {
digits
.parse::<std::num::NonZero<u8>>()
.map(ReleaseCandidateNumber)
}),
),
opt(os_suffix),
opt(digest),
)
.map(|(major, number, os, digest)| {
Image::OfficialReleaseCandidate {
major,
number,
os: os.unwrap_or(OS::Default),
digest,
}
}),
)
.parse(input)
}
fn official_release(input: &str) -> ParseResult<'_, Image> {
context(
"official release image",
(
digit1.map_res(|digits: &str| digits.parse::<u8>().map(Major)),
opt(preceded(
tag("."),
digit1.map_res(|digits: &str| digits.parse::<u8>().map(Minor::Explicit)),
)),
opt(os_suffix),
opt(digest),
)
.map(|(major, minor, os, digest)| Image::OfficialRelease {
major,
minor: minor.unwrap_or(Minor::Latest),
os: os.unwrap_or(OS::Default),
digest,
}),
)
.parse(input)
}
fn image(input: &str) -> ParseResult<'_, Image> {
alt((latest, release_candidate, official_release, os_only)).parse(input)
}
match image(value).finish() {
Ok(("", result)) => Ok(result),
Ok((remaining, _)) => Err(format!("unexpected trailing input: '{remaining}'")),
Err(error) => Err(nom_language::error::convert_error(value, error)),
}
}
}
impl<'de> serde::Deserialize<'de> for Image {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
<String as serde::de::Deserialize<'de>>::deserialize(deserializer)
.and_then(|value| value.parse().map_err(serde::de::Error::custom))
}
}
impl From<&Image> for ociman::image::Reference {
fn from(image: &Image) -> Self {
match image {
Image::Explicit(reference) => reference.clone(),
Image::OfficialRelease { .. }
| Image::OfficialReleaseCandidate { .. }
| Image::OfficialLatest { .. } => {
format!("registry.hub.docker.com/library/postgres:{image}")
.parse()
.unwrap()
}
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Major(u8);
impl std::fmt::Display for Major {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
impl Major {
#[must_use]
pub const fn new(value: u8) -> Self {
Self(value)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Minor {
Explicit(u8),
Latest,
}
impl std::fmt::Display for Minor {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Explicit(number) => write!(formatter, ".{number}"),
Self::Latest => write!(formatter, ""),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ReleaseCandidateNumber(std::num::NonZero<u8>);
impl ReleaseCandidateNumber {
#[must_use]
pub const fn new(value: std::num::NonZero<u8>) -> Self {
Self(value)
}
}
impl std::fmt::Display for ReleaseCandidateNumber {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum OS {
Default,
Explicit(String),
}
impl std::fmt::Display for OS {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Default => write!(formatter, ""),
Self::Explicit(value) => write!(formatter, "-{value}"),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Digest([u8; 32]);
impl std::fmt::Display for Digest {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "sha256:{}", hex::encode(self.0))
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_image_string() {
assert_image(
"latest",
&Image::OfficialLatest {
os: OS::Default,
digest: None,
},
);
assert_image(
"trixie",
&Image::OfficialLatest {
os: OS::Explicit("trixie".to_string()),
digest: None,
},
);
assert_image(
"18rc1",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Default,
digest: None,
},
);
assert_image(
"18rc1-trixie",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Explicit("trixie".to_string()),
digest: None,
},
);
assert_image(
"18rc1-bookworm",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Explicit("bookworm".to_string()),
digest: None,
},
);
assert_image(
"18rc1-alpine3.22",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Explicit("alpine3.22".to_string()),
digest: None,
},
);
assert_image(
"18rc1-alpine3.21",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Explicit("alpine3.21".to_string()),
digest: None,
},
);
assert_image(
"18rc1-alpine",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Explicit("alpine".to_string()),
digest: None,
},
);
assert_image(
"17",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Latest,
os: OS::Default,
digest: None,
},
);
assert_image(
"17-trixie",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Latest,
os: OS::Explicit("trixie".to_string()),
digest: None,
},
);
assert_image(
"17.6",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Explicit(6),
os: OS::Default,
digest: None,
},
);
assert_image(
"17.6-trixie",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Explicit(6),
os: OS::Explicit("trixie".to_string()),
digest: None,
},
);
}
fn assert_image(syntax: &str, expected: &Image) {
assert_eq!(syntax.parse().as_ref(), Ok(expected), "parses: {syntax:#?}");
assert_eq!(format!("{expected}"), syntax, "generates: {syntax:#?}");
}
#[test]
fn test_image_with_digest() {
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let parsed_digest = Some(Digest(hex::decode(hash).unwrap().try_into().unwrap()));
assert_image(
"17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Explicit(6),
os: OS::Default,
digest: parsed_digest.clone(),
},
);
assert_image(
"17.6-trixie@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Explicit(6),
os: OS::Explicit("trixie".to_string()),
digest: parsed_digest.clone(),
},
);
assert_image(
"17@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialRelease {
major: Major(17),
minor: Minor::Latest,
os: OS::Default,
digest: parsed_digest.clone(),
},
);
assert_image(
"18rc1@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Default,
digest: parsed_digest.clone(),
},
);
assert_image(
"18rc1-alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialReleaseCandidate {
major: Major(18),
number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
os: OS::Explicit("alpine".to_string()),
digest: parsed_digest.clone(),
},
);
assert_image(
"latest@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialLatest {
os: OS::Default,
digest: parsed_digest.clone(),
},
);
assert_image(
"trixie@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
&Image::OfficialLatest {
os: OS::Explicit("trixie".to_string()),
digest: parsed_digest.clone(),
},
);
}
#[test]
fn test_ociman_image_conversion_with_digest() {
let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let image = Image::OfficialRelease {
major: Major(17),
minor: Minor::Explicit(6),
os: OS::Default,
digest: Some(Digest(hex::decode(hash).unwrap().try_into().unwrap())),
};
let reference: ociman::image::Reference = (&image).into();
let expected = "registry.hub.docker.com/library/postgres:17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert_eq!(reference.to_string(), expected);
}
#[test]
fn test_parse_error_uppercase() {
let error = "LATEST".parse::<Image>().unwrap_err();
let expected = indoc::indoc! {"
0: at line 1, in TakeWhileMN:
LATEST
^
1: at line 1, in OS name:
LATEST
^
2: at line 1, in OS-only image:
LATEST
^
3: at line 1, in Alt:
LATEST
^
"};
assert_eq!(error, expected);
}
#[test]
fn test_parse_error_invalid_rc() {
let error = "17rc".parse::<Image>().unwrap_err();
let expected = "unexpected trailing input: 'rc'";
assert_eq!(error, expected);
}
#[test]
fn test_parse_error_short_digest() {
let error = "17@sha256:abc".parse::<Image>().unwrap_err();
let expected = indoc::indoc! {"
0: at line 1, in TakeWhileMN:
17@sha256:abc
^
1: at line 1, in digest:
17@sha256:abc
^
2: at line 1, in official release image:
17@sha256:abc
^
"};
assert_eq!(error, expected);
}
#[test]
fn test_parse_error_trailing_dash() {
let error = "17-".parse::<Image>().unwrap_err();
let expected = "unexpected trailing input: '-'";
assert_eq!(error, expected);
}
#[test]
fn test_parse_error_trailing_content() {
let error = "17.6.5".parse::<Image>().unwrap_err();
let expected = "unexpected trailing input: '.5'";
assert_eq!(error, expected);
}
#[test]
fn test_parse_error_invalid_os_name() {
let error = "17-9invalid".parse::<Image>().unwrap_err();
let expected = "unexpected trailing input: '-9invalid'";
assert_eq!(error, expected);
}
}