1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
14#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
15#[serde(try_from = "String", into = "String")]
16#[cfg_attr(feature = "typescript", ts(type = "string"))]
17pub struct ImageReference(String);
18
19#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
21#[error("{0}")]
22pub struct InvalidImageReference(&'static str);
23
24impl ImageReference {
25 const NAME_LENGTH: usize = 255;
27 const TAG_LENGTH: usize = 128;
29
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33
34 pub fn sandbox_name(&self) -> String {
37 let name_and_tag = self.0.split('@').next().unwrap_or_default();
38 let name_and_tag = name_and_tag.rsplit('/').next().unwrap_or_default();
39 let last_part = name_and_tag.split(':').next().unwrap_or_default();
40 let label: String = last_part
43 .chars()
44 .map(|c| if c == '.' || c == '_' { '-' } else { c })
45 .take(63)
46 .collect();
47 label.trim_end_matches('-').to_owned()
48 }
49
50 fn check(text: &str) -> Result<(), InvalidImageReference> {
52 if text.is_empty() {
53 return Err(InvalidImageReference("an image is required"));
54 }
55 let (name_and_tag, digest) = match text.split_once('@') {
56 Some((name_and_tag, digest)) => (name_and_tag, Some(digest)),
57 None => (text, None),
58 };
59 let last_slash = name_and_tag.rfind('/');
60 let (name, tag) = match name_and_tag.rfind(':') {
61 Some(colon) if last_slash.is_none_or(|slash| colon > slash) => {
62 (&name_and_tag[..colon], Some(&name_and_tag[colon + 1..]))
63 }
64 _ => (name_and_tag, None),
65 };
66 Self::check_name(name)?;
67 if let Some(tag) = tag {
68 Self::check_tag(tag)?;
69 }
70 if let Some(digest) = digest {
71 Self::check_digest(digest)?;
72 }
73 Ok(())
74 }
75
76 fn check_name(name: &str) -> Result<(), InvalidImageReference> {
80 if name.is_empty() {
81 return Err(InvalidImageReference("the image's name is missing"));
82 }
83 if name.len() > Self::NAME_LENGTH {
84 return Err(InvalidImageReference(
85 "the image's name is longer than 255 characters",
86 ));
87 }
88 let path = match name.split_once('/') {
89 Some((first, rest))
90 if first.contains(['.', ':'])
91 || first == "localhost"
92 || first.chars().any(|c| c.is_ascii_uppercase()) =>
93 {
94 Self::check_domain(first)?;
95 rest
96 }
97 _ => name,
98 };
99 path.split('/').try_for_each(Self::check_path_component)
100 }
101
102 fn check_path_component(component: &str) -> Result<(), InvalidImageReference> {
105 const INVALID: InvalidImageReference = InvalidImageReference(
106 "each part of the image's name is lowercase letters and digits, joined by '.', '_', '__' or '-'",
107 );
108 let is_alphanumeric = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
109 let mut rest = component;
110 loop {
111 let run = rest.find(|c| !is_alphanumeric(c)).unwrap_or(rest.len());
112 if run == 0 {
113 return Err(INVALID);
114 }
115 rest = &rest[run..];
116 let separator = if rest.starts_with("__") {
117 2
118 } else if rest.starts_with(['.', '_']) {
119 1
120 } else if rest.starts_with('-') {
121 rest.find(|c| c != '-').unwrap_or(rest.len())
122 } else if rest.is_empty() {
123 return Ok(());
124 } else {
125 return Err(INVALID);
126 };
127 rest = &rest[separator..];
128 }
129 }
130
131 fn check_domain(domain: &str) -> Result<(), InvalidImageReference> {
133 let (host, port) = match domain.split_once(':') {
134 Some((host, port)) => (host, Some(port)),
135 None => (domain, None),
136 };
137 let label_is_valid = |label: &str| {
138 !label.is_empty()
139 && !label.starts_with('-')
140 && !label.ends_with('-')
141 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
142 };
143 if !host.split('.').all(label_is_valid) {
144 return Err(InvalidImageReference(
145 "the image's registry isn't a host name",
146 ));
147 }
148 if let Some(port) = port
149 && (port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()))
150 {
151 return Err(InvalidImageReference(
152 "the image's registry port isn't a number",
153 ));
154 }
155 Ok(())
156 }
157
158 fn check_tag(tag: &str) -> Result<(), InvalidImageReference> {
160 let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_';
161 let mut chars = tag.chars();
162 let valid = tag.len() <= Self::TAG_LENGTH
163 && chars.next().is_some_and(is_word)
164 && chars.all(|c| is_word(c) || c == '.' || c == '-');
165 if valid {
166 Ok(())
167 } else {
168 Err(InvalidImageReference(
169 "the tag is up to 128 letters, digits, '_', '.' and '-', not starting with '.' or '-'",
170 ))
171 }
172 }
173
174 fn check_digest(digest: &str) -> Result<(), InvalidImageReference> {
176 let (hex, length) = match digest.split_once(':') {
177 Some(("sha256", hex)) => (hex, 64),
178 Some(("sha512", hex)) => (hex, 128),
179 _ => {
180 return Err(InvalidImageReference(
181 "the digest isn't sha256:<hex> or sha512:<hex>",
182 ));
183 }
184 };
185 if hex.len() == length && hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
186 Ok(())
187 } else {
188 Err(InvalidImageReference(
189 "the digest's hex is the wrong length, or not lowercase hex",
190 ))
191 }
192 }
193}
194
195impl FromStr for ImageReference {
196 type Err = InvalidImageReference;
197
198 fn from_str(text: &str) -> Result<Self, Self::Err> {
199 Self::check(text)?;
200 Ok(Self(text.to_owned()))
201 }
202}
203
204impl TryFrom<String> for ImageReference {
205 type Error = InvalidImageReference;
206
207 fn try_from(text: String) -> Result<Self, Self::Error> {
208 Self::check(&text)?;
209 Ok(Self(text))
210 }
211}
212
213impl From<ImageReference> for String {
214 fn from(reference: ImageReference) -> Self {
215 reference.0
216 }
217}
218
219impl fmt::Display for ImageReference {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 f.write_str(&self.0)
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn accepts_references_docker_accepts() {
231 let sha256 = format!("sha256:{}", "a1".repeat(32));
232 for reference in [
233 "alpine".to_owned(),
234 "alpine:3.20".to_owned(),
235 "library/alpine".to_owned(),
236 "docker.io/library/alpine:3.20".to_owned(),
237 "ghcr.io/owner/name".to_owned(),
238 "localhost/name".to_owned(),
239 "localhost:5000/name:latest".to_owned(),
240 "registry.example.com:443/a/b_c__d-e.f---g:v1.2-rc_3".to_owned(),
241 format!("alpine@{sha256}"),
242 format!("alpine:3.20@{sha256}"),
243 ] {
244 assert!(reference.parse::<ImageReference>().is_ok(), "{reference}");
245 }
246 }
247
248 #[test]
249 fn a_sandbox_is_named_after_its_repository() {
250 let sha256 = format!("sha256:{}", "a1".repeat(32));
251 for (reference, name) in [
252 ("alpine".to_owned(), "alpine"),
253 ("python:3.12".to_owned(), "python"),
254 ("localhost:5000/name:latest".to_owned(), "name"),
255 (
256 "registry.example.com:443/a/b_c__d-e.f---g:v1".to_owned(),
257 "b-c--d-e-f---g",
258 ),
259 (format!("alpine:3.20@{sha256}"), "alpine"),
260 (format!("{}-b", "a".repeat(62)), &"a".repeat(62)),
261 ] {
262 let reference: ImageReference = reference.parse().unwrap();
263 assert_eq!(reference.sandbox_name(), name, "{reference}");
264 }
265 }
266
267 #[test]
268 fn refuses_what_isnt_a_reference() {
269 for reference in [
270 "",
271 "--volume=/:/host",
272 "-alpine",
273 "Alpine",
274 "alpine:",
275 "alpine:-rc",
276 ":3.20",
277 "a//b",
278 "a/",
279 "/a",
280 "a b",
281 "a___b",
282 "a.-b",
283 "alpine@sha256:abc",
284 "alpine@md5:0123456789abcdef0123456789abcdef",
285 "-registry.io/a",
286 "registry.io:port/a",
287 ] {
288 assert!(
289 reference.parse::<ImageReference>().is_err(),
290 "{reference:?}"
291 );
292 }
293 }
294
295 #[test]
296 fn deserializing_checks() {
297 assert!(serde_json::from_str::<ImageReference>(r#""alpine:3.20""#).is_ok());
298 assert!(serde_json::from_str::<ImageReference>(r#""--dir=/""#).is_err());
299 }
300}