Skip to main content

boxferry_model/
image.rs

1//! Tolerant real-world container image references.
2
3use crate::ModelError;
4
5/// A preserved image reference with optional tag and digest components.
6///
7/// Unlike a strict OCI-only parser, this type deliberately accepts the common
8/// `name:tag@algorithm:digest` form used by Docker Compose and Podman.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct ImageReference {
11    authored: String,
12    repository: String,
13    tag: Option<String>,
14    digest: Option<String>,
15}
16
17impl ImageReference {
18    /// Parses a preserved image reference without normalizing its spelling.
19    ///
20    /// # Errors
21    ///
22    /// Returns a [`ModelError`] for empty components, multiple digest
23    /// separators, or embedded NUL bytes.
24    pub fn parse(authored: impl Into<String>) -> Result<Self, ModelError> {
25        let authored = authored.into();
26        validate_component("image reference", &authored)?;
27
28        let mut digest_parts = authored.split('@');
29        let name_and_tag = digest_parts.next().unwrap_or_default();
30        let digest = digest_parts.next();
31        if digest_parts.next().is_some() {
32            return Err(ModelError::InvalidImageReference(
33                "multiple `@` digest separators are not supported",
34            ));
35        }
36        if let Some(value) = digest {
37            validate_component("image digest", value)?;
38        }
39
40        let last_slash = name_and_tag.rfind('/');
41        let last_colon = name_and_tag.rfind(':');
42        let has_tag = last_colon.is_some_and(|colon| last_slash.is_none_or(|slash| colon > slash));
43        let (repository, tag) = if has_tag {
44            let colon = last_colon.unwrap_or_default();
45            (&name_and_tag[..colon], Some(&name_and_tag[colon + 1..]))
46        } else {
47            (name_and_tag, None)
48        };
49        validate_component("image repository", repository)?;
50        if let Some(value) = tag {
51            validate_component("image tag", value)?;
52        }
53
54        let repository = repository.to_owned();
55        let tag = tag.map(str::to_owned);
56        let digest = digest.map(str::to_owned);
57        Ok(Self {
58            authored,
59            repository,
60            tag,
61            digest,
62        })
63    }
64
65    /// Returns the complete authored spelling.
66    #[must_use]
67    pub fn as_str(&self) -> &str {
68        &self.authored
69    }
70
71    /// Returns the repository/name component without tag or digest.
72    #[must_use]
73    pub fn repository(&self) -> &str {
74        &self.repository
75    }
76
77    /// Returns the optional tag without its colon.
78    #[must_use]
79    pub fn tag(&self) -> Option<&str> {
80        self.tag.as_deref()
81    }
82
83    /// Returns the optional digest without its `@` separator.
84    #[must_use]
85    pub fn digest(&self) -> Option<&str> {
86        self.digest.as_deref()
87    }
88}
89
90fn validate_component(kind: &'static str, value: &str) -> Result<(), ModelError> {
91    if value.is_empty() {
92        return Err(ModelError::EmptyValue(kind));
93    }
94    if value.contains('\0') {
95        return Err(ModelError::ContainsNul(kind));
96    }
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::ImageReference;
103
104    #[test]
105    fn accepts_registry_port_tag_and_digest_together() -> Result<(), String> {
106        let image = ImageReference::parse("registry.example:5000/team/app:1.2@sha256:abcd")
107            .map_err(|error| error.to_string())?;
108        assert_eq!(image.repository(), "registry.example:5000/team/app");
109        assert_eq!(image.tag(), Some("1.2"));
110        assert_eq!(image.digest(), Some("sha256:abcd"));
111        assert_eq!(image.as_str(), "registry.example:5000/team/app:1.2@sha256:abcd");
112        Ok(())
113    }
114
115    #[test]
116    fn does_not_treat_a_registry_port_as_a_tag() -> Result<(), String> {
117        let image =
118            ImageReference::parse("registry.example:5000/team/app@sha256:abcd").map_err(|error| error.to_string())?;
119        assert_eq!(image.repository(), "registry.example:5000/team/app");
120        assert_eq!(image.tag(), None);
121        Ok(())
122    }
123}