Skip to main content

containers_image_proxy/
transport.rs

1use std::fmt;
2use std::str::FromStr;
3use thiserror::Error;
4
5/// Errors that can occur when parsing a transport from a string.
6#[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/// Errors that can occur when parsing an image reference from a string.
16#[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/// A backend/transport for OCI/Docker images.
30#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
31pub enum Transport {
32    /// A remote Docker/OCI registry (`registry:` or `docker://`)
33    Registry,
34    /// A local OCI directory (`oci:`)
35    OciDir,
36    /// A local OCI archive tarball (`oci-archive:`)
37    OciArchive,
38    /// A local Docker archive tarball (`docker-archive:`)
39    DockerArchive,
40    /// Local container storage (`containers-storage:`)
41    ContainerStorage,
42    /// Local directory (`dir:`)
43    Dir,
44    /// Local Docker daemon (`docker-daemon:`)
45    DockerDaemon,
46}
47
48impl fmt::Display for Transport {
49    /// Convert the transport back to its string representation.
50    ///
51    /// Note: Registry transport defaults to "docker://" format.
52    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    /// Parse the transport type from a container image reference string, eg
69    /// docker://quay.io/myimage, containers-storage:localhost/myimage
70    ///
71    /// Supports various transport types like "registry:", "oci:", "docker://", etc.
72    /// Returns an error for unknown transports or malformed references without colons.
73    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                    // Check if this is actually "docker://" format
87                    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/// Combination of a transport and image name.
106///
107/// For example, `docker://quay.io/exampleos/blah:latest` would be parsed as:
108/// - transport: `Registry`
109/// - name: `quay.io/exampleos/blah:latest`
110///
111/// # Name formats by transport
112///
113/// The `name` field format varies by transport:
114///
115/// | Transport | Name format | Example |
116/// |-----------|-------------|---------|
117/// | `Registry` | `[domain/]name[:tag][@digest]` | `quay.io/example/image:latest` |
118/// | `OciDir` | `path[:reference]` | `/path/to/oci-layout:mytag` |
119/// | `OciArchive` | `path[:reference]` | `/path/to/image.tar:v1.0` |
120/// | `DockerArchive` | `path[:docker-reference]` | `/path/to/image.tar:myimage:tag` |
121/// | `ContainerStorage` | `[[storage-spec]]{image-id\|docker-ref}` | `localhost/myimage:latest` |
122/// | `Dir` | `path` | `/path/to/directory` |
123/// | `DockerDaemon` | `docker-reference` or `algo:digest` | `myimage:latest` |
124#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125pub struct ImageReference {
126    /// The storage and transport for the image
127    pub transport: Transport,
128    /// The image name - format depends on transport (see struct docs)
129    pub name: String,
130}
131
132impl ImageReference {
133    /// Create a new image reference from a transport and name.
134    ///
135    /// # Examples
136    /// ```
137    /// use containers_image_proxy::{ImageReference, Transport};
138    ///
139    /// let imgref = ImageReference::new(Transport::Registry, "quay.io/example/image:tag");
140    /// assert_eq!(imgref.to_string(), "docker://quay.io/example/image:tag");
141    /// ```
142    pub fn new(transport: Transport, name: impl Into<String>) -> Self {
143        Self {
144            transport,
145            name: name.into(),
146        }
147    }
148
149    /// Create a new registry image reference from a parsed OCI Reference.
150    ///
151    /// # Examples
152    /// ```
153    /// use containers_image_proxy::ImageReference;
154    /// use oci_spec::distribution::Reference;
155    ///
156    /// let oci_ref: Reference = "quay.io/example/image:latest".parse().unwrap();
157    /// let imgref = ImageReference::new_registry(oci_ref);
158    /// assert_eq!(imgref.to_string(), "docker://quay.io/example/image:latest");
159    /// ```
160    pub fn new_registry(reference: oci_spec::distribution::Reference) -> Self {
161        Self {
162            transport: Transport::Registry,
163            name: reference.whole(),
164        }
165    }
166
167    /// Try to create a new registry image reference by parsing the name.
168    ///
169    /// Returns an error if the name is not a valid OCI distribution reference.
170    ///
171    /// # Examples
172    /// ```
173    /// use containers_image_proxy::ImageReference;
174    ///
175    /// let imgref = ImageReference::try_new_registry("quay.io/example/image:latest").unwrap();
176    /// assert_eq!(imgref.to_string(), "docker://quay.io/example/image:latest");
177    ///
178    /// // Invalid references return an error
179    /// assert!(ImageReference::try_new_registry("not a valid reference!").is_err());
180    /// ```
181    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    /// For Registry transport, parse the name as an OCI distribution Reference.
187    ///
188    /// Returns `None` for non-Registry transports. For Registry transport,
189    /// returns `Some(Result)` with the parsed reference or a parse error.
190    ///
191    /// This is useful when you need structured access to the registry, repository,
192    /// tag, and digest components of a registry image reference.
193    ///
194    /// # Examples
195    /// ```
196    /// use containers_image_proxy::{ImageReference, Transport};
197    ///
198    /// let imgref: ImageReference = "docker://quay.io/example/image:latest".try_into().unwrap();
199    /// let oci_ref = imgref.as_registry().unwrap().unwrap();
200    /// assert_eq!(oci_ref.registry(), "quay.io");
201    /// assert_eq!(oci_ref.repository(), "example/image");
202    /// assert_eq!(oci_ref.tag(), Some("latest"));
203    ///
204    /// // Non-registry transports return None
205    /// let imgref: ImageReference = "oci:/path/to/image".try_into().unwrap();
206    /// assert!(imgref.as_registry().is_none());
207    /// ```
208    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    /// For ContainerStorage transport, parse into structured components.
219    ///
220    /// Returns `None` for non-ContainerStorage transports.
221    ///
222    /// # Examples
223    /// ```
224    /// use containers_image_proxy::{ImageReference, Transport};
225    ///
226    /// // Simple image reference
227    /// let imgref: ImageReference = "containers-storage:localhost/myimage:tag".try_into().unwrap();
228    /// let csref = imgref.as_containers_storage().unwrap();
229    /// assert_eq!(csref.store_spec(), None);
230    /// assert_eq!(csref.image(), "localhost/myimage:tag");
231    ///
232    /// // With store specifier
233    /// let imgref: ImageReference = "containers-storage:[overlay@/var/lib/containers]busybox".try_into().unwrap();
234    /// let csref = imgref.as_containers_storage().unwrap();
235    /// assert_eq!(csref.store_spec(), Some("overlay@/var/lib/containers"));
236    /// assert_eq!(csref.image(), "busybox");
237    ///
238    /// // Normalizing sha256: prefix (workaround for skopeo#2750)
239    /// let imgref: ImageReference = "containers-storage:sha256:abc123".try_into().unwrap();
240    /// let csref = imgref.as_containers_storage().unwrap();
241    /// assert_eq!(csref.image_for_skopeo(), "abc123");
242    /// ```
243    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/// A parsed containers-storage reference.
253///
254/// The containers-storage transport has a complex format:
255/// `containers-storage:[store-spec]image-ref`
256///
257/// Where:
258/// - `store-spec` is optional: `[driver@graphroot+runroot:options]`
259/// - `image-ref` can be: `@image-id`, `docker-ref`, or `docker-ref@image-id`
260///
261/// This struct provides access to the parsed components.
262#[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        // Check for store specifier: [...]
271        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    /// The store specifier content (without brackets), if present.
286    ///
287    /// Format: `driver@graphroot+runroot:options` (all parts optional except graphroot)
288    pub fn store_spec(&self) -> Option<&'a str> {
289        self.store_spec
290    }
291
292    /// The image reference portion after any store specifier.
293    pub fn image(&self) -> &'a str {
294        self.image
295    }
296
297    /// Returns the image reference normalized for skopeo.
298    ///
299    /// This strips the `sha256:` prefix if present, working around
300    /// [skopeo#2750](https://github.com/containers/skopeo/issues/2750) where
301    /// skopeo expects bare image IDs without the algorithm prefix.
302    pub fn image_for_skopeo(&self) -> &'a str {
303        self.image.strip_prefix("sha256:").unwrap_or(self.image)
304    }
305
306    /// Convert back to an ImageReference, optionally applying skopeo normalization.
307    ///
308    /// If `normalize` is true, applies the sha256: stripping workaround.
309    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    /// Parse an image reference string into transport and name components.
329    ///
330    /// # Examples
331    /// ```
332    /// use containers_image_proxy::transport::{ImageReference, Transport};
333    ///
334    /// let imgref: ImageReference = "docker://quay.io/example/image:tag".try_into().unwrap();
335    /// assert_eq!(imgref.transport, Transport::Registry);
336    /// assert_eq!(imgref.name, "quay.io/example/image:tag");
337    ///
338    /// let imgref: ImageReference = "containers-storage:localhost/myimage".try_into().unwrap();
339    /// assert_eq!(imgref.transport, Transport::ContainerStorage);
340    /// assert_eq!(imgref.name, "localhost/myimage");
341    /// ```
342    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        // Handle docker:// format - requires the // prefix
361        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        // Test specific transports
399        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        // Test docker:// prefix
429        assert!(matches!(
430            Transport::try_from("docker://example.com/image"),
431            Ok(Transport::Registry)
432        ));
433
434        // Test bare image references with colon (port or tag)
435        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        // Test unknown transport (should error)
445        assert!(matches!(
446            Transport::try_from("unknown:/path"),
447            Err(TransportConversionError::InvalidTransport(_))
448        ));
449    }
450
451    #[test]
452    fn test_transport_error_cases() {
453        // Test missing colon (bare image reference without transport)
454        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        // Test invalid transport prefixes
464        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        // Test docker: without :// (should error)
474        assert!(matches!(
475            Transport::try_from("docker:example.com/image"),
476            Err(TransportConversionError::InvalidTransport(_))
477        ));
478
479        // Test empty string
480        assert!(matches!(
481            Transport::try_from(""),
482            Err(TransportConversionError::MissingColon)
483        ));
484
485        // Test just colon
486        assert!(matches!(
487            Transport::try_from(":"),
488            Err(TransportConversionError::InvalidTransport(_))
489        ));
490    }
491
492    #[test]
493    fn test_transport_edge_cases() {
494        // Test transport at end of string
495        assert!(matches!(
496            Transport::try_from("registry:"),
497            Ok(Transport::Registry)
498        ));
499        assert!(matches!(Transport::try_from("oci:"), Ok(Transport::OciDir)));
500
501        // Test docker:// with empty path
502        assert!(matches!(
503            Transport::try_from("docker://"),
504            Ok(Transport::Registry)
505        ));
506
507        // Test multiple colons (should use first colon position)
508        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        // Test that each transport converts to its expected string representation
530        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        // Test roundtrip conversion for transports that map back to themselves
545        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        // Test special case for Registry (docker:// -> Registry)
564        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        // Table of valid image references: (input, expected_transport, expected_name)
572        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            // registry: is asymmetric - parses but serializes as docker://
601            (
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        // Invalid image references
615        let invalid_cases: &[&str] = &[
616            "",            // empty
617            "foo://bar",   // unknown transport
618            "docker:blah", // docker without //
619            "registry:",   // empty name
620            "docker://",   // empty name after stripping //
621            "foo:bar",     // unknown transport
622            "nocolon",     // no colon at all
623        ];
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        // These should roundtrip exactly
636        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        // registry: is asymmetric - serializes as docker://
652        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        // Table of test cases: (input, expected_store_spec, expected_image, expected_skopeo_image)
690        let cases: &[(&str, Option<&str>, &str, &str)] = &[
691            // Simple cases
692            (
693                "localhost/myimage:tag",
694                None,
695                "localhost/myimage:tag",
696                "localhost/myimage:tag",
697            ),
698            ("busybox", None, "busybox", "busybox"),
699            // With store specifier
700            (
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            // sha256: prefix handling (skopeo#2750 workaround)
719            (
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            // Image ID without sha256: prefix (already normalized)
732            ("abc123def456", None, "abc123def456", "abc123def456"),
733            // Edge cases
734            ("", 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        // Non-containers-storage transport returns None
755        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            // (input, normalize, expected_output)
763            ("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"), // normalized
769            ("[store]sha256:abc123", false, "[store]sha256:abc123"),
770            ("[store]sha256:abc123", true, "[store]abc123"), // normalized
771        ];
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}