Skip to main content

a3s_box_runtime/oci/
reference.rs

1//! OCI image reference parsing.
2//!
3//! Parses image references like `ghcr.io/a3s-box/code:v0.1.0` into structured components.
4
5use a3s_box_core::error::{BoxError, Result};
6
7/// Default registry when none is specified.
8const DEFAULT_REGISTRY: &str = "docker.io";
9
10/// Default tag when none is specified.
11const DEFAULT_TAG: &str = "latest";
12
13/// Parsed OCI image reference.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ImageReference {
16    /// Registry hostname (e.g., "ghcr.io", "docker.io")
17    pub registry: String,
18    /// Repository path (e.g., "library/nginx", "a3s-box/code")
19    pub repository: String,
20    /// Tag (e.g., "latest", "v0.1.0")
21    pub tag: Option<String>,
22    /// Digest (e.g., "sha256:abc123...")
23    pub digest: Option<String>,
24}
25
26impl ImageReference {
27    /// Parse an image reference string.
28    ///
29    /// Supports formats:
30    /// - `nginx` → docker.io/library/nginx:latest
31    /// - `nginx:1.25` → docker.io/library/nginx:1.25
32    /// - `myuser/myimage` → docker.io/myuser/myimage:latest
33    /// - `ghcr.io/org/image:tag` → ghcr.io/org/image:tag
34    /// - `ghcr.io/org/image@sha256:abc...` → ghcr.io/org/image@sha256:abc...
35    pub fn parse(reference: &str) -> Result<Self> {
36        let reference = reference.trim();
37        if reference.is_empty() {
38            return Err(BoxError::OciImageError("Empty image reference".to_string()));
39        }
40
41        // Split off digest first (@ separator)
42        let (name_tag, digest) = if let Some(at_pos) = reference.rfind('@') {
43            let digest_part = &reference[at_pos + 1..];
44            if !digest_part.contains(':') {
45                return Err(BoxError::OciImageError(format!(
46                    "Invalid digest format in reference '{}': expected algorithm:hex",
47                    reference
48                )));
49            }
50            (&reference[..at_pos], Some(digest_part.to_string()))
51        } else {
52            (reference, None)
53        };
54
55        // Split tag (: separator, but only after the last /)
56        let (name, tag) = if digest.is_some() {
57            // If we have a digest, tag is in name_tag only if explicitly present
58            if let Some(slash_pos) = name_tag.rfind('/') {
59                let after_slash = &name_tag[slash_pos + 1..];
60                if let Some(colon_pos) = after_slash.rfind(':') {
61                    let tag = &after_slash[colon_pos + 1..];
62                    let name = &name_tag[..slash_pos + 1 + colon_pos];
63                    (name.to_string(), Some(tag.to_string()))
64                } else {
65                    (name_tag.to_string(), None)
66                }
67            } else if let Some(colon_pos) = name_tag.rfind(':') {
68                let tag = &name_tag[colon_pos + 1..];
69                let name = &name_tag[..colon_pos];
70                (name.to_string(), Some(tag.to_string()))
71            } else {
72                (name_tag.to_string(), None)
73            }
74        } else {
75            // No digest — split on last colon after last slash
76            if let Some(slash_pos) = name_tag.rfind('/') {
77                let after_slash = &name_tag[slash_pos + 1..];
78                if let Some(colon_pos) = after_slash.rfind(':') {
79                    let tag = &after_slash[colon_pos + 1..];
80                    let name = &name_tag[..slash_pos + 1 + colon_pos];
81                    (name.to_string(), Some(tag.to_string()))
82                } else {
83                    (name_tag.to_string(), None)
84                }
85            } else if let Some(colon_pos) = name_tag.rfind(':') {
86                // No slash: the colon always introduces a tag. A bare
87                // `registry:port` with no repository is not a valid image
88                // reference, so numeric tags (`redis:7`, `node:18`,
89                // `postgres:16`) must NOT be mistaken for a port.
90                let tag = &name_tag[colon_pos + 1..];
91                let name = &name_tag[..colon_pos];
92                (name.to_string(), Some(tag.to_string()))
93            } else {
94                (name_tag.to_string(), None)
95            }
96        };
97
98        // Determine registry and repository
99        let (registry, repository) = Self::split_registry_repository(&name)?;
100
101        // Apply default tag if no tag and no digest
102        let tag = if tag.is_none() && digest.is_none() {
103            Some(DEFAULT_TAG.to_string())
104        } else {
105            tag
106        };
107
108        Ok(ImageReference {
109            registry,
110            repository,
111            tag,
112            digest,
113        })
114    }
115
116    /// Split a name into registry and repository components.
117    fn split_registry_repository(name: &str) -> Result<(String, String)> {
118        // Check if the first component looks like a registry hostname
119        // (contains a dot or colon, or is "localhost")
120        if let Some(slash_pos) = name.find('/') {
121            let first = &name[..slash_pos];
122            if first.contains('.') || first.contains(':') || first == "localhost" {
123                let registry = first.to_string();
124                let repo = name[slash_pos + 1..].to_string();
125                if repo.is_empty() {
126                    return Err(BoxError::OciImageError(format!(
127                        "Empty repository in reference '{}'",
128                        name
129                    )));
130                }
131                return Ok((registry, repo));
132            }
133        }
134
135        // No registry detected — use default
136        let repository = if name.contains('/') {
137            name.to_string()
138        } else {
139            // Single name like "nginx" → "library/nginx" for Docker Hub
140            format!("library/{}", name)
141        };
142
143        Ok((DEFAULT_REGISTRY.to_string(), repository))
144    }
145
146    /// Get the full reference string.
147    pub fn full_reference(&self) -> String {
148        let mut s = format!("{}/{}", self.registry, self.repository);
149        if let Some(ref tag) = self.tag {
150            s.push(':');
151            s.push_str(tag);
152        }
153        if let Some(ref digest) = self.digest {
154            s.push('@');
155            s.push_str(digest);
156        }
157        s
158    }
159}
160
161impl std::fmt::Display for ImageReference {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "{}", self.full_reference())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_parse_simple_name() {
173        let r = ImageReference::parse("nginx").unwrap();
174        assert_eq!(r.registry, "docker.io");
175        assert_eq!(r.repository, "library/nginx");
176        assert_eq!(r.tag, Some("latest".to_string()));
177        assert_eq!(r.digest, None);
178    }
179
180    #[test]
181    fn test_parse_name_with_tag() {
182        let r = ImageReference::parse("nginx:1.25").unwrap();
183        assert_eq!(r.registry, "docker.io");
184        assert_eq!(r.repository, "library/nginx");
185        assert_eq!(r.tag, Some("1.25".to_string()));
186        assert_eq!(r.digest, None);
187    }
188
189    #[test]
190    fn test_parse_user_repo() {
191        let r = ImageReference::parse("myuser/myimage").unwrap();
192        assert_eq!(r.registry, "docker.io");
193        assert_eq!(r.repository, "myuser/myimage");
194        assert_eq!(r.tag, Some("latest".to_string()));
195    }
196
197    #[test]
198    fn test_parse_user_repo_with_tag() {
199        let r = ImageReference::parse("myuser/myimage:v1.0").unwrap();
200        assert_eq!(r.registry, "docker.io");
201        assert_eq!(r.repository, "myuser/myimage");
202        assert_eq!(r.tag, Some("v1.0".to_string()));
203    }
204
205    #[test]
206    fn test_parse_custom_registry() {
207        let r = ImageReference::parse("ghcr.io/a3s-box/code:v0.1.0").unwrap();
208        assert_eq!(r.registry, "ghcr.io");
209        assert_eq!(r.repository, "a3s-box/code");
210        assert_eq!(r.tag, Some("v0.1.0".to_string()));
211    }
212
213    #[test]
214    fn test_parse_custom_registry_no_tag() {
215        let r = ImageReference::parse("ghcr.io/a3s-box/code").unwrap();
216        assert_eq!(r.registry, "ghcr.io");
217        assert_eq!(r.repository, "a3s-box/code");
218        assert_eq!(r.tag, Some("latest".to_string()));
219    }
220
221    #[test]
222    fn test_parse_digest_only() {
223        let r = ImageReference::parse(
224            "ghcr.io/a3s-box/code@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
225        )
226        .unwrap();
227        assert_eq!(r.registry, "ghcr.io");
228        assert_eq!(r.repository, "a3s-box/code");
229        assert_eq!(r.tag, None);
230        assert_eq!(
231            r.digest,
232            Some(
233                "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
234                    .to_string()
235            )
236        );
237    }
238
239    #[test]
240    fn test_parse_tag_and_digest() {
241        let r =
242            ImageReference::parse("ghcr.io/a3s-box/code:v0.1.0@sha256:abcdef1234567890").unwrap();
243        assert_eq!(r.registry, "ghcr.io");
244        assert_eq!(r.repository, "a3s-box/code");
245        assert_eq!(r.tag, Some("v0.1.0".to_string()));
246        assert_eq!(r.digest, Some("sha256:abcdef1234567890".to_string()));
247    }
248
249    #[test]
250    fn test_parse_localhost_registry() {
251        let r = ImageReference::parse("localhost/myimage:test").unwrap();
252        assert_eq!(r.registry, "localhost");
253        assert_eq!(r.repository, "myimage");
254        assert_eq!(r.tag, Some("test".to_string()));
255    }
256
257    #[test]
258    fn test_parse_registry_with_port() {
259        let r = ImageReference::parse("myregistry.io:5000/myimage:v1").unwrap();
260        assert_eq!(r.registry, "myregistry.io:5000");
261        assert_eq!(r.repository, "myimage");
262        assert_eq!(r.tag, Some("v1".to_string()));
263    }
264
265    #[test]
266    fn test_parse_empty_reference() {
267        let r = ImageReference::parse("");
268        assert!(r.is_err());
269    }
270
271    #[test]
272    fn test_parse_whitespace_reference() {
273        let r = ImageReference::parse("  nginx  ").unwrap();
274        assert_eq!(r.repository, "library/nginx");
275    }
276
277    #[test]
278    fn test_parse_invalid_digest() {
279        let r = ImageReference::parse("nginx@invaliddigest");
280        assert!(r.is_err());
281    }
282
283    #[test]
284    fn test_full_reference() {
285        let r = ImageReference::parse("ghcr.io/a3s-box/code:v0.1.0").unwrap();
286        assert_eq!(r.full_reference(), "ghcr.io/a3s-box/code:v0.1.0");
287    }
288
289    #[test]
290    fn test_full_reference_with_digest() {
291        let r = ImageReference {
292            registry: "ghcr.io".to_string(),
293            repository: "a3s-box/code".to_string(),
294            tag: Some("v0.1.0".to_string()),
295            digest: Some("sha256:abc123".to_string()),
296        };
297        assert_eq!(
298            r.full_reference(),
299            "ghcr.io/a3s-box/code:v0.1.0@sha256:abc123"
300        );
301    }
302
303    #[test]
304    fn test_display() {
305        let r = ImageReference::parse("nginx:1.25").unwrap();
306        assert_eq!(format!("{}", r), "docker.io/library/nginx:1.25");
307    }
308
309    /// Regression: a purely numeric tag with no registry (`redis:7`, `node:18`)
310    /// must be parsed as a tag, not mistaken for a registry port.
311    #[test]
312    fn test_numeric_tag_is_not_a_port() {
313        for (input, repo, tag) in [
314            ("redis:7", "library/redis", "7"),
315            ("node:18", "library/node", "18"),
316            ("postgres:16", "library/postgres", "16"),
317            ("mgmt:1", "library/mgmt", "1"),
318        ] {
319            let r = ImageReference::parse(input).unwrap();
320            assert_eq!(r.registry, "docker.io", "registry for {input}");
321            assert_eq!(r.repository, repo, "repository for {input}");
322            assert_eq!(r.tag.as_deref(), Some(tag), "tag for {input}");
323        }
324        // A real registry:port/repo:tag still splits correctly.
325        let r = ImageReference::parse("localhost:5000/app:2").unwrap();
326        assert_eq!(r.registry, "localhost:5000");
327        assert_eq!(r.repository, "app");
328        assert_eq!(r.tag.as_deref(), Some("2"));
329    }
330
331    #[test]
332    fn test_deep_repository_path() {
333        let r = ImageReference::parse("ghcr.io/org/sub/image:v1").unwrap();
334        assert_eq!(r.registry, "ghcr.io");
335        assert_eq!(r.repository, "org/sub/image");
336        assert_eq!(r.tag, Some("v1".to_string()));
337    }
338}