oci_distribution/
reference.rs

1use std::convert::TryFrom;
2use std::error::Error;
3use std::fmt;
4use std::str::FromStr;
5
6use crate::regexp;
7
8/// NAME_TOTAL_LENGTH_MAX is the maximum total number of characters in a repository name.
9const NAME_TOTAL_LENGTH_MAX: usize = 255;
10
11const DOCKER_HUB_DOMAIN_LEGACY: &str = "index.docker.io";
12const DOCKER_HUB_DOMAIN: &str = "docker.io";
13const DOCKER_HUB_OFFICIAL_REPO_NAME: &str = "library";
14const DEFAULT_TAG: &str = "latest";
15
16/// Reasons that parsing a string as a Reference can fail.
17#[derive(Debug, PartialEq, Eq)]
18pub enum ParseError {
19    /// Invalid checksum digest format
20    DigestInvalidFormat,
21    /// Invalid checksum digest length
22    DigestInvalidLength,
23    /// Unsupported digest algorithm
24    DigestUnsupported,
25    /// Repository name must be lowercase
26    NameContainsUppercase,
27    /// Repository name must have at least one component
28    NameEmpty,
29    /// Repository name must not be more than NAME_TOTAL_LENGTH_MAX characters
30    NameTooLong,
31    /// Invalid reference format
32    ReferenceInvalidFormat,
33    /// Invalid tag format
34    TagInvalidFormat,
35}
36
37impl fmt::Display for ParseError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            ParseError::DigestInvalidFormat => write!(f, "invalid checksum digest format"),
41            ParseError::DigestInvalidLength => write!(f, "invalid checksum digest length"),
42            ParseError::DigestUnsupported => write!(f, "unsupported digest algorithm"),
43            ParseError::NameContainsUppercase => write!(f, "repository name must be lowercase"),
44            ParseError::NameEmpty => write!(f, "repository name must have at least one component"),
45            ParseError::NameTooLong => write!(
46                f,
47                "repository name must not be more than {} characters",
48                NAME_TOTAL_LENGTH_MAX
49            ),
50            ParseError::ReferenceInvalidFormat => write!(f, "invalid reference format"),
51            ParseError::TagInvalidFormat => write!(f, "invalid tag format"),
52        }
53    }
54}
55
56impl Error for ParseError {}
57
58/// Reference provides a general type to represent any way of referencing images within an OCI registry.
59///
60/// # Examples
61///
62/// Parsing a tagged image reference:
63///
64/// ```
65/// use oci_distribution::Reference;
66///
67/// let reference: Reference = "docker.io/library/hello-world:latest".parse().unwrap();
68///
69/// assert_eq!("docker.io/library/hello-world:latest", reference.whole().as_str());
70/// assert_eq!("docker.io", reference.registry());
71/// assert_eq!("library/hello-world", reference.repository());
72/// assert_eq!(Some("latest"), reference.tag());
73/// assert_eq!(None, reference.digest());
74/// ```
75#[derive(Clone, Hash, PartialEq, Eq, Debug)]
76pub struct Reference {
77    registry: String,
78    repository: String,
79    tag: Option<String>,
80    digest: Option<String>,
81}
82
83impl Reference {
84    /// Create a Reference with a registry, repository and tag.
85    pub fn with_tag(registry: String, repository: String, tag: String) -> Self {
86        Self {
87            registry,
88            repository,
89            tag: Some(tag),
90            digest: None,
91        }
92    }
93
94    /// Create a Reference with a registry, repository and digest.
95    pub fn with_digest(registry: String, repository: String, digest: String) -> Self {
96        Self {
97            registry,
98            repository,
99            tag: None,
100            digest: Some(digest),
101        }
102    }
103
104    /// Resolve the registry address of a given `Reference`.
105    ///
106    /// Some registries, such as docker.io, uses a different address for the actual
107    /// registry. This function implements such redirection.
108    pub fn resolve_registry(&self) -> &str {
109        let registry = self.registry();
110        match registry {
111            "docker.io" => "index.docker.io",
112            _ => registry,
113        }
114    }
115
116    /// registry returns the name of the registry.
117    pub fn registry(&self) -> &str {
118        &self.registry
119    }
120
121    /// repository returns the name of the repository.
122    pub fn repository(&self) -> &str {
123        &self.repository
124    }
125
126    /// tag returns the object's tag, if present.
127    pub fn tag(&self) -> Option<&str> {
128        self.tag.as_deref()
129    }
130
131    /// digest returns the object's digest, if present.
132    pub fn digest(&self) -> Option<&str> {
133        self.digest.as_deref()
134    }
135
136    /// full_name returns the full repository name and path.
137    fn full_name(&self) -> String {
138        if self.registry() == "" {
139            self.repository().to_string()
140        } else {
141            format!("{}/{}", self.registry(), self.repository())
142        }
143    }
144
145    /// whole returns the whole reference.
146    pub fn whole(&self) -> String {
147        let mut s = self.full_name();
148        if let Some(t) = self.tag() {
149            if !s.is_empty() {
150                s.push(':');
151            }
152            s.push_str(t);
153        }
154        if let Some(d) = self.digest() {
155            if !s.is_empty() {
156                s.push('@');
157            }
158            s.push_str(d);
159        }
160        s
161    }
162}
163
164impl fmt::Display for Reference {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        write!(f, "{}", self.whole())
167    }
168}
169
170impl FromStr for Reference {
171    type Err = ParseError;
172
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        Reference::try_from(s)
175    }
176}
177
178impl TryFrom<String> for Reference {
179    type Error = ParseError;
180
181    fn try_from(s: String) -> Result<Self, Self::Error> {
182        if s.is_empty() {
183            return Err(ParseError::NameEmpty);
184        }
185        lazy_static! {
186            static ref RE: regex::Regex = regexp::must_compile(regexp::REFERENCE_REGEXP);
187        };
188        let captures = match RE.captures(&s) {
189            Some(caps) => caps,
190            None => {
191                return Err(ParseError::ReferenceInvalidFormat);
192            }
193        };
194        let name = &captures[1];
195        let mut tag = captures.get(2).map(|m| m.as_str().to_owned());
196        let digest = captures.get(3).map(|m| m.as_str().to_owned());
197        if tag.is_none() && digest.is_none() {
198            tag = Some(DEFAULT_TAG.into());
199        }
200        let (registry, repository) = split_domain(name);
201        let reference = Reference {
202            registry,
203            repository,
204            tag,
205            digest,
206        };
207        if reference.repository().len() > NAME_TOTAL_LENGTH_MAX {
208            return Err(ParseError::NameTooLong);
209        }
210        // Digests much always be hex-encoded, ensuring that their hex portion will always be
211        // size*2
212        if reference.digest().is_some() {
213            let d = reference.digest().unwrap();
214            // FIXME: we should actually separate the algorithm from the digest
215            // using regular expressions. This won't hold up if we support an
216            // algorithm more or less than 6 characters like sha1024.
217            if d.len() < 8 {
218                return Err(ParseError::DigestInvalidFormat);
219            }
220            let algo = &d[0..6];
221            let digest = &d[7..];
222            match algo {
223                "sha256" => {
224                    if digest.len() != 64 {
225                        return Err(ParseError::DigestInvalidLength);
226                    }
227                }
228                "sha384" => {
229                    if digest.len() != 96 {
230                        return Err(ParseError::DigestInvalidLength);
231                    }
232                }
233                "sha512" => {
234                    if digest.len() != 128 {
235                        return Err(ParseError::DigestInvalidLength);
236                    }
237                }
238                _ => return Err(ParseError::DigestUnsupported),
239            }
240        }
241        Ok(reference)
242    }
243}
244
245impl TryFrom<&str> for Reference {
246    type Error = ParseError;
247    fn try_from(string: &str) -> Result<Self, Self::Error> {
248        TryFrom::try_from(string.to_owned())
249    }
250}
251
252impl From<Reference> for String {
253    fn from(reference: Reference) -> Self {
254        reference.whole()
255    }
256}
257
258/// Splits a repository name to domain and remotename string.
259/// If no valid domain is found, the default domain is used. Repository name
260/// needs to be already validated before.
261///
262/// This function is a Rust rewrite of the official Go code used by Docker:
263/// https://github.com/distribution/distribution/blob/41a0452eea12416aaf01bceb02a924871e964c67/reference/normalize.go#L87-L104
264fn split_domain(name: &str) -> (String, String) {
265    let mut domain: String;
266    let mut remainder: String;
267
268    match name.split_once('/') {
269        None => {
270            domain = DOCKER_HUB_DOMAIN.into();
271            remainder = name.into();
272        }
273        Some((left, right)) => {
274            if !(left.contains('.') || left.contains(':')) && left != "localhost" {
275                domain = DOCKER_HUB_DOMAIN.into();
276                remainder = name.into();
277            } else {
278                domain = left.into();
279                remainder = right.into();
280            }
281        }
282    }
283    if domain == DOCKER_HUB_DOMAIN_LEGACY {
284        domain = DOCKER_HUB_DOMAIN.into();
285    }
286    if domain == DOCKER_HUB_DOMAIN && !remainder.contains('/') {
287        remainder = format!("{}/{}", DOCKER_HUB_OFFICIAL_REPO_NAME, remainder);
288    }
289
290    (domain, remainder)
291}
292
293#[cfg(test)]
294mod test {
295    use super::*;
296
297    mod parse {
298        use super::*;
299        use rstest::rstest;
300
301        #[rstest(input, registry, repository, tag, digest, whole,
302            case("busybox", "docker.io", "library/busybox", Some("latest"), None, "docker.io/library/busybox:latest"),
303            case("test.com:tag", "docker.io", "library/test.com", Some("tag"), None, "docker.io/library/test.com:tag"),
304            case("test.com:5000", "docker.io", "library/test.com", Some("5000"), None, "docker.io/library/test.com:5000"),
305            case("test.com/repo:tag", "test.com", "repo", Some("tag"), None, "test.com/repo:tag"),
306            case("test:5000/repo", "test:5000", "repo", Some("latest"), None, "test:5000/repo:latest"),
307            case("test:5000/repo:tag", "test:5000", "repo", Some("tag"), None, "test:5000/repo:tag"),
308            case("test:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "test:5000", "repo", None, Some("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), "test:5000/repo@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
309            case("test:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "test:5000", "repo", Some("tag"), Some("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), "test:5000/repo:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
310            case("lowercase:Uppercase", "docker.io", "library/lowercase", Some("Uppercase"), None, "docker.io/library/lowercase:Uppercase"),
311            case("sub-dom1.foo.com/bar/baz/quux", "sub-dom1.foo.com", "bar/baz/quux", Some("latest"), None, "sub-dom1.foo.com/bar/baz/quux:latest"),
312            case("sub-dom1.foo.com/bar/baz/quux:some-long-tag", "sub-dom1.foo.com", "bar/baz/quux", Some("some-long-tag"), None, "sub-dom1.foo.com/bar/baz/quux:some-long-tag"),
313            case("b.gcr.io/test.example.com/my-app:test.example.com", "b.gcr.io", "test.example.com/my-app", Some("test.example.com"), None, "b.gcr.io/test.example.com/my-app:test.example.com"),
314            // ☃.com in punycode
315            case("xn--n3h.com/myimage:xn--n3h.com", "xn--n3h.com", "myimage", Some("xn--n3h.com"), None, "xn--n3h.com/myimage:xn--n3h.com"),
316            // 🐳.com in punycode
317            case("xn--7o8h.com/myimage:xn--7o8h.com@sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "xn--7o8h.com", "myimage", Some("xn--7o8h.com"), Some("sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), "xn--7o8h.com/myimage:xn--7o8h.com@sha512:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
318            case("foo_bar.com:8080", "docker.io", "library/foo_bar.com", Some("8080"), None, "docker.io/library/foo_bar.com:8080" ),
319            case("foo/foo_bar.com:8080", "docker.io", "foo/foo_bar.com", Some("8080"), None, "docker.io/foo/foo_bar.com:8080"),
320            case("opensuse/leap:15.3", "docker.io", "opensuse/leap", Some("15.3"), None, "docker.io/opensuse/leap:15.3"),
321        )]
322        fn parse_good_reference(
323            input: &str,
324            registry: &str,
325            repository: &str,
326            tag: Option<&str>,
327            digest: Option<&str>,
328            whole: &str,
329        ) {
330            println!("input: {}", input);
331            let reference = Reference::try_from(input).expect("could not parse reference");
332            println!("{} -> {:?}", input, reference);
333            assert_eq!(registry, reference.registry());
334            assert_eq!(repository, reference.repository());
335            assert_eq!(tag, reference.tag());
336            assert_eq!(digest, reference.digest());
337            assert_eq!(whole, reference.whole());
338        }
339
340        #[rstest(input, err,
341            case("", ParseError::NameEmpty),
342            case(":justtag", ParseError::ReferenceInvalidFormat),
343            case("@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", ParseError::ReferenceInvalidFormat),
344            case("repo@sha256:ffffffffffffffffffffffffffffffffff", ParseError::DigestInvalidLength),
345            case("validname@invaliddigest:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", ParseError::DigestUnsupported),
346            // FIXME: should really pass a ParseError::NameContainsUppercase, but "invalid format" is good enough for now.
347            case("Uppercase:tag", ParseError::ReferenceInvalidFormat),
348            // FIXME: "Uppercase" is incorrectly handled as a domain-name here, and therefore passes.
349            // https://github.com/docker/distribution/blob/master/reference/reference_test.go#L104-L109
350            // case("Uppercase/lowercase:tag", ParseError::NameContainsUppercase),
351            // FIXME: should really pass a ParseError::NameContainsUppercase, but "invalid format" is good enough for now.
352            case("test:5000/Uppercase/lowercase:tag", ParseError::ReferenceInvalidFormat),
353            case("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ParseError::NameTooLong),
354            case("aa/asdf$$^/aa", ParseError::ReferenceInvalidFormat)
355        )]
356        fn parse_bad_reference(input: &str, err: ParseError) {
357            assert_eq!(Reference::try_from(input).unwrap_err(), err)
358        }
359    }
360}