Skip to main content

use_docker_tag/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when Docker tag text is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerTagError {
10    /// The tag was empty after trimming.
11    Empty,
12    /// Docker tags are limited to 128 characters.
13    TooLong,
14    /// The first character was not ASCII alphanumeric or `_`.
15    InvalidStart,
16    /// The tag contained a character outside `[A-Za-z0-9_.-]`.
17    InvalidCharacter,
18}
19
20impl fmt::Display for DockerTagError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Empty => formatter.write_str("Docker tag cannot be empty"),
24            Self::TooLong => formatter.write_str("Docker tag cannot exceed 128 characters"),
25            Self::InvalidStart => {
26                formatter.write_str("Docker tag must start with an ASCII word character")
27            },
28            Self::InvalidCharacter => formatter.write_str("Docker tag contains invalid characters"),
29        }
30    }
31}
32
33impl Error for DockerTagError {}
34
35/// A validated Docker tag.
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct DockerTag(String);
38
39impl DockerTag {
40    /// Creates a validated Docker tag.
41    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerTagError> {
42        let trimmed = value.as_ref().trim();
43        validate_tag(trimmed)?;
44        Ok(Self(trimmed.to_string()))
45    }
46
47    /// Returns the conventional `latest` tag.
48    #[must_use]
49    pub fn latest() -> Self {
50        Self("latest".to_string())
51    }
52
53    /// Returns the tag text.
54    #[must_use]
55    pub fn as_str(&self) -> &str {
56        &self.0
57    }
58
59    /// Returns true when the tag is exactly `latest`.
60    #[must_use]
61    pub fn is_latest(&self) -> bool {
62        self.as_str() == "latest"
63    }
64
65    /// Returns true for common semantic-version-shaped tags.
66    #[must_use]
67    pub fn is_semver_like(&self) -> bool {
68        let value = self.as_str().strip_prefix('v').unwrap_or(self.as_str());
69        let core = value.split_once('-').map_or(value, |(core, _)| core);
70        let mut parts = core.split('.');
71        matches!(
72            (parts.next(), parts.next(), parts.next(), parts.next()),
73            (Some(major), Some(minor), Some(patch), None)
74                if is_digits(major) && is_digits(minor) && is_digits(patch)
75        )
76    }
77
78    /// Returns true when the tag ends with a common platform or distro suffix.
79    #[must_use]
80    pub fn has_platform_suffix(&self) -> bool {
81        let suffix = self
82            .as_str()
83            .rsplit_once(['-', '_'])
84            .map_or(self.as_str(), |(_, suffix)| suffix);
85        matches!(
86            suffix,
87            "amd64"
88                | "arm64"
89                | "armv7"
90                | "armv6"
91                | "386"
92                | "alpine"
93                | "bookworm"
94                | "bullseye"
95                | "slim"
96        )
97    }
98}
99
100impl AsRef<str> for DockerTag {
101    fn as_ref(&self) -> &str {
102        self.as_str()
103    }
104}
105
106impl fmt::Display for DockerTag {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        formatter.write_str(self.as_str())
109    }
110}
111
112impl FromStr for DockerTag {
113    type Err = DockerTagError;
114
115    fn from_str(value: &str) -> Result<Self, Self::Err> {
116        Self::new(value)
117    }
118}
119
120impl TryFrom<&str> for DockerTag {
121    type Error = DockerTagError;
122
123    fn try_from(value: &str) -> Result<Self, Self::Error> {
124        Self::new(value)
125    }
126}
127
128/// Returns true when `value` is valid Docker tag text.
129#[must_use]
130pub fn is_valid_docker_tag(value: impl AsRef<str>) -> bool {
131    validate_tag(value.as_ref().trim()).is_ok()
132}
133
134fn validate_tag(value: &str) -> Result<(), DockerTagError> {
135    if value.is_empty() {
136        return Err(DockerTagError::Empty);
137    }
138    if value.len() > 128 {
139        return Err(DockerTagError::TooLong);
140    }
141    let mut chars = value.chars();
142    let Some(first) = chars.next() else {
143        return Err(DockerTagError::Empty);
144    };
145    if !(first.is_ascii_alphanumeric() || first == '_') {
146        return Err(DockerTagError::InvalidStart);
147    }
148    if chars.any(|character| {
149        !(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
150    }) {
151        return Err(DockerTagError::InvalidCharacter);
152    }
153    Ok(())
154}
155
156fn is_digits(value: &str) -> bool {
157    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::{DockerTag, DockerTagError, is_valid_docker_tag};
163
164    #[test]
165    fn validates_and_classifies_tags() -> Result<(), Box<dyn std::error::Error>> {
166        let tag: DockerTag = "v1.2.3-amd64".parse()?;
167
168        assert!(tag.is_semver_like());
169        assert!(tag.has_platform_suffix());
170        assert!(DockerTag::latest().is_latest());
171        assert!(is_valid_docker_tag("_dev"));
172        assert_eq!(DockerTag::new("-bad"), Err(DockerTagError::InvalidStart));
173        Ok(())
174    }
175}