Skip to main content

use_oci_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/// Errors returned when OCI tag text is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum OciTagError {
10    Empty,
11    TooLong,
12    InvalidStart,
13    InvalidCharacter,
14    NotVersionLike,
15    NotArchitectureLike,
16}
17
18impl fmt::Display for OciTagError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("OCI tag cannot be empty"),
22            Self::TooLong => formatter.write_str("OCI tag cannot exceed 128 characters"),
23            Self::InvalidStart => {
24                formatter.write_str("OCI tag must start with an ASCII word character")
25            },
26            Self::InvalidCharacter => formatter.write_str("OCI tag contains invalid characters"),
27            Self::NotVersionLike => formatter.write_str("OCI tag is not version-like"),
28            Self::NotArchitectureLike => formatter.write_str("OCI tag is not architecture-like"),
29        }
30    }
31}
32
33impl Error for OciTagError {}
34
35/// A validated OCI tag.
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct OciTag(String);
38
39impl OciTag {
40    /// Creates a validated tag.
41    pub fn new(value: impl AsRef<str>) -> Result<Self, OciTagError> {
42        let trimmed = value.as_ref().trim();
43        validate_tag(trimmed)?;
44        Ok(Self(trimmed.to_string()))
45    }
46
47    /// Returns the conventional `latest` marker.
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 when the tag looks like a simple semantic version.
66    #[must_use]
67    pub fn is_version_like(&self) -> bool {
68        is_version_like(self.as_str())
69    }
70
71    /// Returns true when the tag contains a common architecture label.
72    #[must_use]
73    pub fn is_architecture_like(&self) -> bool {
74        architecture_token(self.as_str()).is_some()
75    }
76}
77
78impl AsRef<str> for OciTag {
79    fn as_ref(&self) -> &str {
80        self.as_str()
81    }
82}
83
84impl fmt::Display for OciTag {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        formatter.write_str(self.as_str())
87    }
88}
89
90impl FromStr for OciTag {
91    type Err = OciTagError;
92
93    fn from_str(value: &str) -> Result<Self, Self::Err> {
94        Self::new(value)
95    }
96}
97
98impl TryFrom<&str> for OciTag {
99    type Error = OciTagError;
100
101    fn try_from(value: &str) -> Result<Self, Self::Error> {
102        Self::new(value)
103    }
104}
105
106/// A version-looking OCI tag.
107#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub struct VersionTag(OciTag);
109
110impl VersionTag {
111    /// Creates a version-looking tag.
112    pub fn new(value: impl AsRef<str>) -> Result<Self, OciTagError> {
113        let tag = OciTag::new(value)?;
114        if tag.is_version_like() {
115            Ok(Self(tag))
116        } else {
117            Err(OciTagError::NotVersionLike)
118        }
119    }
120
121    /// Returns the tag text.
122    #[must_use]
123    pub fn as_str(&self) -> &str {
124        self.0.as_str()
125    }
126}
127
128impl fmt::Display for VersionTag {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter.write_str(self.as_str())
131    }
132}
133
134/// An architecture-looking OCI tag.
135#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
136pub struct ArchitectureTag(OciTag);
137
138impl ArchitectureTag {
139    /// Creates an architecture-looking tag.
140    pub fn new(value: impl AsRef<str>) -> Result<Self, OciTagError> {
141        let tag = OciTag::new(value)?;
142        if tag.is_architecture_like() {
143            Ok(Self(tag))
144        } else {
145            Err(OciTagError::NotArchitectureLike)
146        }
147    }
148
149    /// Returns the tag text.
150    #[must_use]
151    pub fn as_str(&self) -> &str {
152        self.0.as_str()
153    }
154}
155
156impl fmt::Display for ArchitectureTag {
157    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        formatter.write_str(self.as_str())
159    }
160}
161
162/// Returns true when text is a valid OCI tag.
163#[must_use]
164pub fn is_valid_oci_tag(value: impl AsRef<str>) -> bool {
165    validate_tag(value.as_ref().trim()).is_ok()
166}
167
168fn validate_tag(value: &str) -> Result<(), OciTagError> {
169    if value.is_empty() {
170        return Err(OciTagError::Empty);
171    }
172    if value.len() > 128 {
173        return Err(OciTagError::TooLong);
174    }
175    let mut chars = value.chars();
176    let Some(first) = chars.next() else {
177        return Err(OciTagError::Empty);
178    };
179    if !(first.is_ascii_alphanumeric() || first == '_') {
180        return Err(OciTagError::InvalidStart);
181    }
182    if chars.any(|character| {
183        !(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
184    }) {
185        return Err(OciTagError::InvalidCharacter);
186    }
187    Ok(())
188}
189
190fn is_version_like(value: &str) -> bool {
191    let value = value.strip_prefix('v').unwrap_or(value);
192    let core = value.split_once('-').map_or(value, |(core, _)| core);
193    let mut parts = core.split('.');
194    matches!(
195        (parts.next(), parts.next(), parts.next(), parts.next()),
196        (Some(major), Some(minor), Some(patch), None)
197            if is_digits(major) && is_digits(minor) && is_digits(patch)
198    )
199}
200
201fn architecture_token(value: &str) -> Option<&str> {
202    value.split(['-', '_', '.']).find(|part| {
203        matches!(
204            *part,
205            "amd64" | "arm64" | "arm" | "386" | "ppc64le" | "riscv64" | "s390x" | "wasm"
206        )
207    })
208}
209
210fn is_digits(value: &str) -> bool {
211    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::{ArchitectureTag, OciTag, OciTagError, VersionTag, is_valid_oci_tag};
217
218    #[test]
219    fn validates_and_classifies_tags() -> Result<(), Box<dyn std::error::Error>> {
220        let tag: OciTag = "v1.2.3-arm64".parse()?;
221
222        assert!(tag.is_version_like());
223        assert!(tag.is_architecture_like());
224        assert!(OciTag::latest().is_latest());
225        assert!(is_valid_oci_tag("_dev"));
226        assert_eq!(OciTag::new("-bad"), Err(OciTagError::InvalidStart));
227        assert_eq!(VersionTag::new("release"), Err(OciTagError::NotVersionLike));
228        assert_eq!(
229            ArchitectureTag::new("v1.2.3"),
230            Err(OciTagError::NotArchitectureLike)
231        );
232        Ok(())
233    }
234}