Skip to main content

use_docker_image/
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 a Docker image reference is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerImageReferenceError {
10    /// The reference was empty after trimming.
11    Empty,
12    /// The image path or repository name was not accepted by this crate.
13    InvalidName,
14    /// A tag marker was present but the tag text was invalid.
15    InvalidTag,
16    /// A digest marker was present but the digest text was invalid.
17    InvalidDigest,
18}
19
20impl fmt::Display for DockerImageReferenceError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Empty => formatter.write_str("Docker image reference cannot be empty"),
24            Self::InvalidName => formatter.write_str("invalid Docker image name"),
25            Self::InvalidTag => formatter.write_str("invalid Docker image tag"),
26            Self::InvalidDigest => formatter.write_str("invalid Docker image digest"),
27        }
28    }
29}
30
31impl Error for DockerImageReferenceError {}
32
33/// A conservatively parsed Docker image reference.
34#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct DockerImageReference {
36    value: String,
37    registry: Option<String>,
38    path: String,
39    repository: String,
40    tag: Option<String>,
41    digest: Option<String>,
42}
43
44impl DockerImageReference {
45    /// Parses a Docker image reference.
46    pub fn parse(value: impl AsRef<str>) -> Result<Self, DockerImageReferenceError> {
47        parse_reference(value.as_ref())
48    }
49
50    /// Returns the original normalized reference text.
51    #[must_use]
52    pub fn as_str(&self) -> &str {
53        &self.value
54    }
55
56    /// Returns the registry host when one was present.
57    #[must_use]
58    pub fn registry(&self) -> Option<&str> {
59        self.registry.as_deref()
60    }
61
62    /// Returns the image path after the optional registry.
63    #[must_use]
64    pub fn path(&self) -> &str {
65        &self.path
66    }
67
68    /// Returns the slash-separated namespace before the repository name.
69    #[must_use]
70    pub fn namespace(&self) -> Option<&str> {
71        self.path.rsplit_once('/').map(|(namespace, _)| namespace)
72    }
73
74    /// Returns the repository name.
75    #[must_use]
76    pub fn repository(&self) -> &str {
77        &self.repository
78    }
79
80    /// Returns the optional tag.
81    #[must_use]
82    pub fn tag(&self) -> Option<&str> {
83        self.tag.as_deref()
84    }
85
86    /// Returns the optional digest.
87    #[must_use]
88    pub fn digest(&self) -> Option<&str> {
89        self.digest.as_deref()
90    }
91}
92
93impl AsRef<str> for DockerImageReference {
94    fn as_ref(&self) -> &str {
95        self.as_str()
96    }
97}
98
99impl fmt::Display for DockerImageReference {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str(self.as_str())
102    }
103}
104
105impl FromStr for DockerImageReference {
106    type Err = DockerImageReferenceError;
107
108    fn from_str(value: &str) -> Result<Self, Self::Err> {
109        Self::parse(value)
110    }
111}
112
113impl TryFrom<&str> for DockerImageReference {
114    type Error = DockerImageReferenceError;
115
116    fn try_from(value: &str) -> Result<Self, Self::Error> {
117        Self::parse(value)
118    }
119}
120
121fn parse_reference(value: &str) -> Result<DockerImageReference, DockerImageReferenceError> {
122    let trimmed = value.trim();
123    if trimmed.is_empty() {
124        return Err(DockerImageReferenceError::Empty);
125    }
126    if trimmed.chars().any(char::is_whitespace) {
127        return Err(DockerImageReferenceError::InvalidName);
128    }
129
130    let (without_digest, digest) = match trimmed.split_once('@') {
131        Some((name, digest)) => {
132            validate_digest(digest)?;
133            (name, Some(digest.to_string()))
134        },
135        None => (trimmed, None),
136    };
137
138    let slash_index = without_digest.rfind('/');
139    let colon_index = without_digest.rfind(':');
140    let (name_part, tag) = match colon_index {
141        Some(index) if slash_index.is_none_or(|slash| index > slash) => {
142            let tag = &without_digest[index + 1..];
143            validate_tag(tag)?;
144            (&without_digest[..index], Some(tag.to_string()))
145        },
146        _ => (without_digest, None),
147    };
148
149    let (registry, path) = split_registry(name_part);
150    validate_path(path)?;
151    let repository = path
152        .rsplit_once('/')
153        .map_or(path, |(_, repository)| repository)
154        .to_string();
155
156    Ok(DockerImageReference {
157        value: trimmed.to_string(),
158        registry: registry.map(str::to_string),
159        path: path.to_string(),
160        repository,
161        tag,
162        digest,
163    })
164}
165
166fn split_registry(value: &str) -> (Option<&str>, &str) {
167    let Some((first, rest)) = value.split_once('/') else {
168        return (None, value);
169    };
170    if first.contains('.') || first.contains(':') || first == "localhost" {
171        (Some(first), rest)
172    } else {
173        (None, value)
174    }
175}
176
177fn validate_path(value: &str) -> Result<(), DockerImageReferenceError> {
178    if value.is_empty()
179        || value
180            .split('/')
181            .any(|component| !is_valid_component(component))
182    {
183        Err(DockerImageReferenceError::InvalidName)
184    } else {
185        Ok(())
186    }
187}
188
189fn is_valid_component(value: &str) -> bool {
190    !value.is_empty()
191        && value.bytes().all(|byte| {
192            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
193        })
194        && value
195            .bytes()
196            .next()
197            .is_some_and(|byte| byte.is_ascii_alphanumeric())
198        && value
199            .bytes()
200            .last()
201            .is_some_and(|byte| byte.is_ascii_alphanumeric())
202}
203
204fn validate_tag(value: &str) -> Result<(), DockerImageReferenceError> {
205    if value.is_empty()
206        || value.len() > 128
207        || !value
208            .bytes()
209            .next()
210            .is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
211        || !value
212            .bytes()
213            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
214    {
215        Err(DockerImageReferenceError::InvalidTag)
216    } else {
217        Ok(())
218    }
219}
220
221fn validate_digest(value: &str) -> Result<(), DockerImageReferenceError> {
222    let Some((algorithm, digest)) = value.split_once(':') else {
223        return Err(DockerImageReferenceError::InvalidDigest);
224    };
225    if algorithm.is_empty()
226        || digest.is_empty()
227        || !algorithm
228            .bytes()
229            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
230        || !digest
231            .bytes()
232            .all(|byte| byte.is_ascii_hexdigit() || matches!(byte, b'_' | b'.' | b'-'))
233    {
234        Err(DockerImageReferenceError::InvalidDigest)
235    } else {
236        Ok(())
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{DockerImageReference, DockerImageReferenceError};
243
244    #[test]
245    fn parses_image_reference_components() -> Result<(), Box<dyn std::error::Error>> {
246        let reference: DockerImageReference = "ghcr.io/rustuse/app:0.1.0".parse()?;
247
248        assert_eq!(reference.registry(), Some("ghcr.io"));
249        assert_eq!(reference.namespace(), Some("rustuse"));
250        assert_eq!(reference.repository(), "app");
251        assert_eq!(reference.tag(), Some("0.1.0"));
252        assert_eq!(reference.digest(), None);
253        assert_eq!(
254            DockerImageReference::parse("Bad/Name"),
255            Err(DockerImageReferenceError::InvalidName)
256        );
257        Ok(())
258    }
259}