use-docker-image 0.0.1

Primitive Docker image reference parsing for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned when a Docker image reference is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerImageReferenceError {
    /// The reference was empty after trimming.
    Empty,
    /// The image path or repository name was not accepted by this crate.
    InvalidName,
    /// A tag marker was present but the tag text was invalid.
    InvalidTag,
    /// A digest marker was present but the digest text was invalid.
    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 {}

/// A conservatively parsed Docker image reference.
#[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 {
    /// Parses a Docker image reference.
    pub fn parse(value: impl AsRef<str>) -> Result<Self, DockerImageReferenceError> {
        parse_reference(value.as_ref())
    }

    /// Returns the original normalized reference text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.value
    }

    /// Returns the registry host when one was present.
    #[must_use]
    pub fn registry(&self) -> Option<&str> {
        self.registry.as_deref()
    }

    /// Returns the image path after the optional registry.
    #[must_use]
    pub fn path(&self) -> &str {
        &self.path
    }

    /// Returns the slash-separated namespace before the repository name.
    #[must_use]
    pub fn namespace(&self) -> Option<&str> {
        self.path.rsplit_once('/').map(|(namespace, _)| namespace)
    }

    /// Returns the repository name.
    #[must_use]
    pub fn repository(&self) -> &str {
        &self.repository
    }

    /// Returns the optional tag.
    #[must_use]
    pub fn tag(&self) -> Option<&str> {
        self.tag.as_deref()
    }

    /// Returns the optional digest.
    #[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(())
    }
}