1#![no_std]
9#![forbid(unsafe_code)]
10
11extern crate alloc;
12use alloc::string::{String, ToString};
13
14use core::fmt;
15use core::str::FromStr;
16use lazy_static::lazy_static;
17use regex::Regex;
18
19#[derive(Debug, PartialEq)]
38pub struct DockerImage {
39 pub registry: Option<String>,
41 pub name: String,
43 pub tag: Option<String>,
45 pub digest: Option<String>,
47}
48
49impl fmt::Display for DockerImage {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 if let Some(registry) = &self.registry {
63 write!(f, "{}/", registry)?;
64 }
65 write!(f, "{}", self.name)?;
66 if let Some(tag) = &self.tag {
67 write!(f, ":{}", tag)?;
68 }
69 if let Some(digest) = &self.digest {
70 write!(f, "@{}", digest)?;
71 }
72 Ok(())
73 }
74}
75
76#[derive(Debug, PartialEq)]
78pub enum DockerImageError {
79 InvalidFormat,
81}
82
83impl fmt::Display for DockerImageError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 DockerImageError::InvalidFormat => write!(f, "Invalid Docker image format"),
87 }
88 }
89}
90
91impl core::error::Error for DockerImageError {}
92
93impl FromStr for DockerImage {
94 type Err = DockerImageError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 lazy_static! {
117 static ref DOCKER_IMAGE_REGEX: Regex = Regex::new(
118 r"^(?:(?P<registry>[a-z0-9]+(?:[._-][a-z0-9]+)*\.[a-z]{2,}(?::\d+)?)/)?(?P<name>[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*)(?::(?P<tag>[a-zA-Z0-9._-]+))?(?:@(?P<digest>[a-z0-9]+:[a-fA-F0-9]{64}))?$"
119 )
120 .unwrap();
121 }
122
123 if let Some(captures) = DOCKER_IMAGE_REGEX.captures(s) {
124 Ok(DockerImage {
125 registry: captures.name("registry").map(|m| m.as_str().to_string()),
126 name: captures
127 .name("name")
128 .ok_or(DockerImageError::InvalidFormat)?
129 .as_str()
130 .to_string(),
131 tag: captures.name("tag").map(|m| m.as_str().to_string()),
132 digest: captures.name("digest").map(|m| m.as_str().to_string()),
133 })
134 } else {
135 Err(DockerImageError::InvalidFormat)
136 }
137 }
138}
139
140impl DockerImage {
141 pub fn parse(image_str: &str) -> Result<Self, DockerImageError> {
154 Self::from_str(image_str)
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use test_format::assert_display_fmt;
162
163 #[test]
164 fn test_trivial_name() {
165 let result = DockerImage::parse("nginx");
166 assert_eq!(
167 result,
168 Ok(DockerImage {
169 registry: None,
170 name: "nginx".to_string(),
171 tag: None,
172 digest: None,
173 })
174 );
175 }
176
177 #[test]
178 fn test_name_with_tag() {
179 let result = DockerImage::parse("nginx:latest");
180 assert_eq!(
181 result,
182 Ok(DockerImage {
183 registry: None,
184 name: "nginx".to_string(),
185 tag: Some("latest".to_string()),
186 digest: None,
187 })
188 );
189 }
190
191 #[test]
192 fn test_name_with_complex_tag() {
193 let result = DockerImage::parse("nginx:stable-alpine3.20-perl");
194 assert_eq!(
195 result,
196 Ok(DockerImage {
197 registry: None,
198 name: "nginx".to_string(),
199 tag: Some("stable-alpine3.20-perl".to_string()),
200 digest: None,
201 })
202 );
203 }
204
205 #[test]
206 fn test_registry_and_name() {
207 let result = DockerImage::parse("docker.io/nginx");
208 assert_eq!(
209 result,
210 Ok(DockerImage {
211 registry: Some("docker.io".to_string()),
212 name: "nginx".to_string(),
213 tag: None,
214 digest: None,
215 })
216 );
217 }
218
219 #[test]
220 fn test_registry_with_namespace() {
221 let result = DockerImage::parse("ghcr.io/nginx/nginx");
222 assert_eq!(
223 result,
224 Ok(DockerImage {
225 registry: Some("ghcr.io".to_string()),
226 name: "nginx/nginx".to_string(),
227 tag: None,
228 digest: None,
229 })
230 );
231 }
232
233 #[test]
234 fn test_name_with_digest() {
235 let result = DockerImage::parse(
236 "ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
237 );
238 assert_eq!(
239 result,
240 Ok(DockerImage {
241 registry: None,
242 name: "ubuntu".to_string(),
243 tag: None,
244 digest: Some(
245 "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
246 .to_string()
247 ),
248 })
249 );
250 }
251
252 #[test]
253 fn test_name_with_tag_and_digest() {
254 let result = DockerImage::parse(
255 "ubuntu:latest@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
256 );
257 assert_eq!(
258 result,
259 Ok(DockerImage {
260 registry: None,
261 name: "ubuntu".to_string(),
262 tag: Some("latest".to_string()),
263 digest: Some(
264 "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
265 .to_string()
266 ),
267 })
268 );
269 }
270
271 #[test]
272 fn test_registry_name_tag() {
273 let result = DockerImage::parse("registry.example.com/library/my-image:1.0.0");
274 assert_eq!(
275 result,
276 Ok(DockerImage {
277 registry: Some("registry.example.com".to_string()),
278 name: "library/my-image".to_string(),
279 tag: Some("1.0.0".to_string()),
280 digest: None,
281 })
282 );
283 }
284
285 #[test]
286 fn test_registry_name_digest() {
287 let result = DockerImage::parse(
288 "my-registry.local:5000/library/image-name@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
289 );
290 assert_eq!(
291 result,
292 Ok(DockerImage {
293 registry: Some("my-registry.local:5000".to_string()),
294 name: "library/image-name".to_string(),
295 tag: None,
296 digest: Some(
297 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
298 .to_string()
299 ),
300 })
301 );
302 }
303
304 #[test]
305 fn test_invalid_format() {
306 let result = DockerImage::parse("invalid@@sha256:wrong");
307 assert_eq!(result, Err(DockerImageError::InvalidFormat));
308 }
309
310 #[test]
311 fn test_invalid_characters_in_tag() {
312 let result = DockerImage::parse("nginx:lat@est");
313 assert_eq!(result, Err(DockerImageError::InvalidFormat));
314 }
315
316 #[test]
317 fn test_invalid_digest_format() {
318 let result = DockerImage::parse("ubuntu@sha256:not-a-hex-string");
319 assert_eq!(result, Err(DockerImageError::InvalidFormat));
320 }
321
322 #[test]
323 fn test_invalid_registry_format() {
324 let result = DockerImage::parse("http://registry.example.com/image-name");
325 assert_eq!(result, Err(DockerImageError::InvalidFormat));
326 }
327
328 #[test]
329 fn test_invalid_double_colons_in_tag() {
330 let result = DockerImage::parse("nginx::latest");
331 assert_eq!(result, Err(DockerImageError::InvalidFormat));
332 }
333
334 #[test]
335 fn test_missing_image_name_with_tag() {
336 let result = DockerImage::parse(":latest");
337 assert_eq!(result, Err(DockerImageError::InvalidFormat));
338 }
339
340 #[test]
341 fn test_missing_image_name_with_digest() {
342 let result = DockerImage::parse(
343 "@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
344 );
345 assert_eq!(result, Err(DockerImageError::InvalidFormat));
346 }
347
348 #[test]
349 fn test_extra_tag_components() {
350 let result = DockerImage::parse("my-image:1.0.0:latest");
351 assert_eq!(result, Err(DockerImageError::InvalidFormat));
352 }
353
354 #[test]
355 fn test_unicode_in_name() {
356 let result = DockerImage::parse("nginx🚀");
357 assert_eq!(result, Err(DockerImageError::InvalidFormat));
358 }
359
360 #[test]
361 fn test_unicode_in_registry() {
362 let result = DockerImage::parse("docker🚀.io/library/nginx");
363 assert_eq!(result, Err(DockerImageError::InvalidFormat));
364 }
365
366 #[test]
367 fn test_unicode_in_tag() {
368 let result = DockerImage::parse("nginx:lat🚀est");
369 assert_eq!(result, Err(DockerImageError::InvalidFormat));
370 }
371
372 #[test]
373 fn test_unicode_in_digest() {
374 let result = DockerImage::parse(
375 "nginx@sha256:deadbeef🚀1234567890abcdef1234567890abcdef1234567890abcdef1234",
376 );
377 assert_eq!(result, Err(DockerImageError::InvalidFormat));
378 }
379
380 #[test]
381 fn test_display_trivial_name() {
382 let image = DockerImage {
383 registry: None,
384 name: "nginx".to_string(),
385 tag: None,
386 digest: None,
387 };
388
389 assert_display_fmt!(image, "nginx");
390 }
391
392 #[test]
393 fn test_display_name_with_tag() {
394 let image = DockerImage {
395 registry: None,
396 name: "nginx".to_string(),
397 tag: Some("latest".to_string()),
398 digest: None,
399 };
400
401 assert_display_fmt!(image, "nginx:latest");
402 }
403
404 #[test]
405 fn test_display_name_with_digest() {
406 let image = DockerImage {
407 registry: None,
408 name: "ubuntu".to_string(),
409 tag: None,
410 digest: Some(
411 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
412 ),
413 };
414
415 assert_display_fmt!(
416 image,
417 "ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
418 );
419 }
420
421 #[test]
422 fn test_display_name_with_tag_and_digest() {
423 let image = DockerImage {
424 registry: None,
425 name: "ubuntu".to_string(),
426 tag: Some("latest".to_string()),
427 digest: Some(
428 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
429 ),
430 };
431
432 assert_display_fmt!(
433 image,
434 "ubuntu:latest@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
435 );
436 }
437
438 #[test]
439 fn test_display_registry_and_name() {
440 let image = DockerImage {
441 registry: Some("docker.io".to_string()),
442 name: "library/nginx".to_string(),
443 tag: None,
444 digest: None,
445 };
446
447 assert_display_fmt!(image, "docker.io/library/nginx");
448 }
449
450 #[test]
451 fn test_display_registry_name_with_tag() {
452 let image = DockerImage {
453 registry: Some("docker.io".to_string()),
454 name: "library/nginx".to_string(),
455 tag: Some("latest".to_string()),
456 digest: None,
457 };
458
459 assert_display_fmt!(image, "docker.io/library/nginx:latest");
460 }
461
462 #[test]
463 fn test_display_full_reference() {
464 let image = DockerImage {
465 registry: Some("my-registry.local:5000".to_string()),
466 name: "library/image-name".to_string(),
467 tag: Some("v1.0.0".to_string()),
468 digest: Some(
469 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
470 ),
471 };
472
473 assert_display_fmt!(
474 image,
475 "my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
476 );
477 }
478}