1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerImageReferenceError {
10 Empty,
12 InvalidName,
14 InvalidTag,
16 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#[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 pub fn parse(value: impl AsRef<str>) -> Result<Self, DockerImageReferenceError> {
47 parse_reference(value.as_ref())
48 }
49
50 #[must_use]
52 pub fn as_str(&self) -> &str {
53 &self.value
54 }
55
56 #[must_use]
58 pub fn registry(&self) -> Option<&str> {
59 self.registry.as_deref()
60 }
61
62 #[must_use]
64 pub fn path(&self) -> &str {
65 &self.path
66 }
67
68 #[must_use]
70 pub fn namespace(&self) -> Option<&str> {
71 self.path.rsplit_once('/').map(|(namespace, _)| namespace)
72 }
73
74 #[must_use]
76 pub fn repository(&self) -> &str {
77 &self.repository
78 }
79
80 #[must_use]
82 pub fn tag(&self) -> Option<&str> {
83 self.tag.as_deref()
84 }
85
86 #[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}