#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerImageReferenceError {
Empty,
InvalidName,
InvalidTag,
InvalidDigest,
}
impl fmt::Display for DockerImageReferenceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Docker image reference cannot be empty"),
Self::InvalidName => formatter.write_str("invalid Docker image name"),
Self::InvalidTag => formatter.write_str("invalid Docker image tag"),
Self::InvalidDigest => formatter.write_str("invalid Docker image digest"),
}
}
}
impl Error for DockerImageReferenceError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerImageReference {
value: String,
registry: Option<String>,
path: String,
repository: String,
tag: Option<String>,
digest: Option<String>,
}
impl DockerImageReference {
pub fn parse(value: impl AsRef<str>) -> Result<Self, DockerImageReferenceError> {
parse_reference(value.as_ref())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
#[must_use]
pub fn registry(&self) -> Option<&str> {
self.registry.as_deref()
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn namespace(&self) -> Option<&str> {
self.path.rsplit_once('/').map(|(namespace, _)| namespace)
}
#[must_use]
pub fn repository(&self) -> &str {
&self.repository
}
#[must_use]
pub fn tag(&self) -> Option<&str> {
self.tag.as_deref()
}
#[must_use]
pub fn digest(&self) -> Option<&str> {
self.digest.as_deref()
}
}
impl AsRef<str> for DockerImageReference {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for DockerImageReference {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DockerImageReference {
type Err = DockerImageReferenceError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
impl TryFrom<&str> for DockerImageReference {
type Error = DockerImageReferenceError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
fn parse_reference(value: &str) -> Result<DockerImageReference, DockerImageReferenceError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(DockerImageReferenceError::Empty);
}
if trimmed.chars().any(char::is_whitespace) {
return Err(DockerImageReferenceError::InvalidName);
}
let (without_digest, digest) = match trimmed.split_once('@') {
Some((name, digest)) => {
validate_digest(digest)?;
(name, Some(digest.to_string()))
},
None => (trimmed, None),
};
let slash_index = without_digest.rfind('/');
let colon_index = without_digest.rfind(':');
let (name_part, tag) = match colon_index {
Some(index) if slash_index.is_none_or(|slash| index > slash) => {
let tag = &without_digest[index + 1..];
validate_tag(tag)?;
(&without_digest[..index], Some(tag.to_string()))
},
_ => (without_digest, None),
};
let (registry, path) = split_registry(name_part);
validate_path(path)?;
let repository = path
.rsplit_once('/')
.map_or(path, |(_, repository)| repository)
.to_string();
Ok(DockerImageReference {
value: trimmed.to_string(),
registry: registry.map(str::to_string),
path: path.to_string(),
repository,
tag,
digest,
})
}
fn split_registry(value: &str) -> (Option<&str>, &str) {
let Some((first, rest)) = value.split_once('/') else {
return (None, value);
};
if first.contains('.') || first.contains(':') || first == "localhost" {
(Some(first), rest)
} else {
(None, value)
}
}
fn validate_path(value: &str) -> Result<(), DockerImageReferenceError> {
if value.is_empty()
|| value
.split('/')
.any(|component| !is_valid_component(component))
{
Err(DockerImageReferenceError::InvalidName)
} else {
Ok(())
}
}
fn is_valid_component(value: &str) -> bool {
!value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
})
&& value
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
&& value
.bytes()
.last()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
}
fn validate_tag(value: &str) -> Result<(), DockerImageReferenceError> {
if value.is_empty()
|| value.len() > 128
|| !value
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
{
Err(DockerImageReferenceError::InvalidTag)
} else {
Ok(())
}
}
fn validate_digest(value: &str) -> Result<(), DockerImageReferenceError> {
let Some((algorithm, digest)) = value.split_once(':') else {
return Err(DockerImageReferenceError::InvalidDigest);
};
if algorithm.is_empty()
|| digest.is_empty()
|| !algorithm
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
|| !digest
.bytes()
.all(|byte| byte.is_ascii_hexdigit() || matches!(byte, b'_' | b'.' | b'-'))
{
Err(DockerImageReferenceError::InvalidDigest)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{DockerImageReference, DockerImageReferenceError};
#[test]
fn parses_image_reference_components() -> Result<(), Box<dyn std::error::Error>> {
let reference: DockerImageReference = "ghcr.io/rustuse/app:0.1.0".parse()?;
assert_eq!(reference.registry(), Some("ghcr.io"));
assert_eq!(reference.namespace(), Some("rustuse"));
assert_eq!(reference.repository(), "app");
assert_eq!(reference.tag(), Some("0.1.0"));
assert_eq!(reference.digest(), None);
assert_eq!(
DockerImageReference::parse("Bad/Name"),
Err(DockerImageReferenceError::InvalidName)
);
Ok(())
}
}