use nom::{
IResult, Parser,
branch::alt,
bytes::complete::{tag, take_while, take_while_m_n, take_while1},
character::complete::{char, digit1, u16},
combinator::{all_consuming, opt, recognize, verify},
multi::{many0, separated_list1},
sequence::{delimited, pair, preceded},
};
pub(crate) trait Parse: Sized {
fn parse(input: &str) -> IResult<&str, Self>;
}
fn write_interspersed<I, T>(
formatter: &mut std::fmt::Formatter<'_>,
items: I,
separator: &str,
) -> std::fmt::Result
where
I: IntoIterator<Item = T>,
T: AsRef<str>,
{
let mut iter = items.into_iter();
if let Some(first) = iter.next() {
formatter.write_str(first.as_ref())?;
for item in iter {
formatter.write_str(separator)?;
formatter.write_str(item.as_ref())?;
}
}
Ok(())
}
macro_rules! impl_from_str {
($($type:ty),* $(,)?) => {
$(
impl std::str::FromStr for $type {
type Err = String;
fn from_str(string: &str) -> Result<Self, Self::Err> {
match all_consuming(<Self as Parse>::parse).parse(string) {
Ok((_remaining, value)) => Ok(value),
Err(error) => Err(format!("parse error: {error}")),
}
}
}
)*
};
}
impl_from_str!(
DomainComponent,
DomainName,
PortNumber,
Host,
Domain,
PathComponent,
Path,
Name,
Tag,
DigestAlgorithm,
Digest,
Reference,
);
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct DomainComponent(String);
impl DomainComponent {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for DomainComponent {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Parse for DomainComponent {
fn parse(input: &str) -> IResult<&str, Self> {
recognize(pair(
take_while1(|character: char| character.is_ascii_alphanumeric()),
many0(pair(
take_while1(|character: char| character == '-'),
take_while1(|character: char| character.is_ascii_alphanumeric()),
)),
))
.map(|string: &str| Self(string.to_string()))
.parse(input)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct DomainName(Vec<DomainComponent>);
impl DomainName {
#[must_use]
pub fn components(&self) -> &[DomainComponent] {
&self.0
}
}
impl Parse for DomainName {
fn parse(input: &str) -> IResult<&str, Self> {
separated_list1(char('.'), DomainComponent::parse)
.map(Self)
.parse(input)
}
}
impl std::fmt::Display for DomainName {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write_interspersed(formatter, &self.0, ".")
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct PortNumber(u16);
impl PortNumber {
#[must_use]
pub fn value(&self) -> u16 {
self.0
}
}
impl Parse for PortNumber {
fn parse(input: &str) -> IResult<&str, Self> {
u16.map(Self).parse(input)
}
}
impl std::fmt::Display for PortNumber {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum Host {
DomainName(DomainName),
Ipv4(std::net::Ipv4Addr),
Ipv6(std::net::Ipv6Addr),
}
impl Host {
fn parse_ipv4(input: &str) -> IResult<&str, std::net::Ipv4Addr> {
recognize((
digit1,
char('.'),
digit1,
char('.'),
digit1,
char('.'),
digit1,
))
.map_res(|s: &str| s.parse())
.parse(input)
}
fn parse_ipv6(input: &str) -> IResult<&str, std::net::Ipv6Addr> {
delimited(
char('['),
take_while1(|character: char| character.is_ascii_hexdigit() || character == ':'),
char(']'),
)
.map_res(|s: &str| s.parse())
.parse(input)
}
}
impl Parse for Host {
fn parse(input: &str) -> IResult<&str, Self> {
alt((
Self::parse_ipv6.map(Self::Ipv6),
Self::parse_ipv4.map(Self::Ipv4),
DomainName::parse.map(Self::DomainName),
))
.parse(input)
}
}
impl std::fmt::Display for Host {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DomainName(name) => write!(formatter, "{name}"),
Self::Ipv4(address) => write!(formatter, "{address}"),
Self::Ipv6(address) => write!(formatter, "[{address}]"),
}
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Domain {
pub host: Host,
pub port: Option<PortNumber>,
}
impl Domain {
#[must_use]
pub fn is_registry(&self) -> bool {
if self.port.is_some() {
return true;
}
match &self.host {
Host::Ipv4(_) | Host::Ipv6(_) => true,
Host::DomainName(name) => {
name.components().len() > 1 || name.to_string() == "localhost"
}
}
}
}
impl Parse for Domain {
fn parse(input: &str) -> IResult<&str, Self> {
pair(Host::parse, opt(preceded(char(':'), PortNumber::parse)))
.map(|(host, port)| Self { host, port })
.parse(input)
}
}
impl std::fmt::Display for Domain {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.host)?;
if let Some(port) = &self.port {
write!(formatter, ":{port}")?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct PathComponent(String);
impl PathComponent {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for PathComponent {
fn as_ref(&self) -> &str {
&self.0
}
}
impl PathComponent {
fn parse_alpha_numeric(input: &str) -> IResult<&str, &str> {
take_while1(|character: char| character.is_ascii_lowercase() || character.is_ascii_digit())
.parse(input)
}
fn parse_separator(input: &str) -> IResult<&str, &str> {
alt((tag("__"), tag("_"), tag("."), recognize(many0(char('-'))))).parse(input)
}
}
impl Parse for PathComponent {
fn parse(input: &str) -> IResult<&str, Self> {
recognize(pair(
Self::parse_alpha_numeric,
many0(pair(Self::parse_separator, Self::parse_alpha_numeric)),
))
.map(|string: &str| Self(string.to_string()))
.parse(input)
}
}
impl std::fmt::Display for PathComponent {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Path(Vec<PathComponent>);
impl Path {
#[must_use]
pub fn components(&self) -> &[PathComponent] {
&self.0
}
}
impl Parse for Path {
fn parse(input: &str) -> IResult<&str, Self> {
separated_list1(char('/'), PathComponent::parse)
.map(Self)
.parse(input)
}
}
impl std::fmt::Display for Path {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write_interspersed(formatter, &self.0, "/")
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Name {
pub domain: Option<Domain>,
pub path: Path,
}
impl Parse for Name {
fn parse(input: &str) -> IResult<&str, Self> {
alt((
pair(
|input| {
let (remaining, domain) = Domain::parse(input)?;
if !remaining.starts_with('/') || !domain.is_registry() {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Verify,
)));
}
Ok((remaining, domain))
},
preceded(char('/'), Path::parse),
)
.map(|(domain, path)| Self {
domain: Some(domain),
path,
}),
Path::parse.map(|path| Self { domain: None, path }),
))
.parse(input)
}
}
impl std::fmt::Display for Name {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(domain) = &self.domain {
write!(formatter, "{domain}/")?;
}
write!(formatter, "{}", self.path)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Tag(String);
impl Tag {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Parse for Tag {
fn parse(input: &str) -> IResult<&str, Self> {
recognize(pair(
take_while_m_n(1, 1, |character: char| {
character.is_ascii_alphanumeric() || character == '_'
}),
take_while_m_n(0, 127, |character: char| {
character.is_ascii_alphanumeric()
|| character == '_'
|| character == '.'
|| character == '-'
}),
))
.map(|string: &str| Self(string.to_string()))
.parse(input)
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
impl From<sha2::digest::Output<sha2::Sha256>> for Tag {
fn from(hash: sha2::digest::Output<sha2::Sha256>) -> Self {
Self(hex::encode(hash))
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct DigestAlgorithm(String);
impl DigestAlgorithm {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn parse_component(input: &str) -> IResult<&str, &str> {
recognize(pair(
take_while_m_n(1, 1, |character: char| character.is_ascii_alphabetic()),
take_while(|character: char| character.is_ascii_alphanumeric()),
))
.parse(input)
}
fn parse_separator(input: &str) -> IResult<&str, char> {
alt((char('+'), char('.'), char('-'), char('_'))).parse(input)
}
}
impl Parse for DigestAlgorithm {
fn parse(input: &str) -> IResult<&str, Self> {
recognize(pair(
Self::parse_component,
many0(pair(Self::parse_separator, Self::parse_component)),
))
.map(|string: &str| Self(string.to_string()))
.parse(input)
}
}
impl std::fmt::Display for DigestAlgorithm {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Digest {
pub algorithm: DigestAlgorithm,
pub hex: String,
}
impl Digest {
fn parse_hex(input: &str) -> IResult<&str, &str> {
verify(
take_while1(|character: char| character.is_ascii_hexdigit()),
|string: &str| string.len() >= 32,
)
.parse(input)
}
}
impl Parse for Digest {
fn parse(input: &str) -> IResult<&str, Self> {
(DigestAlgorithm::parse, char(':'), Self::parse_hex)
.map(|(algorithm, _, hex)| Self {
algorithm,
hex: hex.to_string(),
})
.parse(input)
}
}
impl std::fmt::Display for Digest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}:{}", self.algorithm, self.hex)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Reference {
pub name: Name,
pub tag: Option<Tag>,
pub digest: Option<Digest>,
}
impl Parse for Reference {
fn parse(input: &str) -> IResult<&str, Self> {
(
Name::parse,
opt(preceded(char(':'), Tag::parse)),
opt(preceded(char('@'), Digest::parse)),
)
.map(|(name, tag, digest)| Self { name, tag, digest })
.parse(input)
}
}
impl std::fmt::Display for Reference {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.name)?;
if let Some(tag) = &self.tag {
write!(formatter, ":{tag}")?;
}
if let Some(digest) = &self.digest {
write!(formatter, "@{digest}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_domain_is_registry_with_dots() {
let domain: Domain = "docker.io".parse().unwrap();
assert!(domain.is_registry());
}
#[test]
fn test_domain_is_registry_with_port() {
let domain: Domain = "myhost:5000".parse().unwrap();
assert!(domain.is_registry());
}
#[test]
fn test_domain_is_registry_localhost() {
let domain: Domain = "localhost".parse().unwrap();
assert!(domain.is_registry());
}
#[test]
fn test_domain_is_registry_localhost_with_port() {
let domain: Domain = "localhost:5000".parse().unwrap();
assert!(domain.is_registry());
}
#[test]
fn test_domain_is_registry_ipv4() {
let domain: Domain = "192.168.1.1".parse().unwrap();
assert!(domain.is_registry());
}
#[test]
fn test_domain_is_not_registry_single_component() {
let domain: Domain = "myproject".parse().unwrap();
assert!(!domain.is_registry());
}
#[test]
fn test_name_single_component_not_domain() {
let name: Name = "pg-ephemeral/main".parse().unwrap();
assert!(name.domain.is_none());
assert_eq!(name.path.to_string(), "pg-ephemeral/main");
}
#[test]
fn test_name_localhost_is_domain() {
let name: Name = "localhost/pg-ephemeral/main".parse().unwrap();
assert_eq!(name.domain.unwrap().to_string(), "localhost");
assert_eq!(name.path.to_string(), "pg-ephemeral/main");
}
#[test]
fn test_name_dotted_is_domain() {
let name: Name = "docker.io/library/alpine".parse().unwrap();
assert_eq!(name.domain.unwrap().to_string(), "docker.io");
assert_eq!(name.path.to_string(), "library/alpine");
}
#[test]
fn test_name_with_port_is_domain() {
let name: Name = "myhost:5000/myimage".parse().unwrap();
assert_eq!(name.domain.unwrap().to_string(), "myhost:5000");
assert_eq!(name.path.to_string(), "myimage");
}
#[test]
fn test_name_bare_path() {
let name: Name = "alpine".parse().unwrap();
assert!(name.domain.is_none());
assert_eq!(name.path.to_string(), "alpine");
}
#[test]
fn test_name_multi_segment_path_no_domain() {
let name: Name = "myorg/myproject/myimage".parse().unwrap();
assert!(name.domain.is_none());
assert_eq!(name.path.to_string(), "myorg/myproject/myimage");
}
}