1use std::fmt;
2use std::str::FromStr;
3use thiserror::Error;
4
5#[derive(Debug, Error)]
7#[non_exhaustive]
8pub enum TransportConversionError {
9 #[error("Invalid transport: {0}")]
10 InvalidTransport(Box<str>),
11 #[error("Missing // in docker:// in {0}")]
12 MissingDockerSlashes(Box<str>),
13 #[error("Missing ':' in imgref")]
14 MissingColon,
15}
16
17#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum ImageReferenceError {
21 #[error("Invalid transport: {0}")]
22 InvalidTransport(Box<str>),
23 #[error("Missing // in docker:// in {0}")]
24 MissingDockerSlashes(Box<str>),
25 #[error("Missing ':' in {0}")]
26 MissingColon(Box<str>),
27 #[error("Invalid empty name in {0}")]
28 EmptyName(Box<str>),
29}
30
31#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
33pub enum Transport {
34 Registry,
36 OciDir,
38 OciArchive,
40 DockerArchive,
42 ContainerStorage,
44 Dir,
46 DockerDaemon,
48}
49
50impl fmt::Display for Transport {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 match self {
56 Transport::Registry => f.write_str("docker://"),
57 Transport::OciDir => f.write_str("oci:"),
58 Transport::OciArchive => f.write_str("oci-archive:"),
59 Transport::DockerArchive => f.write_str("docker-archive:"),
60 Transport::ContainerStorage => f.write_str("containers-storage:"),
61 Transport::Dir => f.write_str("dir:"),
62 Transport::DockerDaemon => f.write_str("docker-daemon:"),
63 }
64 }
65}
66
67impl TryFrom<&str> for Transport {
68 type Error = TransportConversionError;
69
70 fn try_from(imgref: &str) -> Result<Self, TransportConversionError> {
76 let (transport_name, rest) = match imgref.find(':') {
77 Some(colon_pos) => (&imgref[..colon_pos], &imgref[colon_pos..]),
78 None => (imgref, ""),
80 };
81
82 let transport = match transport_name {
83 "registry" => Transport::Registry,
84 "oci" => Transport::OciDir,
85 "oci-archive" => Transport::OciArchive,
86 "docker-archive" => Transport::DockerArchive,
87 "containers-storage" => Transport::ContainerStorage,
88 "dir" => Transport::Dir,
89 "docker-daemon" => Transport::DockerDaemon,
90 "docker" => {
91 if rest.starts_with("://") {
93 Transport::Registry
94 } else {
95 return Err(
96 TransportConversionError::MissingDockerSlashes(imgref.into()).into(),
97 );
98 }
99 }
100 prefix => {
101 return Err(TransportConversionError::InvalidTransport(prefix.into()).into());
102 }
103 };
104
105 Ok(transport)
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Hash)]
129pub struct ImageReference {
130 pub transport: Transport,
132 pub name: String,
134}
135
136impl ImageReference {
137 pub fn new(transport: Transport, name: impl Into<String>) -> Self {
147 Self {
148 transport,
149 name: name.into(),
150 }
151 }
152
153 pub fn new_registry(reference: oci_spec::distribution::Reference) -> Self {
165 Self {
166 transport: Transport::Registry,
167 name: reference.whole(),
168 }
169 }
170
171 pub fn try_new_registry(name: &str) -> Result<Self, oci_spec::distribution::ParseError> {
186 let reference: oci_spec::distribution::Reference = name.parse()?;
187 Ok(Self::new_registry(reference))
188 }
189
190 pub fn as_registry(
213 &self,
214 ) -> Option<Result<oci_spec::distribution::Reference, oci_spec::distribution::ParseError>> {
215 if self.transport == Transport::Registry {
216 Some(self.name.parse())
217 } else {
218 None
219 }
220 }
221
222 pub fn as_containers_storage(&self) -> Option<ContainersStorageRef<'_>> {
248 if self.transport == Transport::ContainerStorage {
249 Some(ContainersStorageRef::new(&self.name))
250 } else {
251 None
252 }
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ContainersStorageRef<'a> {
268 store_spec: Option<&'a str>,
269 image: &'a str,
270}
271
272impl<'a> ContainersStorageRef<'a> {
273 fn new(name: &'a str) -> Self {
274 if let Some(rest) = name.strip_prefix('[') {
276 if let Some(bracket_end) = rest.find(']') {
277 return Self {
278 store_spec: Some(&rest[..bracket_end]),
279 image: &rest[bracket_end + 1..],
280 };
281 }
282 }
283 Self {
284 store_spec: None,
285 image: name,
286 }
287 }
288
289 pub fn store_spec(&self) -> Option<&'a str> {
293 self.store_spec
294 }
295
296 pub fn image(&self) -> &'a str {
298 self.image
299 }
300
301 pub fn image_for_skopeo(&self) -> &'a str {
307 self.image.strip_prefix("sha256:").unwrap_or(self.image)
308 }
309
310 pub fn to_image_reference(&self, normalize: bool) -> ImageReference {
314 let image = if normalize {
315 self.image_for_skopeo()
316 } else {
317 self.image
318 };
319
320 let name = match self.store_spec {
321 Some(spec) => format!("[{}]{}", spec, image),
322 None => image.to_string(),
323 };
324
325 ImageReference::new(Transport::ContainerStorage, name)
326 }
327}
328
329impl TryFrom<&str> for ImageReference {
330 type Error = ImageReferenceError;
331
332 fn try_from(value: &str) -> Result<Self, ImageReferenceError> {
347 let (transport_name, mut name) = value
348 .split_once(':')
349 .ok_or_else(|| ImageReferenceError::MissingColon(value.into()))?;
350
351 let transport = match transport_name {
352 "registry" | "docker" => Transport::Registry,
353 "oci" => Transport::OciDir,
354 "oci-archive" => Transport::OciArchive,
355 "docker-archive" => Transport::DockerArchive,
356 "containers-storage" => Transport::ContainerStorage,
357 "dir" => Transport::Dir,
358 "docker-daemon" => Transport::DockerDaemon,
359 prefix => {
360 return Err(ImageReferenceError::InvalidTransport(prefix.into()));
361 }
362 };
363
364 if transport_name == "docker" {
366 name = name
367 .strip_prefix("//")
368 .ok_or_else(|| ImageReferenceError::MissingDockerSlashes(value.into()))?;
369 }
370
371 if name.is_empty() {
372 return Err(ImageReferenceError::EmptyName(value.into()));
373 }
374
375 Ok(Self {
376 transport,
377 name: name.to_string(),
378 })
379 }
380}
381
382impl FromStr for ImageReference {
383 type Err = ImageReferenceError;
384
385 fn from_str(s: &str) -> Result<Self, Self::Err> {
386 Self::try_from(s)
387 }
388}
389
390impl fmt::Display for ImageReference {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 write!(f, "{}{}", self.transport, self.name)
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_transport_from_str() {
402 assert!(matches!(
404 Transport::try_from("registry:example.com/image"),
405 Ok(Transport::Registry)
406 ));
407 assert!(matches!(
408 Transport::try_from("oci:/path/to/image"),
409 Ok(Transport::OciDir)
410 ));
411 assert!(matches!(
412 Transport::try_from("oci-archive:/path/to/archive.tar"),
413 Ok(Transport::OciArchive)
414 ));
415 assert!(matches!(
416 Transport::try_from("docker-archive:/path/to/archive.tar"),
417 Ok(Transport::DockerArchive)
418 ));
419 assert!(matches!(
420 Transport::try_from("containers-storage:example.com/image"),
421 Ok(Transport::ContainerStorage)
422 ));
423 assert!(matches!(
424 Transport::try_from("dir:/path/to/directory"),
425 Ok(Transport::Dir)
426 ));
427 assert!(matches!(
428 Transport::try_from("docker-daemon:example.com/image"),
429 Ok(Transport::DockerDaemon)
430 ));
431
432 assert!(matches!(
434 Transport::try_from("docker://example.com/image"),
435 Ok(Transport::Registry)
436 ));
437
438 assert!(matches!(
440 Transport::try_from("example.com:8080/image"),
441 Err(TransportConversionError::InvalidTransport(_))
442 ));
443 assert!(matches!(
444 Transport::try_from("example.com/image:tag"),
445 Err(TransportConversionError::InvalidTransport(_))
446 ));
447
448 assert!(matches!(
450 Transport::try_from("unknown:/path"),
451 Err(TransportConversionError::InvalidTransport(_))
452 ));
453 }
454
455 #[test]
456 fn test_transport_error_cases() {
457 assert!(matches!(
459 Transport::try_from("docker.io/library/hello-world"),
460 Err(TransportConversionError::InvalidTransport(_))
461 ));
462 assert!(matches!(
463 Transport::try_from("example.com/image"),
464 Err(TransportConversionError::InvalidTransport(_))
465 ));
466
467 assert!(matches!(
469 Transport::try_from("invalid:example.com/image"),
470 Err(TransportConversionError::InvalidTransport(_))
471 ));
472 assert!(matches!(
473 Transport::try_from("ftp:example.com/image"),
474 Err(TransportConversionError::InvalidTransport(_))
475 ));
476
477 assert!(matches!(
479 Transport::try_from("docker:example.com/image"),
480 Err(TransportConversionError::MissingDockerSlashes(_))
481 ));
482
483 assert!(matches!(
485 Transport::try_from(""),
486 Err(TransportConversionError::InvalidTransport(_))
487 ));
488
489 assert!(matches!(
491 Transport::try_from(":"),
492 Err(TransportConversionError::InvalidTransport(_))
493 ));
494 }
495
496 #[test]
497 fn test_bare_transport_parsing() {
498 assert!(matches!(
500 Transport::try_from("registry"),
501 Ok(Transport::Registry)
502 ));
503 assert!(matches!(Transport::try_from("oci"), Ok(Transport::OciDir)));
504 assert!(matches!(
505 Transport::try_from("oci-archive"),
506 Ok(Transport::OciArchive)
507 ));
508 assert!(matches!(
509 Transport::try_from("docker-archive"),
510 Ok(Transport::DockerArchive)
511 ));
512 assert!(matches!(
513 Transport::try_from("containers-storage"),
514 Ok(Transport::ContainerStorage)
515 ));
516 assert!(matches!(Transport::try_from("dir"), Ok(Transport::Dir)));
517 assert!(matches!(
518 Transport::try_from("docker-daemon"),
519 Ok(Transport::DockerDaemon)
520 ));
521
522 assert!(matches!(
524 Transport::try_from("docker"),
525 Err(TransportConversionError::MissingDockerSlashes(_))
526 ));
527
528 assert!(matches!(
530 Transport::try_from("unknown"),
531 Err(TransportConversionError::InvalidTransport(_))
532 ));
533 }
534
535 #[test]
536 fn test_transport_edge_cases() {
537 assert!(matches!(
539 Transport::try_from("registry:"),
540 Ok(Transport::Registry)
541 ));
542 assert!(matches!(Transport::try_from("oci:"), Ok(Transport::OciDir)));
543
544 assert!(matches!(
546 Transport::try_from("docker://"),
547 Ok(Transport::Registry)
548 ));
549
550 assert!(matches!(
552 Transport::try_from("registry:example.com:8080/image"),
553 Ok(Transport::Registry)
554 ));
555 assert!(matches!(
556 Transport::try_from("oci:/path/with:colon/image"),
557 Ok(Transport::OciDir)
558 ));
559 }
560
561 #[test]
562 fn test_error_display() {
563 let err = TransportConversionError::InvalidTransport("unknown".into());
564 assert_eq!(err.to_string(), "Invalid transport: unknown");
565
566 let err = TransportConversionError::MissingDockerSlashes("docker:example.com".into());
567 assert_eq!(
568 err.to_string(),
569 "Missing // in docker:// in docker:example.com"
570 );
571 }
572
573 #[test]
574 fn test_transport_display() {
575 assert_eq!(Transport::Registry.to_string(), "docker://");
577 assert_eq!(Transport::OciDir.to_string(), "oci:");
578 assert_eq!(Transport::OciArchive.to_string(), "oci-archive:");
579 assert_eq!(Transport::DockerArchive.to_string(), "docker-archive:");
580 assert_eq!(
581 Transport::ContainerStorage.to_string(),
582 "containers-storage:"
583 );
584 assert_eq!(Transport::Dir.to_string(), "dir:");
585 assert_eq!(Transport::DockerDaemon.to_string(), "docker-daemon:");
586 }
587
588 #[test]
589 fn test_transport_roundtrip() {
590 let transports = [
592 Transport::OciDir,
593 Transport::OciArchive,
594 Transport::DockerArchive,
595 Transport::ContainerStorage,
596 Transport::Dir,
597 Transport::DockerDaemon,
598 ];
599
600 for original_transport in transports {
601 let transport_str = original_transport.to_string();
602 let parsed = Transport::try_from(transport_str.as_str()).unwrap();
603 assert_eq!(
604 parsed, original_transport,
605 "Failed roundtrip for {original_transport:?}"
606 );
607 }
608
609 let registry_str = Transport::Registry.to_string();
611 let parsed = Transport::try_from(registry_str.as_str()).unwrap();
612 assert!(matches!(parsed, Transport::Registry));
613 }
614
615 #[test]
616 fn test_imagereference() {
617 let valid_cases: &[(&str, Transport, &str)] = &[
619 ("oci:somedir", Transport::OciDir, "somedir"),
620 ("dir:/some/dir/blah", Transport::Dir, "/some/dir/blah"),
621 (
622 "oci-archive:/path/to/foo.ociarchive",
623 Transport::OciArchive,
624 "/path/to/foo.ociarchive",
625 ),
626 (
627 "docker-archive:/path/to/foo.dockerarchive",
628 Transport::DockerArchive,
629 "/path/to/foo.dockerarchive",
630 ),
631 (
632 "containers-storage:localhost/someimage:blah",
633 Transport::ContainerStorage,
634 "localhost/someimage:blah",
635 ),
636 (
637 "docker://quay.io/exampleos/blah:tag",
638 Transport::Registry,
639 "quay.io/exampleos/blah:tag",
640 ),
641 (
642 "docker-daemon:myimage:latest",
643 Transport::DockerDaemon,
644 "myimage:latest",
645 ),
646 (
648 "registry:quay.io/exampleos/blah",
649 Transport::Registry,
650 "quay.io/exampleos/blah",
651 ),
652 ];
653
654 for (input, expected_transport, expected_name) in valid_cases {
655 let ir: ImageReference = (*input).try_into().unwrap();
656 assert_eq!(ir.transport, *expected_transport, "transport for {input}");
657 assert_eq!(ir.name, *expected_name, "name for {input}");
658 }
659
660 let invalid_cases: &[&str] = &[
662 "", "foo://bar", "docker:blah", "registry:", "docker://", "foo:bar", "nocolon", ];
670
671 for input in invalid_cases {
672 assert!(
673 ImageReference::try_from(*input).is_err(),
674 "should fail: {input}"
675 );
676 }
677 }
678
679 #[test]
680 fn test_imagereference_roundtrip() {
681 let roundtrip_cases: &[&str] = &[
683 "oci:somedir",
684 "oci-archive:/path/to/archive.tar",
685 "docker-archive:/path/to/archive.tar",
686 "containers-storage:localhost/myimage",
687 "dir:/path/to/dir",
688 "docker-daemon:myimage:latest",
689 "docker://quay.io/example/image",
690 ];
691
692 for input in roundtrip_cases {
693 let ir: ImageReference = (*input).try_into().unwrap();
694 assert_eq!(*input, ir.to_string(), "roundtrip for {input}");
695 }
696
697 let ir: ImageReference = "registry:quay.io/example".try_into().unwrap();
699 assert_eq!(ir.to_string(), "docker://quay.io/example");
700 }
701
702 #[test]
703 fn test_imagereference_errors() {
704 assert!(matches!(
705 ImageReference::try_from("no-colon"),
706 Err(ImageReferenceError::MissingColon(_))
707 ));
708 assert!(matches!(
709 ImageReference::try_from("registry:"),
710 Err(ImageReferenceError::EmptyName(_))
711 ));
712 assert!(matches!(
713 ImageReference::try_from("docker://"),
714 Err(ImageReferenceError::EmptyName(_))
715 ));
716 assert!(matches!(
717 ImageReference::try_from("docker:blah"),
718 Err(ImageReferenceError::MissingDockerSlashes(_))
719 ));
720 assert!(matches!(
721 ImageReference::try_from("unknown:foo"),
722 Err(ImageReferenceError::InvalidTransport(_))
723 ));
724 }
725
726 #[test]
727 fn test_imagereference_fromstr() {
728 let ir1: ImageReference = "docker://quay.io/example/image".parse().unwrap();
729 let ir2: ImageReference = "docker://quay.io/example/image".try_into().unwrap();
730 assert_eq!(ir1, ir2);
731 }
732
733 #[test]
734 fn test_containers_storage_ref() {
735 let cases: &[(&str, Option<&str>, &str, &str)] = &[
737 (
739 "localhost/myimage:tag",
740 None,
741 "localhost/myimage:tag",
742 "localhost/myimage:tag",
743 ),
744 ("busybox", None, "busybox", "busybox"),
745 (
747 "[overlay@/var/lib/containers]busybox",
748 Some("overlay@/var/lib/containers"),
749 "busybox",
750 "busybox",
751 ),
752 (
753 "[/var/lib/containers]busybox:tag",
754 Some("/var/lib/containers"),
755 "busybox:tag",
756 "busybox:tag",
757 ),
758 (
759 "[overlay@/var/lib/containers+/run/containers:opt1,opt2]image",
760 Some("overlay@/var/lib/containers+/run/containers:opt1,opt2"),
761 "image",
762 "image",
763 ),
764 (
766 "sha256:abc123def456",
767 None,
768 "sha256:abc123def456",
769 "abc123def456",
770 ),
771 (
772 "[overlay@/tmp]sha256:abc123",
773 Some("overlay@/tmp"),
774 "sha256:abc123",
775 "abc123",
776 ),
777 ("abc123def456", None, "abc123def456", "abc123def456"),
779 ("", None, "", ""),
781 ("[]image", Some(""), "image", "image"),
782 ];
783
784 for (input, expected_store_spec, expected_image, expected_skopeo) in cases {
785 let imgref = ImageReference::new(Transport::ContainerStorage, *input);
786 let csref = imgref.as_containers_storage().unwrap();
787 assert_eq!(
788 csref.store_spec(),
789 *expected_store_spec,
790 "store_spec for {input}"
791 );
792 assert_eq!(csref.image(), *expected_image, "image for {input}");
793 assert_eq!(
794 csref.image_for_skopeo(),
795 *expected_skopeo,
796 "image_for_skopeo for {input}"
797 );
798 }
799
800 let imgref: ImageReference = "docker://quay.io/example".try_into().unwrap();
802 assert!(imgref.as_containers_storage().is_none());
803 }
804
805 #[test]
806 fn test_containers_storage_ref_roundtrip() {
807 let cases: &[(&str, bool, &str)] = &[
808 ("localhost/myimage:tag", false, "localhost/myimage:tag"),
810 ("localhost/myimage:tag", true, "localhost/myimage:tag"),
811 ("[overlay@/tmp]busybox", false, "[overlay@/tmp]busybox"),
812 ("[overlay@/tmp]busybox", true, "[overlay@/tmp]busybox"),
813 ("sha256:abc123", false, "sha256:abc123"),
814 ("sha256:abc123", true, "abc123"), ("[store]sha256:abc123", false, "[store]sha256:abc123"),
816 ("[store]sha256:abc123", true, "[store]abc123"), ];
818
819 for (input, normalize, expected) in cases {
820 let imgref = ImageReference::new(Transport::ContainerStorage, *input);
821 let csref = imgref.as_containers_storage().unwrap();
822 let result = csref.to_image_reference(*normalize);
823 assert_eq!(
824 result.name, *expected,
825 "roundtrip for {input} normalize={normalize}"
826 );
827 assert_eq!(result.transport, Transport::ContainerStorage);
828 }
829 }
830}