1#![cfg_attr(not(feature = "serde-deserialize"), 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}))?$"
120 )
121 .expect("Invalid regular expression for Docker image format");
122 }
123
124 if let Some(captures) = DOCKER_IMAGE_REGEX.captures(s) {
125 Ok(DockerImage {
126 registry: captures.name("registry").map(|m| m.as_str().to_string()),
127 name: captures
128 .name("name")
129 .ok_or(DockerImageError::InvalidFormat)?
130 .as_str()
131 .to_string(),
132 tag: captures.name("tag").map(|m| m.as_str().to_string()),
133 digest: captures.name("digest").map(|m| m.as_str().to_string()),
134 })
135 } else {
136 Err(DockerImageError::InvalidFormat)
137 }
138 }
139}
140
141impl TryFrom<String> for DockerImage {
142 type Error = DockerImageError;
143
144 fn try_from(value: String) -> Result<Self, Self::Error> {
145 value.parse()
146 }
147}
148
149impl TryFrom<&str> for DockerImage {
150 type Error = DockerImageError;
151
152 fn try_from(value: &str) -> Result<Self, Self::Error> {
153 value.parse()
154 }
155}
156
157impl DockerImage {
158 pub fn parse(image_str: &str) -> Result<Self, DockerImageError> {
171 Self::from_str(image_str)
172 }
173}
174
175#[cfg(feature = "serde-serialize")]
176impl serde::Serialize for DockerImage {
177 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178 where
179 S: serde::ser::Serializer,
180 {
181 serializer.serialize_str(&self.to_string())
182 }
183}
184
185#[cfg(feature = "serde-deserialize")]
186impl<'de> serde::Deserialize<'de> for DockerImage {
187 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
188 where
189 D: serde::de::Deserializer<'de>,
190 {
191 let docker_image_str = <String as serde::Deserialize>::deserialize(deserializer)?;
192 docker_image_str.parse().map_err(serde::de::Error::custom)
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use test_format::assert_display_fmt;
200
201 #[test]
202 fn test_trivial_name() {
203 let result = DockerImage::parse("nginx");
204 assert_eq!(
205 result,
206 Ok(DockerImage {
207 registry: None,
208 name: "nginx".to_string(),
209 tag: None,
210 digest: None,
211 })
212 );
213 }
214
215 #[test]
216 fn test_name_with_tag() {
217 let result = DockerImage::parse("nginx:latest");
218 assert_eq!(
219 result,
220 Ok(DockerImage {
221 registry: None,
222 name: "nginx".to_string(),
223 tag: Some("latest".to_string()),
224 digest: None,
225 })
226 );
227 }
228
229 #[test]
230 fn test_name_with_complex_tag() {
231 let result = DockerImage::parse("nginx:stable-alpine3.20-perl");
232 assert_eq!(
233 result,
234 Ok(DockerImage {
235 registry: None,
236 name: "nginx".to_string(),
237 tag: Some("stable-alpine3.20-perl".to_string()),
238 digest: None,
239 })
240 );
241 }
242
243 #[test]
244 fn test_registry_and_name() {
245 let result = DockerImage::parse("docker.io/nginx");
246 assert_eq!(
247 result,
248 Ok(DockerImage {
249 registry: Some("docker.io".to_string()),
250 name: "nginx".to_string(),
251 tag: None,
252 digest: None,
253 })
254 );
255 }
256
257 #[test]
258 fn test_registry_with_namespace() {
259 let result = DockerImage::parse("ghcr.io/nginx/nginx");
260 assert_eq!(
261 result,
262 Ok(DockerImage {
263 registry: Some("ghcr.io".to_string()),
264 name: "nginx/nginx".to_string(),
265 tag: None,
266 digest: None,
267 })
268 );
269 }
270
271 #[test]
272 fn test_name_with_digest() {
273 let result = DockerImage::parse(
274 "ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
275 );
276 assert_eq!(
277 result,
278 Ok(DockerImage {
279 registry: None,
280 name: "ubuntu".to_string(),
281 tag: None,
282 digest: Some(
283 "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
284 .to_string()
285 ),
286 })
287 );
288 }
289
290 #[test]
291 fn test_name_with_tag_and_digest() {
292 let result = DockerImage::parse(
293 "ubuntu:latest@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
294 );
295 assert_eq!(
296 result,
297 Ok(DockerImage {
298 registry: None,
299 name: "ubuntu".to_string(),
300 tag: Some("latest".to_string()),
301 digest: Some(
302 "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
303 .to_string()
304 ),
305 })
306 );
307 }
308
309 #[test]
310 fn test_registry_name_tag() {
311 let result = DockerImage::parse("registry.example.com/library/my-image:1.0.0");
312 assert_eq!(
313 result,
314 Ok(DockerImage {
315 registry: Some("registry.example.com".to_string()),
316 name: "library/my-image".to_string(),
317 tag: Some("1.0.0".to_string()),
318 digest: None,
319 })
320 );
321 }
322
323 #[test]
324 fn test_registry_name_digest() {
325 let result = DockerImage::parse(
326 "my-registry.local:5000/library/image-name@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
327 );
328 assert_eq!(
329 result,
330 Ok(DockerImage {
331 registry: Some("my-registry.local:5000".to_string()),
332 name: "library/image-name".to_string(),
333 tag: None,
334 digest: Some(
335 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
336 .to_string()
337 ),
338 })
339 );
340 }
341
342 #[test]
343 fn test_invalid_format() {
344 let result = DockerImage::parse("invalid@@sha256:wrong");
345 assert_eq!(result, Err(DockerImageError::InvalidFormat));
346 }
347
348 #[test]
349 fn test_invalid_characters_in_tag() {
350 let result = DockerImage::parse("nginx:lat@est");
351 assert_eq!(result, Err(DockerImageError::InvalidFormat));
352 }
353
354 #[test]
355 fn test_invalid_digest_format() {
356 let result = DockerImage::parse("ubuntu@sha256:not-a-hex-string");
357 assert_eq!(result, Err(DockerImageError::InvalidFormat));
358 }
359
360 #[test]
361 fn test_invalid_registry_format() {
362 let result = DockerImage::parse("http://registry.example.com/image-name");
363 assert_eq!(result, Err(DockerImageError::InvalidFormat));
364 }
365
366 #[test]
367 fn test_invalid_double_colons_in_tag() {
368 let result = DockerImage::parse("nginx::latest");
369 assert_eq!(result, Err(DockerImageError::InvalidFormat));
370 }
371
372 #[test]
373 fn test_missing_image_name_with_tag() {
374 let result = DockerImage::parse(":latest");
375 assert_eq!(result, Err(DockerImageError::InvalidFormat));
376 }
377
378 #[test]
379 fn test_missing_image_name_with_digest() {
380 let result = DockerImage::parse(
381 "@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
382 );
383 assert_eq!(result, Err(DockerImageError::InvalidFormat));
384 }
385
386 #[test]
387 fn test_extra_tag_components() {
388 let result = DockerImage::parse("my-image:1.0.0:latest");
389 assert_eq!(result, Err(DockerImageError::InvalidFormat));
390 }
391
392 #[test]
393 fn test_unicode_in_name() {
394 let result = DockerImage::parse("nginx🚀");
395 assert_eq!(result, Err(DockerImageError::InvalidFormat));
396 }
397
398 #[test]
399 fn test_unicode_in_registry() {
400 let result = DockerImage::parse("docker🚀.io/library/nginx");
401 assert_eq!(result, Err(DockerImageError::InvalidFormat));
402 }
403
404 #[test]
405 fn test_unicode_in_tag() {
406 let result = DockerImage::parse("nginx:lat🚀est");
407 assert_eq!(result, Err(DockerImageError::InvalidFormat));
408 }
409
410 #[test]
411 fn test_unicode_in_digest() {
412 let result = DockerImage::parse(
413 "nginx@sha256:deadbeef🚀1234567890abcdef1234567890abcdef1234567890abcdef1234",
414 );
415 assert_eq!(result, Err(DockerImageError::InvalidFormat));
416 }
417
418 #[test]
419 fn test_display_trivial_name() {
420 let image = DockerImage {
421 registry: None,
422 name: "nginx".to_string(),
423 tag: None,
424 digest: None,
425 };
426
427 assert_display_fmt!(image, "nginx");
428 }
429
430 #[test]
431 fn test_display_name_with_tag() {
432 let image = DockerImage {
433 registry: None,
434 name: "nginx".to_string(),
435 tag: Some("latest".to_string()),
436 digest: None,
437 };
438
439 assert_display_fmt!(image, "nginx:latest");
440 }
441
442 #[test]
443 fn test_display_name_with_digest() {
444 let image = DockerImage {
445 registry: None,
446 name: "ubuntu".to_string(),
447 tag: None,
448 digest: Some(
449 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
450 ),
451 };
452
453 assert_display_fmt!(
454 image,
455 "ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
456 );
457 }
458
459 #[test]
460 fn test_display_name_with_tag_and_digest() {
461 let image = DockerImage {
462 registry: None,
463 name: "ubuntu".to_string(),
464 tag: Some("latest".to_string()),
465 digest: Some(
466 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
467 ),
468 };
469
470 assert_display_fmt!(
471 image,
472 "ubuntu:latest@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
473 );
474 }
475
476 #[test]
477 fn test_display_registry_and_name() {
478 let image = DockerImage {
479 registry: Some("docker.io".to_string()),
480 name: "library/nginx".to_string(),
481 tag: None,
482 digest: None,
483 };
484
485 assert_display_fmt!(image, "docker.io/library/nginx");
486 }
487
488 #[test]
489 fn test_display_registry_name_with_tag() {
490 let image = DockerImage {
491 registry: Some("docker.io".to_string()),
492 name: "library/nginx".to_string(),
493 tag: Some("latest".to_string()),
494 digest: None,
495 };
496
497 assert_display_fmt!(image, "docker.io/library/nginx:latest");
498 }
499
500 #[test]
501 fn test_display_full_reference() {
502 let image = DockerImage {
503 registry: Some("my-registry.local:5000".to_string()),
504 name: "library/image-name".to_string(),
505 tag: Some("v1.0.0".to_string()),
506 digest: Some(
507 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
508 ),
509 };
510
511 assert_display_fmt!(
512 image,
513 "my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
514 );
515 }
516
517 #[test]
518 #[cfg(feature = "serde-serialize")]
519 fn test_serialize_dockerimage_to_json() {
520 use serde_json;
521
522 let image = DockerImage {
523 registry: Some("my-registry.local:5000".to_string()),
524 name: "library/image-name".to_string(),
525 tag: Some("v1.0.0".to_string()),
526 digest: Some(
527 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
528 .to_string(),
529 ),
530 };
531
532 let serialized = serde_json::to_string(&image).expect("Failed to serialize DockerImage");
533 assert_eq!(
534 serialized,
535 r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#
536 );
537 }
538
539 #[test]
540 #[cfg(feature = "serde-deserialize")]
541 fn test_deserialize_dockerimage_from_json() {
542 use serde_json;
543
544 let json = r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#;
545
546 let image: DockerImage =
547 serde_json::from_str(json).expect("Failed to deserialize DockerImage");
548 assert_eq!(
549 image,
550 DockerImage {
551 registry: Some("my-registry.local:5000".to_string()),
552 name: "library/image-name".to_string(),
553 tag: Some("v1.0.0".to_string()),
554 digest: Some(
555 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
556 .to_string()
557 ),
558 }
559 );
560 }
561}