Skip to main content

dvm_api/
image_reference.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6/// A Docker image reference, as `docker run` takes it: `alpine`,
7/// `alpine:3.20`, `ghcr.io/owner/name:tag`, `name@sha256:<hex>`.
8///
9/// Parsed by the grammar of distribution's `reference` package (IPv6 hosts
10/// aside), so what passes is a reference and never anything else: no
11/// spaces, and no leading `-` that `dvm run` would take for an option.
12#[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/// Why a text isn't an image reference.
20#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
21#[error("{0}")]
22pub struct InvalidImageReference(&'static str);
23
24impl ImageReference {
25    /// distribution's limit on a name, its repository's path with its domain.
26    const NAME_LENGTH: usize = 255;
27    /// distribution's limit on a tag.
28    const TAG_LENGTH: usize = 128;
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33
34    /// The name of a sandbox made from it and given none: its repository's
35    /// last part, as a DNS label (`ghcr.io/owner/my_app:1` is `my-app`).
36    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        // A part is lowercase letters and digits joined by '.', '_' and '-',
41        // so the label starts with one and has no other characters.
42        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    /// `name[:tag][@digest]`, the tag after the name's last `/`.
51    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    /// `[domain/]path`: the first component is a domain when there is more
77    /// than one and it has a `.` or a `:`, is `localhost`, or has an
78    /// uppercase letter, as Docker splits them.
79    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    /// Lowercase letters and digits, runs of them joined by one `.`, one or
103    /// two `_`, or any number of `-`.
104    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    /// `host[:port]`, the host's labels letters, digits and inner `-`.
132    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    /// A letter, digit or `_`, then up to 127 of those, `.` and `-`.
159    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    /// `sha256:` and 64 hex digits, or `sha512:` and 128.
175    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}