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 docker:// in {0}")]
12    MissingDockerSlashes(Box<str>),
13    #[error("Missing ':' in imgref")]
14    MissingColon,
15}
16
17/// Errors that can occur when parsing an image reference from a string.
18#[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/// A backend/transport for OCI/Docker images.
32#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
33pub enum Transport {
34    /// A remote Docker/OCI registry (`registry:` or `docker://`)
35    Registry,
36    /// A local OCI directory (`oci:`)
37    OciDir,
38    /// A local OCI archive tarball (`oci-archive:`)
39    OciArchive,
40    /// A local Docker archive tarball (`docker-archive:`)
41    DockerArchive,
42    /// Local container storage (`containers-storage:`)
43    ContainerStorage,
44    /// Local directory (`dir:`)
45    Dir,
46    /// Local Docker daemon (`docker-daemon:`)
47    DockerDaemon,
48}
49
50impl fmt::Display for Transport {
51    /// Convert the transport back to its string representation.
52    ///
53    /// Note: Registry transport defaults to "docker://" format.
54    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    /// Parse the transport type from a container image reference string, eg
71    /// docker://quay.io/myimage, containers-storage:localhost/myimage
72    ///
73    /// Supports various transport types like "registry:", "oci:", "docker://", etc.
74    /// Returns an error for unknown transports or malformed references without colons.
75    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            // A simple transport like "oci", "registry" was passed in
79            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                // Check if this is actually "docker://" format
92                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/// Combination of a transport and image name.
110///
111/// For example, `docker://quay.io/exampleos/blah:latest` would be parsed as:
112/// - transport: `Registry`
113/// - name: `quay.io/exampleos/blah:latest`
114///
115/// # Name formats by transport
116///
117/// The `name` field format varies by transport:
118///
119/// | Transport | Name format | Example |
120/// |-----------|-------------|---------|
121/// | `Registry` | `[domain/]name[:tag][@digest]` | `quay.io/example/image:latest` |
122/// | `OciDir` | `path[:reference]` | `/path/to/oci-layout:mytag` |
123/// | `OciArchive` | `path[:reference]` | `/path/to/image.tar:v1.0` |
124/// | `DockerArchive` | `path[:docker-reference]` | `/path/to/image.tar:myimage:tag` |
125/// | `ContainerStorage` | `[[storage-spec]]{image-id\|docker-ref}` | `localhost/myimage:latest` |
126/// | `Dir` | `path` | `/path/to/directory` |
127/// | `DockerDaemon` | `docker-reference` or `algo:digest` | `myimage:latest` |
128#[derive(Debug, Clone, PartialEq, Eq, Hash)]
129pub struct ImageReference {
130    /// The storage and transport for the image
131    pub transport: Transport,
132    /// The image name - format depends on transport (see struct docs)
133    pub name: String,
134}
135
136impl ImageReference {
137    /// Create a new image reference from a transport and name.
138    ///
139    /// # Examples
140    /// ```
141    /// use containers_image_proxy::{ImageReference, Transport};
142    ///
143    /// let imgref = ImageReference::new(Transport::Registry, "quay.io/example/image:tag");
144    /// assert_eq!(imgref.to_string(), "docker://quay.io/example/image:tag");
145    /// ```
146    pub fn new(transport: Transport, name: impl Into<String>) -> Self {
147        Self {
148            transport,
149            name: name.into(),
150        }
151    }
152
153    /// Create a new registry image reference from a parsed OCI Reference.
154    ///
155    /// # Examples
156    /// ```
157    /// use containers_image_proxy::ImageReference;
158    /// use oci_spec::distribution::Reference;
159    ///
160    /// let oci_ref: Reference = "quay.io/example/image:latest".parse().unwrap();
161    /// let imgref = ImageReference::new_registry(oci_ref);
162    /// assert_eq!(imgref.to_string(), "docker://quay.io/example/image:latest");
163    /// ```
164    pub fn new_registry(reference: oci_spec::distribution::Reference) -> Self {
165        Self {
166            transport: Transport::Registry,
167            name: reference.whole(),
168        }
169    }
170
171    /// Try to create a new registry image reference by parsing the name.
172    ///
173    /// Returns an error if the name is not a valid OCI distribution reference.
174    ///
175    /// # Examples
176    /// ```
177    /// use containers_image_proxy::ImageReference;
178    ///
179    /// let imgref = ImageReference::try_new_registry("quay.io/example/image:latest").unwrap();
180    /// assert_eq!(imgref.to_string(), "docker://quay.io/example/image:latest");
181    ///
182    /// // Invalid references return an error
183    /// assert!(ImageReference::try_new_registry("not a valid reference!").is_err());
184    /// ```
185    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    /// For Registry transport, parse the name as an OCI distribution Reference.
191    ///
192    /// Returns `None` for non-Registry transports. For Registry transport,
193    /// returns `Some(Result)` with the parsed reference or a parse error.
194    ///
195    /// This is useful when you need structured access to the registry, repository,
196    /// tag, and digest components of a registry image reference.
197    ///
198    /// # Examples
199    /// ```
200    /// use containers_image_proxy::{ImageReference, Transport};
201    ///
202    /// let imgref: ImageReference = "docker://quay.io/example/image:latest".try_into().unwrap();
203    /// let oci_ref = imgref.as_registry().unwrap().unwrap();
204    /// assert_eq!(oci_ref.registry(), "quay.io");
205    /// assert_eq!(oci_ref.repository(), "example/image");
206    /// assert_eq!(oci_ref.tag(), Some("latest"));
207    ///
208    /// // Non-registry transports return None
209    /// let imgref: ImageReference = "oci:/path/to/image".try_into().unwrap();
210    /// assert!(imgref.as_registry().is_none());
211    /// ```
212    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    /// For ContainerStorage transport, parse into structured components.
223    ///
224    /// Returns `None` for non-ContainerStorage transports.
225    ///
226    /// # Examples
227    /// ```
228    /// use containers_image_proxy::{ImageReference, Transport};
229    ///
230    /// // Simple image reference
231    /// let imgref: ImageReference = "containers-storage:localhost/myimage:tag".try_into().unwrap();
232    /// let csref = imgref.as_containers_storage().unwrap();
233    /// assert_eq!(csref.store_spec(), None);
234    /// assert_eq!(csref.image(), "localhost/myimage:tag");
235    ///
236    /// // With store specifier
237    /// let imgref: ImageReference = "containers-storage:[overlay@/var/lib/containers]busybox".try_into().unwrap();
238    /// let csref = imgref.as_containers_storage().unwrap();
239    /// assert_eq!(csref.store_spec(), Some("overlay@/var/lib/containers"));
240    /// assert_eq!(csref.image(), "busybox");
241    ///
242    /// // Normalizing sha256: prefix (workaround for skopeo#2750)
243    /// let imgref: ImageReference = "containers-storage:sha256:abc123".try_into().unwrap();
244    /// let csref = imgref.as_containers_storage().unwrap();
245    /// assert_eq!(csref.image_for_skopeo(), "abc123");
246    /// ```
247    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/// A parsed containers-storage reference.
257///
258/// The containers-storage transport has a complex format:
259/// `containers-storage:[store-spec]image-ref`
260///
261/// Where:
262/// - `store-spec` is optional: `[driver@graphroot+runroot:options]`
263/// - `image-ref` can be: `@image-id`, `docker-ref`, or `docker-ref@image-id`
264///
265/// This struct provides access to the parsed components.
266#[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        // Check for store specifier: [...]
275        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    /// The store specifier content (without brackets), if present.
290    ///
291    /// Format: `driver@graphroot+runroot:options` (all parts optional except graphroot)
292    pub fn store_spec(&self) -> Option<&'a str> {
293        self.store_spec
294    }
295
296    /// The image reference portion after any store specifier.
297    pub fn image(&self) -> &'a str {
298        self.image
299    }
300
301    /// Returns the image reference normalized for skopeo.
302    ///
303    /// This strips the `sha256:` prefix if present, working around
304    /// [skopeo#2750](https://github.com/containers/skopeo/issues/2750) where
305    /// skopeo expects bare image IDs without the algorithm prefix.
306    pub fn image_for_skopeo(&self) -> &'a str {
307        self.image.strip_prefix("sha256:").unwrap_or(self.image)
308    }
309
310    /// Convert back to an ImageReference, optionally applying skopeo normalization.
311    ///
312    /// If `normalize` is true, applies the sha256: stripping workaround.
313    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    /// Parse an image reference string into transport and name components.
333    ///
334    /// # Examples
335    /// ```
336    /// use containers_image_proxy::transport::{ImageReference, Transport};
337    ///
338    /// let imgref: ImageReference = "docker://quay.io/example/image:tag".try_into().unwrap();
339    /// assert_eq!(imgref.transport, Transport::Registry);
340    /// assert_eq!(imgref.name, "quay.io/example/image:tag");
341    ///
342    /// let imgref: ImageReference = "containers-storage:localhost/myimage".try_into().unwrap();
343    /// assert_eq!(imgref.transport, Transport::ContainerStorage);
344    /// assert_eq!(imgref.name, "localhost/myimage");
345    /// ```
346    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        // Handle docker:// format - requires the // prefix
365        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        // Test specific transports
403        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        // Test docker:// prefix
433        assert!(matches!(
434            Transport::try_from("docker://example.com/image"),
435            Ok(Transport::Registry)
436        ));
437
438        // Test bare image references with colon (port or tag)
439        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        // Test unknown transport (should error)
449        assert!(matches!(
450            Transport::try_from("unknown:/path"),
451            Err(TransportConversionError::InvalidTransport(_))
452        ));
453    }
454
455    #[test]
456    fn test_transport_error_cases() {
457        // Test missing colon (bare image reference without transport)
458        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        // Test invalid transport prefixes
468        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        // Test docker: without :// (should error)
478        assert!(matches!(
479            Transport::try_from("docker:example.com/image"),
480            Err(TransportConversionError::MissingDockerSlashes(_))
481        ));
482
483        // Test empty string
484        assert!(matches!(
485            Transport::try_from(""),
486            Err(TransportConversionError::InvalidTransport(_))
487        ));
488
489        // Test just colon
490        assert!(matches!(
491            Transport::try_from(":"),
492            Err(TransportConversionError::InvalidTransport(_))
493        ));
494    }
495
496    #[test]
497    fn test_bare_transport_parsing() {
498        // Test parsing bare transport names without image references
499        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        // Test that bare "docker" fails (needs docker://)
523        assert!(matches!(
524            Transport::try_from("docker"),
525            Err(TransportConversionError::MissingDockerSlashes(_))
526        ));
527
528        // Test unknown bare transport
529        assert!(matches!(
530            Transport::try_from("unknown"),
531            Err(TransportConversionError::InvalidTransport(_))
532        ));
533    }
534
535    #[test]
536    fn test_transport_edge_cases() {
537        // Test transport at end of string
538        assert!(matches!(
539            Transport::try_from("registry:"),
540            Ok(Transport::Registry)
541        ));
542        assert!(matches!(Transport::try_from("oci:"), Ok(Transport::OciDir)));
543
544        // Test docker:// with empty path
545        assert!(matches!(
546            Transport::try_from("docker://"),
547            Ok(Transport::Registry)
548        ));
549
550        // Test multiple colons (should use first colon position)
551        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        // Test that each transport converts to its expected string representation
576        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        // Test roundtrip conversion for transports that map back to themselves
591        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        // Test special case for Registry (docker:// -> Registry)
610        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        // Table of valid image references: (input, expected_transport, expected_name)
618        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            // registry: is asymmetric - parses but serializes as docker://
647            (
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        // Invalid image references
661        let invalid_cases: &[&str] = &[
662            "",            // empty
663            "foo://bar",   // unknown transport
664            "docker:blah", // docker without //
665            "registry:",   // empty name
666            "docker://",   // empty name after stripping //
667            "foo:bar",     // unknown transport
668            "nocolon",     // no colon at all
669        ];
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        // These should roundtrip exactly
682        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        // registry: is asymmetric - serializes as docker://
698        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        // Table of test cases: (input, expected_store_spec, expected_image, expected_skopeo_image)
736        let cases: &[(&str, Option<&str>, &str, &str)] = &[
737            // Simple cases
738            (
739                "localhost/myimage:tag",
740                None,
741                "localhost/myimage:tag",
742                "localhost/myimage:tag",
743            ),
744            ("busybox", None, "busybox", "busybox"),
745            // With store specifier
746            (
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            // sha256: prefix handling (skopeo#2750 workaround)
765            (
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            // Image ID without sha256: prefix (already normalized)
778            ("abc123def456", None, "abc123def456", "abc123def456"),
779            // Edge cases
780            ("", 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        // Non-containers-storage transport returns None
801        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            // (input, normalize, expected_output)
809            ("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"), // normalized
815            ("[store]sha256:abc123", false, "[store]sha256:abc123"),
816            ("[store]sha256:abc123", true, "[store]abc123"), // normalized
817        ];
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}