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 imgref")]
12 MissingColon,
13}
14
15#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum ImageReferenceError {
19 #[error("Unknown transport '{0}'")]
20 InvalidTransport(Box<str>),
21 #[error("Missing ':' in {0}")]
22 MissingColon(Box<str>),
23 #[error("Invalid empty name in {0}")]
24 EmptyName(Box<str>),
25 #[error("Missing // in docker:// in {0}")]
26 MissingDockerSlashes(Box<str>),
27}
28
29#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
31pub enum Transport {
32 Registry,
34 OciDir,
36 OciArchive,
38 DockerArchive,
40 ContainerStorage,
42 Dir,
44 DockerDaemon,
46}
47
48impl fmt::Display for Transport {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 Transport::Registry => f.write_str("docker://"),
55 Transport::OciDir => f.write_str("oci:"),
56 Transport::OciArchive => f.write_str("oci-archive:"),
57 Transport::DockerArchive => f.write_str("docker-archive:"),
58 Transport::ContainerStorage => f.write_str("containers-storage:"),
59 Transport::Dir => f.write_str("dir:"),
60 Transport::DockerDaemon => f.write_str("docker-daemon:"),
61 }
62 }
63}
64
65impl TryFrom<&str> for Transport {
66 type Error = TransportConversionError;
67
68 fn try_from(imgref: &str) -> Result<Self, TransportConversionError> {
74 if let Some(colon_pos) = imgref.find(':') {
75 let transport_prefix = &imgref[..colon_pos];
76
77 let transport = match transport_prefix {
78 "registry" => Transport::Registry,
79 "oci" => Transport::OciDir,
80 "oci-archive" => Transport::OciArchive,
81 "docker-archive" => Transport::DockerArchive,
82 "containers-storage" => Transport::ContainerStorage,
83 "dir" => Transport::Dir,
84 "docker-daemon" => Transport::DockerDaemon,
85 "docker" => {
86 if imgref[colon_pos..].starts_with("://") {
88 Transport::Registry
89 } else {
90 return Err(TransportConversionError::InvalidTransport(
91 transport_prefix.into(),
92 ));
93 }
94 }
95 prefix => return Err(TransportConversionError::InvalidTransport(prefix.into())),
96 };
97
98 return Ok(transport);
99 }
100
101 Err(TransportConversionError::MissingColon)
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125pub struct ImageReference {
126 pub transport: Transport,
128 pub name: String,
130}
131
132impl ImageReference {
133 pub fn new(transport: Transport, name: impl Into<String>) -> Self {
143 Self {
144 transport,
145 name: name.into(),
146 }
147 }
148
149 pub fn new_registry(reference: oci_spec::distribution::Reference) -> Self {
161 Self {
162 transport: Transport::Registry,
163 name: reference.whole(),
164 }
165 }
166
167 pub fn try_new_registry(name: &str) -> Result<Self, oci_spec::distribution::ParseError> {
182 let reference: oci_spec::distribution::Reference = name.parse()?;
183 Ok(Self::new_registry(reference))
184 }
185
186 pub fn as_registry(
209 &self,
210 ) -> Option<Result<oci_spec::distribution::Reference, oci_spec::distribution::ParseError>> {
211 if self.transport == Transport::Registry {
212 Some(self.name.parse())
213 } else {
214 None
215 }
216 }
217
218 pub fn as_containers_storage(&self) -> Option<ContainersStorageRef<'_>> {
244 if self.transport == Transport::ContainerStorage {
245 Some(ContainersStorageRef::new(&self.name))
246 } else {
247 None
248 }
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct ContainersStorageRef<'a> {
264 store_spec: Option<&'a str>,
265 image: &'a str,
266}
267
268impl<'a> ContainersStorageRef<'a> {
269 fn new(name: &'a str) -> Self {
270 if let Some(rest) = name.strip_prefix('[') {
272 if let Some(bracket_end) = rest.find(']') {
273 return Self {
274 store_spec: Some(&rest[..bracket_end]),
275 image: &rest[bracket_end + 1..],
276 };
277 }
278 }
279 Self {
280 store_spec: None,
281 image: name,
282 }
283 }
284
285 pub fn store_spec(&self) -> Option<&'a str> {
289 self.store_spec
290 }
291
292 pub fn image(&self) -> &'a str {
294 self.image
295 }
296
297 pub fn image_for_skopeo(&self) -> &'a str {
303 self.image.strip_prefix("sha256:").unwrap_or(self.image)
304 }
305
306 pub fn to_image_reference(&self, normalize: bool) -> ImageReference {
310 let image = if normalize {
311 self.image_for_skopeo()
312 } else {
313 self.image
314 };
315
316 let name = match self.store_spec {
317 Some(spec) => format!("[{}]{}", spec, image),
318 None => image.to_string(),
319 };
320
321 ImageReference::new(Transport::ContainerStorage, name)
322 }
323}
324
325impl TryFrom<&str> for ImageReference {
326 type Error = ImageReferenceError;
327
328 fn try_from(value: &str) -> Result<Self, ImageReferenceError> {
343 let (transport_name, mut name) = value
344 .split_once(':')
345 .ok_or_else(|| ImageReferenceError::MissingColon(value.into()))?;
346
347 let transport = match transport_name {
348 "registry" | "docker" => Transport::Registry,
349 "oci" => Transport::OciDir,
350 "oci-archive" => Transport::OciArchive,
351 "docker-archive" => Transport::DockerArchive,
352 "containers-storage" => Transport::ContainerStorage,
353 "dir" => Transport::Dir,
354 "docker-daemon" => Transport::DockerDaemon,
355 prefix => {
356 return Err(ImageReferenceError::InvalidTransport(prefix.into()));
357 }
358 };
359
360 if transport_name == "docker" {
362 name = name
363 .strip_prefix("//")
364 .ok_or_else(|| ImageReferenceError::MissingDockerSlashes(value.into()))?;
365 }
366
367 if name.is_empty() {
368 return Err(ImageReferenceError::EmptyName(value.into()));
369 }
370
371 Ok(Self {
372 transport,
373 name: name.to_string(),
374 })
375 }
376}
377
378impl FromStr for ImageReference {
379 type Err = ImageReferenceError;
380
381 fn from_str(s: &str) -> Result<Self, Self::Err> {
382 Self::try_from(s)
383 }
384}
385
386impl fmt::Display for ImageReference {
387 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388 write!(f, "{}{}", self.transport, self.name)
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_transport_from_str() {
398 assert!(matches!(
400 Transport::try_from("registry:example.com/image"),
401 Ok(Transport::Registry)
402 ));
403 assert!(matches!(
404 Transport::try_from("oci:/path/to/image"),
405 Ok(Transport::OciDir)
406 ));
407 assert!(matches!(
408 Transport::try_from("oci-archive:/path/to/archive.tar"),
409 Ok(Transport::OciArchive)
410 ));
411 assert!(matches!(
412 Transport::try_from("docker-archive:/path/to/archive.tar"),
413 Ok(Transport::DockerArchive)
414 ));
415 assert!(matches!(
416 Transport::try_from("containers-storage:example.com/image"),
417 Ok(Transport::ContainerStorage)
418 ));
419 assert!(matches!(
420 Transport::try_from("dir:/path/to/directory"),
421 Ok(Transport::Dir)
422 ));
423 assert!(matches!(
424 Transport::try_from("docker-daemon:example.com/image"),
425 Ok(Transport::DockerDaemon)
426 ));
427
428 assert!(matches!(
430 Transport::try_from("docker://example.com/image"),
431 Ok(Transport::Registry)
432 ));
433
434 assert!(matches!(
436 Transport::try_from("example.com:8080/image"),
437 Err(TransportConversionError::InvalidTransport(_))
438 ));
439 assert!(matches!(
440 Transport::try_from("example.com/image:tag"),
441 Err(TransportConversionError::InvalidTransport(_))
442 ));
443
444 assert!(matches!(
446 Transport::try_from("unknown:/path"),
447 Err(TransportConversionError::InvalidTransport(_))
448 ));
449 }
450
451 #[test]
452 fn test_transport_error_cases() {
453 assert!(matches!(
455 Transport::try_from("docker.io/library/hello-world"),
456 Err(TransportConversionError::MissingColon)
457 ));
458 assert!(matches!(
459 Transport::try_from("example.com/image"),
460 Err(TransportConversionError::MissingColon)
461 ));
462
463 assert!(matches!(
465 Transport::try_from("invalid:example.com/image"),
466 Err(TransportConversionError::InvalidTransport(_))
467 ));
468 assert!(matches!(
469 Transport::try_from("ftp:example.com/image"),
470 Err(TransportConversionError::InvalidTransport(_))
471 ));
472
473 assert!(matches!(
475 Transport::try_from("docker:example.com/image"),
476 Err(TransportConversionError::InvalidTransport(_))
477 ));
478
479 assert!(matches!(
481 Transport::try_from(""),
482 Err(TransportConversionError::MissingColon)
483 ));
484
485 assert!(matches!(
487 Transport::try_from(":"),
488 Err(TransportConversionError::InvalidTransport(_))
489 ));
490 }
491
492 #[test]
493 fn test_transport_edge_cases() {
494 assert!(matches!(
496 Transport::try_from("registry:"),
497 Ok(Transport::Registry)
498 ));
499 assert!(matches!(Transport::try_from("oci:"), Ok(Transport::OciDir)));
500
501 assert!(matches!(
503 Transport::try_from("docker://"),
504 Ok(Transport::Registry)
505 ));
506
507 assert!(matches!(
509 Transport::try_from("registry:example.com:8080/image"),
510 Ok(Transport::Registry)
511 ));
512 assert!(matches!(
513 Transport::try_from("oci:/path/with:colon/image"),
514 Ok(Transport::OciDir)
515 ));
516 }
517
518 #[test]
519 fn test_error_display() {
520 let err = TransportConversionError::InvalidTransport("unknown".into());
521 assert_eq!(err.to_string(), "Invalid transport: unknown");
522
523 let err = TransportConversionError::MissingColon;
524 assert_eq!(err.to_string(), "Missing ':' in imgref");
525 }
526
527 #[test]
528 fn test_transport_display() {
529 assert_eq!(Transport::Registry.to_string(), "docker://");
531 assert_eq!(Transport::OciDir.to_string(), "oci:");
532 assert_eq!(Transport::OciArchive.to_string(), "oci-archive:");
533 assert_eq!(Transport::DockerArchive.to_string(), "docker-archive:");
534 assert_eq!(
535 Transport::ContainerStorage.to_string(),
536 "containers-storage:"
537 );
538 assert_eq!(Transport::Dir.to_string(), "dir:");
539 assert_eq!(Transport::DockerDaemon.to_string(), "docker-daemon:");
540 }
541
542 #[test]
543 fn test_transport_roundtrip() {
544 let transports = [
546 Transport::OciDir,
547 Transport::OciArchive,
548 Transport::DockerArchive,
549 Transport::ContainerStorage,
550 Transport::Dir,
551 Transport::DockerDaemon,
552 ];
553
554 for original_transport in transports {
555 let transport_str = original_transport.to_string();
556 let parsed = Transport::try_from(transport_str.as_str()).unwrap();
557 assert_eq!(
558 parsed, original_transport,
559 "Failed roundtrip for {original_transport:?}"
560 );
561 }
562
563 let registry_str = Transport::Registry.to_string();
565 let parsed = Transport::try_from(registry_str.as_str()).unwrap();
566 assert!(matches!(parsed, Transport::Registry));
567 }
568
569 #[test]
570 fn test_imagereference() {
571 let valid_cases: &[(&str, Transport, &str)] = &[
573 ("oci:somedir", Transport::OciDir, "somedir"),
574 ("dir:/some/dir/blah", Transport::Dir, "/some/dir/blah"),
575 (
576 "oci-archive:/path/to/foo.ociarchive",
577 Transport::OciArchive,
578 "/path/to/foo.ociarchive",
579 ),
580 (
581 "docker-archive:/path/to/foo.dockerarchive",
582 Transport::DockerArchive,
583 "/path/to/foo.dockerarchive",
584 ),
585 (
586 "containers-storage:localhost/someimage:blah",
587 Transport::ContainerStorage,
588 "localhost/someimage:blah",
589 ),
590 (
591 "docker://quay.io/exampleos/blah:tag",
592 Transport::Registry,
593 "quay.io/exampleos/blah:tag",
594 ),
595 (
596 "docker-daemon:myimage:latest",
597 Transport::DockerDaemon,
598 "myimage:latest",
599 ),
600 (
602 "registry:quay.io/exampleos/blah",
603 Transport::Registry,
604 "quay.io/exampleos/blah",
605 ),
606 ];
607
608 for (input, expected_transport, expected_name) in valid_cases {
609 let ir: ImageReference = (*input).try_into().unwrap();
610 assert_eq!(ir.transport, *expected_transport, "transport for {input}");
611 assert_eq!(ir.name, *expected_name, "name for {input}");
612 }
613
614 let invalid_cases: &[&str] = &[
616 "", "foo://bar", "docker:blah", "registry:", "docker://", "foo:bar", "nocolon", ];
624
625 for input in invalid_cases {
626 assert!(
627 ImageReference::try_from(*input).is_err(),
628 "should fail: {input}"
629 );
630 }
631 }
632
633 #[test]
634 fn test_imagereference_roundtrip() {
635 let roundtrip_cases: &[&str] = &[
637 "oci:somedir",
638 "oci-archive:/path/to/archive.tar",
639 "docker-archive:/path/to/archive.tar",
640 "containers-storage:localhost/myimage",
641 "dir:/path/to/dir",
642 "docker-daemon:myimage:latest",
643 "docker://quay.io/example/image",
644 ];
645
646 for input in roundtrip_cases {
647 let ir: ImageReference = (*input).try_into().unwrap();
648 assert_eq!(*input, ir.to_string(), "roundtrip for {input}");
649 }
650
651 let ir: ImageReference = "registry:quay.io/example".try_into().unwrap();
653 assert_eq!(ir.to_string(), "docker://quay.io/example");
654 }
655
656 #[test]
657 fn test_imagereference_errors() {
658 assert!(matches!(
659 ImageReference::try_from("no-colon"),
660 Err(ImageReferenceError::MissingColon(_))
661 ));
662 assert!(matches!(
663 ImageReference::try_from("registry:"),
664 Err(ImageReferenceError::EmptyName(_))
665 ));
666 assert!(matches!(
667 ImageReference::try_from("docker://"),
668 Err(ImageReferenceError::EmptyName(_))
669 ));
670 assert!(matches!(
671 ImageReference::try_from("docker:blah"),
672 Err(ImageReferenceError::MissingDockerSlashes(_))
673 ));
674 assert!(matches!(
675 ImageReference::try_from("unknown:foo"),
676 Err(ImageReferenceError::InvalidTransport(_))
677 ));
678 }
679
680 #[test]
681 fn test_imagereference_fromstr() {
682 let ir1: ImageReference = "docker://quay.io/example/image".parse().unwrap();
683 let ir2: ImageReference = "docker://quay.io/example/image".try_into().unwrap();
684 assert_eq!(ir1, ir2);
685 }
686
687 #[test]
688 fn test_containers_storage_ref() {
689 let cases: &[(&str, Option<&str>, &str, &str)] = &[
691 (
693 "localhost/myimage:tag",
694 None,
695 "localhost/myimage:tag",
696 "localhost/myimage:tag",
697 ),
698 ("busybox", None, "busybox", "busybox"),
699 (
701 "[overlay@/var/lib/containers]busybox",
702 Some("overlay@/var/lib/containers"),
703 "busybox",
704 "busybox",
705 ),
706 (
707 "[/var/lib/containers]busybox:tag",
708 Some("/var/lib/containers"),
709 "busybox:tag",
710 "busybox:tag",
711 ),
712 (
713 "[overlay@/var/lib/containers+/run/containers:opt1,opt2]image",
714 Some("overlay@/var/lib/containers+/run/containers:opt1,opt2"),
715 "image",
716 "image",
717 ),
718 (
720 "sha256:abc123def456",
721 None,
722 "sha256:abc123def456",
723 "abc123def456",
724 ),
725 (
726 "[overlay@/tmp]sha256:abc123",
727 Some("overlay@/tmp"),
728 "sha256:abc123",
729 "abc123",
730 ),
731 ("abc123def456", None, "abc123def456", "abc123def456"),
733 ("", None, "", ""),
735 ("[]image", Some(""), "image", "image"),
736 ];
737
738 for (input, expected_store_spec, expected_image, expected_skopeo) in cases {
739 let imgref = ImageReference::new(Transport::ContainerStorage, *input);
740 let csref = imgref.as_containers_storage().unwrap();
741 assert_eq!(
742 csref.store_spec(),
743 *expected_store_spec,
744 "store_spec for {input}"
745 );
746 assert_eq!(csref.image(), *expected_image, "image for {input}");
747 assert_eq!(
748 csref.image_for_skopeo(),
749 *expected_skopeo,
750 "image_for_skopeo for {input}"
751 );
752 }
753
754 let imgref: ImageReference = "docker://quay.io/example".try_into().unwrap();
756 assert!(imgref.as_containers_storage().is_none());
757 }
758
759 #[test]
760 fn test_containers_storage_ref_roundtrip() {
761 let cases: &[(&str, bool, &str)] = &[
762 ("localhost/myimage:tag", false, "localhost/myimage:tag"),
764 ("localhost/myimage:tag", true, "localhost/myimage:tag"),
765 ("[overlay@/tmp]busybox", false, "[overlay@/tmp]busybox"),
766 ("[overlay@/tmp]busybox", true, "[overlay@/tmp]busybox"),
767 ("sha256:abc123", false, "sha256:abc123"),
768 ("sha256:abc123", true, "abc123"), ("[store]sha256:abc123", false, "[store]sha256:abc123"),
770 ("[store]sha256:abc123", true, "[store]abc123"), ];
772
773 for (input, normalize, expected) in cases {
774 let imgref = ImageReference::new(Transport::ContainerStorage, *input);
775 let csref = imgref.as_containers_storage().unwrap();
776 let result = csref.to_image_reference(*normalize);
777 assert_eq!(
778 result.name, *expected,
779 "roundtrip for {input} normalize={normalize}"
780 );
781 assert_eq!(result.transport, Transport::ContainerStorage);
782 }
783 }
784}