1use a3s_box_core::error::{BoxError, Result};
6
7const DEFAULT_REGISTRY: &str = "docker.io";
9
10const DEFAULT_TAG: &str = "latest";
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ImageReference {
16 pub registry: String,
18 pub repository: String,
20 pub tag: Option<String>,
22 pub digest: Option<String>,
24}
25
26impl ImageReference {
27 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 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 let (name, tag) = if digest.is_some() {
57 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 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 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 let (registry, repository) = Self::split_registry_repository(&name)?;
100
101 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 fn split_registry_repository(name: &str) -> Result<(String, String)> {
118 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 let repository = if name.contains('/') {
137 name.to_string()
138 } else {
139 format!("library/{}", name)
141 };
142
143 Ok((DEFAULT_REGISTRY.to_string(), repository))
144 }
145
146 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 #[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 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}