#![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 DockerRegistryError {
Empty,
InvalidRegistry,
InvalidRepository,
}
impl fmt::Display for DockerRegistryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Docker registry reference cannot be empty"),
Self::InvalidRegistry => formatter.write_str("invalid Docker registry host"),
Self::InvalidRepository => formatter.write_str("invalid Docker repository path"),
}
}
}
impl Error for DockerRegistryError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerRegistry(String);
impl DockerRegistry {
pub fn new(value: impl AsRef<str>) -> Result<Self, DockerRegistryError> {
let trimmed = value.as_ref().trim();
validate_registry(trimmed)?;
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for DockerRegistry {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for DockerRegistry {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DockerRegistry {
type Err = DockerRegistryError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerRepositoryPath(String);
impl DockerRepositoryPath {
pub fn new(value: impl AsRef<str>) -> Result<Self, DockerRegistryError> {
let trimmed = value.as_ref().trim();
validate_repository(trimmed)?;
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn repository(&self) -> &str {
self.as_str()
.rsplit_once('/')
.map_or(self.as_str(), |(_, repository)| repository)
}
}
impl AsRef<str> for DockerRepositoryPath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for DockerRepositoryPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DockerRepositoryPath {
type Err = DockerRegistryError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RegistryImagePath {
registry: Option<DockerRegistry>,
repository: DockerRepositoryPath,
}
impl RegistryImagePath {
#[must_use]
pub fn new(registry: Option<DockerRegistry>, repository: DockerRepositoryPath) -> Self {
Self {
registry,
repository,
}
}
#[must_use]
pub fn registry(&self) -> Option<&DockerRegistry> {
self.registry.as_ref()
}
#[must_use]
pub fn repository_path(&self) -> &DockerRepositoryPath {
&self.repository
}
}
impl fmt::Display for RegistryImagePath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(registry) = &self.registry {
write!(formatter, "{registry}/{}", self.repository)
} else {
fmt::Display::fmt(&self.repository, formatter)
}
}
}
fn validate_registry(value: &str) -> Result<(), DockerRegistryError> {
if value.is_empty() {
return Err(DockerRegistryError::Empty);
}
if value.contains("//")
|| value.contains('/')
|| value.chars().any(char::is_whitespace)
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b':'))
|| value.starts_with(['.', '-', ':'])
|| value.ends_with(['.', '-', ':'])
{
Err(DockerRegistryError::InvalidRegistry)
} else {
Ok(())
}
}
fn validate_repository(value: &str) -> Result<(), DockerRegistryError> {
if value.is_empty() {
return Err(DockerRegistryError::Empty);
}
if value
.split('/')
.any(|component| !is_valid_component(component))
{
Err(DockerRegistryError::InvalidRepository)
} 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())
}
#[cfg(test)]
mod tests {
use super::{DockerRegistry, DockerRepositoryPath, RegistryImagePath};
#[test]
fn renders_registry_image_paths() -> Result<(), Box<dyn std::error::Error>> {
let registry = DockerRegistry::new("ghcr.io")?;
let repository = DockerRepositoryPath::new("rustuse/app")?;
let path = RegistryImagePath::new(Some(registry), repository);
assert_eq!(path.to_string(), "ghcr.io/rustuse/app");
assert_eq!(path.repository_path().repository(), "app");
Ok(())
}
}