Skip to main content

ostree_ext/container/
mod.rs

1//! # APIs bridging OSTree and container images
2//!
3//! This module provides the core infrastructure for bidirectionally mapping between
4//! OCI/Docker container images and OSTree repositories. It enables bootable container
5//! images to be fetched from registries, stored efficiently, and deployed as ostree
6//! commits.
7//!
8//! ## Overview
9//!
10//! Container images are fundamentally layers of tarballs. This module leverages the
11//! [`crate::tar`] module to import container layers as ostree content, and exports
12//! ostree commits back to container images. The key insight is that ostree's
13//! content-addressed object storage maps naturally to OCI layer deduplication.
14//!
15//! When a container image is imported ("pulled"), each layer becomes an ostree commit.
16//! These layer commits are then merged into a single "merge commit" that represents
17//! the complete filesystem state. This merge commit is what gets deployed as a
18//! bootable system.
19//!
20//! ## On-Disk Storage Structure
21//!
22//! Container images are stored in the ostree repository (typically `/sysroot/ostree/repo/`)
23//! using a structured reference (ref) namespace:
24//!
25//! ### Reference Namespace
26//!
27//! - **`ostree/container/blob/<escaped-digest>`**: Each OCI layer is stored as a
28//!   separate ostree commit. The digest (e.g., `sha256:abc123...`) is escaped using
29//!   [`crate::refescape`] to be valid as an ostree ref. For example:
30//!   `ostree/container/blob/sha256_3A_abc123...`
31//!
32//! - **`ostree/container/image/<escaped-image-reference>`**: Points to the "merge
33//!   commit" for a pulled image. The image reference (e.g., `docker://quay.io/org/image:tag`)
34//!   is escaped similarly. This is the ref that deployments point to.
35//!
36//! - **`ostree/container/baseimage/<project>/<index>`**: Used to protect base images
37//!   from garbage collection. Tooling that builds derived images locally should write
38//!   refs under this prefix to prevent the base layers from being pruned.
39//!
40//! ### Layer Storage
41//!
42//! Each container layer is stored as an ostree commit with a special structure:
43//!
44//! - **OSTree "chunk" layers**: Layers that are part of the base ostree commit use
45//!   the "object set" format - the filenames in the commit *are* the object checksums.
46//!   This enables efficient reconstruction of the original ostree commit.
47//!
48//! - **Derived layers**: Non-ostree layers (e.g., from `RUN` commands in a Containerfile)
49//!   are imported as regular filesystem trees and stored as standard ostree commits.
50//!
51//! ### The Merge Commit
52//!
53//! The merge commit (`ostree/container/image/...`) combines all layers into a single
54//! filesystem tree. It contains critical metadata in its commit metadata:
55//!
56//! - `ostree.manifest-digest`: The OCI manifest digest (e.g., `sha256:...`)
57//! - `ostree.manifest`: The complete OCI manifest as JSON
58//! - `ostree.container.image-config`: The OCI image configuration as JSON
59//!
60//! This metadata enables round-tripping: an imported image can be re-exported with
61//! its original manifest structure preserved.
62//!
63//! ## Import Flow
64//!
65//! The import process (implemented in [`store::ImageImporter`]) follows these steps:
66//!
67//! 1. **Manifest fetch**: Contact the registry via containers-image-proxy (skopeo)
68//!    to retrieve the image manifest and configuration.
69//!
70//! 2. **Layout parsing**: Analyze the manifest to identify:
71//!    - The base ostree layer (identified by the `ostree.final-diffid` label)
72//!    - Component/chunk layers (split object sets)
73//!    - Derived layers (non-ostree content)
74//!
75//! 3. **Layer caching check**: For each layer, check if an ostree ref already exists
76//!    for that digest. Cached layers are skipped, enabling efficient incremental updates.
77//!
78//! 4. **Layer import**: For uncached layers:
79//!    - Fetch the compressed tarball from the registry
80//!    - Decompress and parse the tar stream
81//!    - Import content into ostree (handling xattrs via `bare-split-xattrs` format)
82//!    - Create an ostree commit and write the layer ref
83//!
84//! 5. **Merge commit creation**: Overlay all layers (processing OCI whiteout files)
85//!    to create a unified filesystem tree. Apply SELinux labeling if needed.
86//!    Store manifest/config metadata and write the image ref.
87//!
88//! 6. **Garbage collection**: Prune layer refs that are no longer referenced by any
89//!    image or deployment.
90//!
91//! ## Tar Stream Format
92//!
93//! The tar format used for ostree layers is documented in [`crate::tar`]. Key points:
94//!
95//! - Uses `bare-split-xattrs` repository mode to handle extended attributes
96//! - XAttrs are stored in separate `.file-xattrs` objects, avoiding tar xattr complexity
97//! - `/etc` in container images maps to `/usr/etc` in ostree (the "3-way merge" location)
98//! - Hardlinks are used for deduplication within layers
99//!
100//! ## Connection to Deployments
101//!
102//! When bootc deploys an image, it creates an ostree deployment whose "origin" file
103//! references the container image. The origin contains:
104//!
105//! - The [`OstreeImageReference`] specifying the image and signature verification method
106//! - The merge commit checksum
107//!
108//! On subsequent boots, bootc can compare the deployed commit against the registry
109//! manifest to detect available updates.
110//!
111//! ## Signatures
112//!
113//! OSTree supports GPG and ed25519 signatures natively. When fetching container images,
114//! signature verification can be configured via [`SignatureSource`]:
115//!
116//! - `OstreeRemote(name)`: Verify using the named ostree remote's keyring
117//! - `ContainerPolicy`: Defer to containers-policy.json (requires explicit allow)
118//! - `ContainerPolicyAllowInsecure`: Use containers-policy.json defaults (not recommended)
119//!
120//! This library defines a URL-like schema to combine signature verification with
121//! image references:
122//!
123//! - `ostree-remote-registry:<remotename>:<containerimage>` - Verify via ostree remote
124//! - `ostree-image-signed:<transport>:<image>` - Use container policy
125//! - `ostree-unverified-registry:<image>` - No verification (not recommended)
126//!
127//! Example: `ostree-remote-registry:fedora:quay.io/fedora/fedora-bootc:latest`
128//!
129//! See [`OstreeImageReference`] for parsing and generating these strings.
130//!
131//! ## Layering and Derived Images
132//!
133//! Container image layering is fully supported. A typical bootable image structure:
134//!
135//! 1. **Base ostree layer**: Contains the core OS as an ostree commit
136//! 2. **Chunk layers**: Split objects for efficient updates (optional)
137//! 3. **Derived layers**: Additional content from Containerfile `RUN` commands
138//!
139//! The `ostree.final-diffid` label in the image configuration marks where the
140//! ostree content ends and derived content begins. This enables:
141//!
142//! - Efficient layer sharing between images with the same base
143//! - Proper SELinux labeling of derived content using the base policy
144//! - Round-trip export preserving the layer structure
145//!
146//! ## Key Types
147//!
148//! - [`Transport`]: OCI/Docker transport (registry, oci-dir, containers-storage, etc.)
149//! - [`ImageReference`]: Container image reference with transport
150//! - [`OstreeImageReference`]: Image reference plus signature verification method
151//! - [`SignatureSource`]: How to verify image signatures
152//! - [`store::ImageImporter`]: Main import orchestrator
153//! - [`store::PreparedImport`]: Analysis of layers to fetch
154//! - [`store::LayeredImageState`]: State of a pulled image
155//! - [`ManifestDiff`]: Comparison between two image manifests
156//!
157//! ## Submodules
158//!
159//! - [`store`]: Core storage and import logic
160//! - [`deploy`]: Integration with ostree deployments
161//! - [`skopeo`]: Skopeo subprocess management for registry operations
162
163use anyhow::anyhow;
164use cap_std_ext::cap_std;
165use cap_std_ext::cap_std::fs::Dir;
166use containers_image_proxy::oci_spec;
167use ostree::glib;
168use serde::Serialize;
169
170use std::borrow::Cow;
171use std::collections::HashMap;
172use std::fmt::Debug;
173use std::ops::Deref;
174use std::str::FromStr;
175
176/// The label injected into a container image that contains the ostree commit SHA-256.
177pub const OSTREE_COMMIT_LABEL: &str = "ostree.commit";
178
179/// The name of an annotation attached to a layer which names the packages/components
180/// which are part of it.
181pub(crate) const CONTENT_ANNOTATION: &str = "ostree.components";
182/// The character we use to separate values in [`CONTENT_ANNOTATION`].
183pub(crate) const COMPONENT_SEPARATOR: char = ',';
184
185/// Our generic catchall fatal error, expected to be converted
186/// to a string to output to a terminal or logs.
187type Result<T> = anyhow::Result<T>;
188
189/// A backend/transport for OCI/Docker images.
190pub type Transport = containers_image_proxy::transport::Transport;
191
192/// Combination of a remote image reference and transport.
193pub type ImageReference = containers_image_proxy::ImageReference;
194
195/// Policy for signature verification.
196#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub enum SignatureSource {
198    /// Fetches will use the named ostree remote for signature verification of the ostree commit.
199    OstreeRemote(String),
200    /// Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy.
201    ContainerPolicy,
202    /// NOT RECOMMENDED.  Fetches will defer to the `containers-policy.json` default which is usually `insecureAcceptAnything`.
203    ContainerPolicyAllowInsecure,
204}
205
206/// A commonly used pre-OCI label for versions.
207pub const LABEL_VERSION: &str = "version";
208
209/// Combination of a signature verification mechanism, and a standard container image reference.
210///
211#[derive(Debug, Clone, PartialEq, Eq, Hash)]
212pub struct OstreeImageReference {
213    /// The signature verification mechanism.
214    pub sigverify: SignatureSource,
215    /// The container image reference.
216    pub imgref: ImageReference,
217}
218
219impl TryFrom<&str> for SignatureSource {
220    type Error = anyhow::Error;
221
222    fn try_from(value: &str) -> Result<Self> {
223        match value {
224            "ostree-image-signed" => Ok(Self::ContainerPolicy),
225            "ostree-unverified-image" => Ok(Self::ContainerPolicyAllowInsecure),
226            o => match o.strip_prefix("ostree-remote-image:") {
227                Some(rest) => Ok(Self::OstreeRemote(rest.to_string())),
228                _ => Err(anyhow!("Invalid signature source: {}", o)),
229            },
230        }
231    }
232}
233
234impl FromStr for SignatureSource {
235    type Err = anyhow::Error;
236
237    fn from_str(s: &str) -> Result<Self> {
238        Self::try_from(s)
239    }
240}
241
242impl TryFrom<&str> for OstreeImageReference {
243    type Error = anyhow::Error;
244
245    fn try_from(value: &str) -> Result<Self> {
246        let (first, second) = value
247            .split_once(':')
248            .ok_or_else(|| anyhow!("Missing ':' in {}", value))?;
249        let (sigverify, rest) = match first {
250            "ostree-image-signed" => (SignatureSource::ContainerPolicy, Cow::Borrowed(second)),
251            "ostree-unverified-image" => (
252                SignatureSource::ContainerPolicyAllowInsecure,
253                Cow::Borrowed(second),
254            ),
255            // Shorthand for ostree-unverified-image:registry:
256            "ostree-unverified-registry" => (
257                SignatureSource::ContainerPolicyAllowInsecure,
258                Cow::Owned(format!("registry:{second}")),
259            ),
260            // This is a shorthand for ostree-remote-image with registry:
261            "ostree-remote-registry" => {
262                let (remote, rest) = second
263                    .split_once(':')
264                    .ok_or_else(|| anyhow!("Missing second ':' in {}", value))?;
265                (
266                    SignatureSource::OstreeRemote(remote.to_string()),
267                    Cow::Owned(format!("registry:{rest}")),
268                )
269            }
270            "ostree-remote-image" => {
271                let (remote, rest) = second
272                    .split_once(':')
273                    .ok_or_else(|| anyhow!("Missing second ':' in {}", value))?;
274                (
275                    SignatureSource::OstreeRemote(remote.to_string()),
276                    Cow::Borrowed(rest),
277                )
278            }
279            o => {
280                return Err(anyhow!("Invalid ostree image reference scheme: {}", o));
281            }
282        };
283        let imgref = rest.deref().try_into()?;
284        Ok(Self { sigverify, imgref })
285    }
286}
287
288impl FromStr for OstreeImageReference {
289    type Err = anyhow::Error;
290
291    fn from_str(s: &str) -> Result<Self> {
292        Self::try_from(s)
293    }
294}
295
296impl std::fmt::Display for SignatureSource {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            SignatureSource::OstreeRemote(r) => write!(f, "ostree-remote-image:{r}"),
300            SignatureSource::ContainerPolicy => write!(f, "ostree-image-signed"),
301            SignatureSource::ContainerPolicyAllowInsecure => {
302                write!(f, "ostree-unverified-image")
303            }
304        }
305    }
306}
307
308impl std::fmt::Display for OstreeImageReference {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match (&self.sigverify, &self.imgref) {
311            (SignatureSource::ContainerPolicyAllowInsecure, imgref)
312                if imgref.transport == Transport::Registry =>
313            {
314                // Because allow-insecure is the effective default, allow formatting
315                // without it.  Note this formatting is asymmetric and cannot be
316                // re-parsed.
317                if f.alternate() {
318                    write!(f, "{}", self.imgref)
319                } else {
320                    write!(f, "ostree-unverified-registry:{}", self.imgref.name)
321                }
322            }
323            (sigverify, imgref) => {
324                write!(f, "{sigverify}:{imgref}")
325            }
326        }
327    }
328}
329
330/// Represents the difference in layer/blob content between two OCI image manifests.
331#[derive(Debug, Serialize)]
332pub struct ManifestDiff<'a> {
333    /// The source container image manifest.
334    #[serde(skip)]
335    pub from: &'a oci_spec::image::ImageManifest,
336    /// The target container image manifest.
337    #[serde(skip)]
338    pub to: &'a oci_spec::image::ImageManifest,
339    /// Layers which are present in the old image but not the new image.
340    #[serde(skip)]
341    pub removed: Vec<&'a oci_spec::image::Descriptor>,
342    /// Layers which are present in the new image but not the old image.
343    #[serde(skip)]
344    pub added: Vec<&'a oci_spec::image::Descriptor>,
345    /// Total number of layers
346    pub total: u64,
347    /// Size of total number of layers.
348    pub total_size: u64,
349    /// Number of layers removed
350    pub n_removed: u64,
351    /// Size of the number of layers removed
352    pub removed_size: u64,
353    /// Number of packages added
354    pub n_added: u64,
355    /// Size of the number of layers added
356    pub added_size: u64,
357}
358
359impl<'a> ManifestDiff<'a> {
360    /// Compute the layer difference between two OCI image manifests.
361    pub fn new(
362        src: &'a oci_spec::image::ImageManifest,
363        dest: &'a oci_spec::image::ImageManifest,
364    ) -> Self {
365        let src_layers = src
366            .layers()
367            .iter()
368            .map(|l| (l.digest().digest(), l))
369            .collect::<HashMap<_, _>>();
370        let dest_layers = dest
371            .layers()
372            .iter()
373            .map(|l| (l.digest().digest(), l))
374            .collect::<HashMap<_, _>>();
375        let mut removed = Vec::new();
376        let mut added = Vec::new();
377        for (blobid, &descriptor) in src_layers.iter() {
378            if !dest_layers.contains_key(blobid) {
379                removed.push(descriptor);
380            }
381        }
382        removed.sort_by(|a, b| a.digest().digest().cmp(b.digest().digest()));
383        for (blobid, &descriptor) in dest_layers.iter() {
384            if !src_layers.contains_key(blobid) {
385                added.push(descriptor);
386            }
387        }
388        added.sort_by(|a, b| a.digest().digest().cmp(b.digest().digest()));
389
390        fn layersum<'a, I: Iterator<Item = &'a oci_spec::image::Descriptor>>(layers: I) -> u64 {
391            layers.map(|layer| layer.size()).sum()
392        }
393        let total = dest_layers.len() as u64;
394        let total_size = layersum(dest.layers().iter());
395        let n_removed = removed.len() as u64;
396        let n_added = added.len() as u64;
397        let removed_size = layersum(removed.iter().copied());
398        let added_size = layersum(added.iter().copied());
399        ManifestDiff {
400            from: src,
401            to: dest,
402            removed,
403            added,
404            total,
405            total_size,
406            n_removed,
407            removed_size,
408            n_added,
409            added_size,
410        }
411    }
412}
413
414impl ManifestDiff<'_> {
415    /// Prints the total, removed and added content between two OCI images
416    pub fn print(&self) {
417        let print_total = self.total;
418        let print_total_size = glib::format_size(self.total_size);
419        let print_n_removed = self.n_removed;
420        let print_removed_size = glib::format_size(self.removed_size);
421        let print_n_added = self.n_added;
422        let print_added_size = glib::format_size(self.added_size);
423        println!("Total new layers: {print_total:<4}  Size: {print_total_size}");
424        println!("Removed layers:   {print_n_removed:<4}  Size: {print_removed_size}");
425        println!("Added layers:     {print_n_added:<4}  Size: {print_added_size}");
426    }
427}
428
429/// Apply default configuration for container image pulls to an existing configuration.
430/// For example, if `authfile` is not set, and `auth_anonymous` is `false`, and a global configuration file exists, it will be used.
431pub fn merge_default_container_proxy_opts(
432    config: &mut containers_image_proxy::ImageProxyConfig,
433) -> Result<()> {
434    let auth_specified =
435        config.auth_anonymous || config.authfile.is_some() || config.auth_data.is_some();
436    if !auth_specified {
437        let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
438        config.auth_data = crate::globals::get_global_authfile(root)?.map(|a| a.1);
439        // If there's no auth data, then force on anonymous pulls to ensure
440        // that the container stack doesn't try to find it in the standard
441        // container paths.
442        if config.auth_data.is_none() {
443            config.auth_anonymous = true;
444        }
445    }
446    Ok(())
447}
448
449/// Convenience helper to return the labels, if present.
450pub(crate) fn labels_of(
451    config: &oci_spec::image::ImageConfiguration,
452) -> Option<&HashMap<String, String>> {
453    config.config().as_ref().and_then(|c| c.labels().as_ref())
454}
455
456/// Retrieve the version number from an image configuration.
457pub fn version_for_config(config: &oci_spec::image::ImageConfiguration) -> Option<&str> {
458    if let Some(labels) = labels_of(config) {
459        for k in [oci_spec::image::ANNOTATION_VERSION, LABEL_VERSION] {
460            if let Some(v) = labels.get(k) {
461                return Some(v.as_str());
462            }
463        }
464    }
465    None
466}
467
468pub mod deploy;
469mod encapsulate;
470pub use encapsulate::*;
471mod unencapsulate;
472pub use unencapsulate::*;
473pub mod skopeo;
474pub mod store;
475mod update_detachedmeta;
476pub use update_detachedmeta::*;
477
478#[cfg(test)]
479mod tests {
480    use std::process::Command;
481
482    use containers_image_proxy::ImageProxyConfig;
483
484    use super::*;
485
486    #[test]
487    fn test_serializable_transport() {
488        for v in [
489            Transport::Registry,
490            Transport::ContainerStorage,
491            Transport::OciArchive,
492            Transport::DockerArchive,
493            Transport::OciDir,
494        ] {
495            assert_eq!(Transport::try_from(v.to_string().as_ref()).unwrap(), v);
496        }
497    }
498
499    const INVALID_IRS: &[&str] = &["", "foo://", "docker:blah", "registry:", "foo:bar"];
500    const VALID_IRS: &[&str] = &[
501        "containers-storage:localhost/someimage",
502        "docker://quay.io/exampleos/blah:sometag",
503    ];
504
505    #[test]
506    fn test_imagereference() {
507        let ir: ImageReference = "registry:quay.io/exampleos/blah".try_into().unwrap();
508        assert_eq!(ir.transport, Transport::Registry);
509        assert_eq!(ir.name, "quay.io/exampleos/blah");
510        assert_eq!(ir.to_string(), "docker://quay.io/exampleos/blah");
511
512        for &v in VALID_IRS {
513            ImageReference::try_from(v).unwrap();
514        }
515
516        for &v in INVALID_IRS {
517            if ImageReference::try_from(v).is_ok() {
518                panic!("Should fail to parse: {v}")
519            }
520        }
521        struct Case {
522            s: &'static str,
523            transport: Transport,
524            name: &'static str,
525        }
526        for case in [
527            Case {
528                s: "oci:somedir",
529                transport: Transport::OciDir,
530                name: "somedir",
531            },
532            Case {
533                s: "dir:/some/dir/blah",
534                transport: Transport::Dir,
535                name: "/some/dir/blah",
536            },
537            Case {
538                s: "oci-archive:/path/to/foo.ociarchive",
539                transport: Transport::OciArchive,
540                name: "/path/to/foo.ociarchive",
541            },
542            Case {
543                s: "docker-archive:/path/to/foo.dockerarchive",
544                transport: Transport::DockerArchive,
545                name: "/path/to/foo.dockerarchive",
546            },
547            Case {
548                s: "containers-storage:localhost/someimage:blah",
549                transport: Transport::ContainerStorage,
550                name: "localhost/someimage:blah",
551            },
552        ] {
553            let ir: ImageReference = case.s.try_into().unwrap();
554            assert_eq!(ir.transport, case.transport);
555            assert_eq!(ir.name, case.name);
556            let reserialized = ir.to_string();
557            assert_eq!(case.s, reserialized.as_str());
558        }
559    }
560
561    #[test]
562    fn test_ostreeimagereference() {
563        // Test both long form `ostree-remote-image:$myremote:registry` and the
564        // shorthand `ostree-remote-registry:$myremote`.
565        let ir_s = "ostree-remote-image:myremote:registry:quay.io/exampleos/blah";
566        let ir_registry = "ostree-remote-registry:myremote:quay.io/exampleos/blah";
567        for &ir_s in &[ir_s, ir_registry] {
568            let ir: OstreeImageReference = ir_s.try_into().unwrap();
569            assert_eq!(
570                ir.sigverify,
571                SignatureSource::OstreeRemote("myremote".to_string())
572            );
573            assert_eq!(ir.imgref.transport, Transport::Registry);
574            assert_eq!(ir.imgref.name, "quay.io/exampleos/blah");
575            assert_eq!(
576                ir.to_string(),
577                "ostree-remote-image:myremote:docker://quay.io/exampleos/blah"
578            );
579        }
580
581        // Also verify our FromStr impls
582
583        let ir: OstreeImageReference = ir_s.try_into().unwrap();
584        assert_eq!(ir, OstreeImageReference::from_str(ir_s).unwrap());
585        // test our Eq implementation
586        assert_eq!(&ir, &OstreeImageReference::try_from(ir_registry).unwrap());
587
588        let ir_s = "ostree-image-signed:docker://quay.io/exampleos/blah";
589        let ir: OstreeImageReference = ir_s.try_into().unwrap();
590        assert_eq!(ir.sigverify, SignatureSource::ContainerPolicy);
591        assert_eq!(ir.imgref.transport, Transport::Registry);
592        assert_eq!(ir.imgref.name, "quay.io/exampleos/blah");
593        assert_eq!(ir.to_string(), ir_s);
594        assert_eq!(format!("{:#}", &ir), ir_s);
595
596        let ir_s = "ostree-unverified-image:docker://quay.io/exampleos/blah";
597        let ir: OstreeImageReference = ir_s.try_into().unwrap();
598        assert_eq!(ir.sigverify, SignatureSource::ContainerPolicyAllowInsecure);
599        assert_eq!(ir.imgref.transport, Transport::Registry);
600        assert_eq!(ir.imgref.name, "quay.io/exampleos/blah");
601        assert_eq!(
602            ir.to_string(),
603            "ostree-unverified-registry:quay.io/exampleos/blah"
604        );
605        let ir_shorthand =
606            OstreeImageReference::try_from("ostree-unverified-registry:quay.io/exampleos/blah")
607                .unwrap();
608        assert_eq!(&ir_shorthand, &ir);
609        assert_eq!(format!("{:#}", &ir), "docker://quay.io/exampleos/blah");
610    }
611
612    #[test]
613    fn test_merge_authopts() {
614        // Verify idempotence of authentication processing
615        let mut c = ImageProxyConfig::default();
616        let authf = std::fs::File::open("/dev/null").unwrap();
617        c.auth_data = Some(authf);
618        super::merge_default_container_proxy_opts(&mut c).unwrap();
619        assert!(!c.auth_anonymous);
620        assert!(c.authfile.is_none());
621        assert!(c.auth_data.is_some());
622        assert!(c.skopeo_cmd.is_none());
623        super::merge_default_container_proxy_opts(&mut c).unwrap();
624        assert!(!c.auth_anonymous);
625        assert!(c.authfile.is_none());
626        assert!(c.auth_data.is_some());
627        assert!(c.skopeo_cmd.is_none());
628
629        // Verify interaction with explicit skopeo command
630        let mut c = ImageProxyConfig::default();
631        c.skopeo_cmd = Some(Command::new("skopeo"));
632        super::merge_default_container_proxy_opts(&mut c).unwrap();
633        assert_eq!(c.skopeo_cmd.unwrap().get_program(), "skopeo");
634    }
635}