ostree_ext/container/
mod.rs

1//! # APIs bridging OSTree and container images
2//!
3//! This module contains APIs to bidirectionally map between a single OSTree commit and a container image wrapping it.
4//! Because container images are just layers of tarballs, this builds on the [`crate::tar`] module.
5//!
6//! To emphasize this, the current high level model is that this is a one-to-one mapping - an ostree commit
7//! can be exported (wrapped) into a container image, which will have exactly one layer.  Upon import
8//! back into an ostree repository, all container metadata except for its digested checksum will be discarded.
9//!
10//! ## Signatures
11//!
12//! OSTree supports GPG and ed25519 signatures natively, and it's expected by default that
13//! when booting from a fetched container image, one verifies ostree-level signatures.
14//! For ostree, a signing configuration is specified via an ostree remote.  In order to
15//! pair this configuration together, this library defines a "URL-like" string schema:
16//!
17//! `ostree-remote-registry:<remotename>:<containerimage>`
18//!
19//! A concrete instantiation might be e.g.: `ostree-remote-registry:fedora:quay.io/coreos/fedora-coreos:stable`
20//!
21//! To parse and generate these strings, see [`OstreeImageReference`].
22//!
23//! ## Layering
24//!
25//! A key feature of container images is support for layering.  At the moment, support
26//! for this is [planned but not implemented](https://github.com/ostreedev/ostree-rs-ext/issues/12).
27
28use anyhow::anyhow;
29use cap_std_ext::cap_std;
30use cap_std_ext::cap_std::fs::Dir;
31use containers_image_proxy::oci_spec;
32use ostree::glib;
33use serde::Serialize;
34
35use std::borrow::Cow;
36use std::collections::HashMap;
37use std::fmt::Debug;
38use std::ops::Deref;
39use std::str::FromStr;
40
41/// The label injected into a container image that contains the ostree commit SHA-256.
42pub const OSTREE_COMMIT_LABEL: &str = "ostree.commit";
43
44/// The name of an annotation attached to a layer which names the packages/components
45/// which are part of it.
46pub(crate) const CONTENT_ANNOTATION: &str = "ostree.components";
47/// The character we use to separate values in [`CONTENT_ANNOTATION`].
48pub(crate) const COMPONENT_SEPARATOR: char = ',';
49
50/// Our generic catchall fatal error, expected to be converted
51/// to a string to output to a terminal or logs.
52type Result<T> = anyhow::Result<T>;
53
54/// A backend/transport for OCI/Docker images.
55#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq)]
56pub enum Transport {
57    /// A remote Docker/OCI registry (`registry:` or `docker://`)
58    Registry,
59    /// A local OCI directory (`oci:`)
60    OciDir,
61    /// A local OCI archive tarball (`oci-archive:`)
62    OciArchive,
63    /// A local Docker archive tarball (`docker-archive:`)
64    DockerArchive,
65    /// Local container storage (`containers-storage:`)
66    ContainerStorage,
67    /// Local directory (`dir:`)
68    Dir,
69    /// Local Docker daemon (`docker-daemon:`)
70    DockerDaemon,
71}
72
73/// Combination of a remote image reference and transport.
74///
75/// For example,
76#[derive(Debug, Clone, Hash, PartialEq, Eq)]
77pub struct ImageReference {
78    /// The storage and transport for the image
79    pub transport: Transport,
80    /// The image name (e.g. `quay.io/somerepo/someimage:latest`)
81    pub name: String,
82}
83
84/// Policy for signature verification.
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86pub enum SignatureSource {
87    /// Fetches will use the named ostree remote for signature verification of the ostree commit.
88    OstreeRemote(String),
89    /// Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy.
90    ContainerPolicy,
91    /// NOT RECOMMENDED.  Fetches will defer to the `containers-policy.json` default which is usually `insecureAcceptAnything`.
92    ContainerPolicyAllowInsecure,
93}
94
95/// A commonly used pre-OCI label for versions.
96pub const LABEL_VERSION: &str = "version";
97
98/// Combination of a signature verification mechanism, and a standard container image reference.
99///
100#[derive(Debug, Clone, PartialEq, Eq, Hash)]
101pub struct OstreeImageReference {
102    /// The signature verification mechanism.
103    pub sigverify: SignatureSource,
104    /// The container image reference.
105    pub imgref: ImageReference,
106}
107
108impl TryFrom<&str> for Transport {
109    type Error = anyhow::Error;
110
111    fn try_from(value: &str) -> Result<Self> {
112        Ok(match value {
113            Self::REGISTRY_STR | "docker" => Self::Registry,
114            Self::OCI_STR => Self::OciDir,
115            Self::OCI_ARCHIVE_STR => Self::OciArchive,
116            Self::DOCKER_ARCHIVE_STR => Self::DockerArchive,
117            Self::CONTAINERS_STORAGE_STR => Self::ContainerStorage,
118            Self::LOCAL_DIRECTORY_STR => Self::Dir,
119            Self::DOCKER_DAEMON_STR => Self::DockerDaemon,
120            o => return Err(anyhow!("Unknown transport '{}'", o)),
121        })
122    }
123}
124
125impl Transport {
126    const OCI_STR: &'static str = "oci";
127    const OCI_ARCHIVE_STR: &'static str = "oci-archive";
128    const DOCKER_ARCHIVE_STR: &'static str = "docker-archive";
129    const CONTAINERS_STORAGE_STR: &'static str = "containers-storage";
130    const LOCAL_DIRECTORY_STR: &'static str = "dir";
131    const REGISTRY_STR: &'static str = "registry";
132    const DOCKER_DAEMON_STR: &'static str = "docker-daemon";
133
134    /// Retrieve an identifier that can then be re-parsed from [`Transport::try_from::<&str>`].
135    pub fn serializable_name(&self) -> &'static str {
136        match self {
137            Transport::Registry => Self::REGISTRY_STR,
138            Transport::OciDir => Self::OCI_STR,
139            Transport::OciArchive => Self::OCI_ARCHIVE_STR,
140            Transport::DockerArchive => Self::DOCKER_ARCHIVE_STR,
141            Transport::ContainerStorage => Self::CONTAINERS_STORAGE_STR,
142            Transport::Dir => Self::LOCAL_DIRECTORY_STR,
143            Transport::DockerDaemon => Self::DOCKER_DAEMON_STR,
144        }
145    }
146}
147
148impl TryFrom<&str> for ImageReference {
149    type Error = anyhow::Error;
150
151    fn try_from(value: &str) -> Result<Self> {
152        let (transport_name, mut name) = value
153            .split_once(':')
154            .ok_or_else(|| anyhow!("Missing ':' in {}", value))?;
155        let transport: Transport = transport_name.try_into()?;
156        if name.is_empty() {
157            return Err(anyhow!("Invalid empty name in {}", value));
158        }
159        if transport_name == "docker" {
160            name = name
161                .strip_prefix("//")
162                .ok_or_else(|| anyhow!("Missing // in docker:// in {}", value))?;
163        }
164        Ok(Self {
165            transport,
166            name: name.to_string(),
167        })
168    }
169}
170
171impl FromStr for ImageReference {
172    type Err = anyhow::Error;
173
174    fn from_str(s: &str) -> Result<Self> {
175        Self::try_from(s)
176    }
177}
178
179impl TryFrom<&str> for SignatureSource {
180    type Error = anyhow::Error;
181
182    fn try_from(value: &str) -> Result<Self> {
183        match value {
184            "ostree-image-signed" => Ok(Self::ContainerPolicy),
185            "ostree-unverified-image" => Ok(Self::ContainerPolicyAllowInsecure),
186            o => match o.strip_prefix("ostree-remote-image:") {
187                Some(rest) => Ok(Self::OstreeRemote(rest.to_string())),
188                _ => Err(anyhow!("Invalid signature source: {}", o)),
189            },
190        }
191    }
192}
193
194impl FromStr for SignatureSource {
195    type Err = anyhow::Error;
196
197    fn from_str(s: &str) -> Result<Self> {
198        Self::try_from(s)
199    }
200}
201
202impl TryFrom<&str> for OstreeImageReference {
203    type Error = anyhow::Error;
204
205    fn try_from(value: &str) -> Result<Self> {
206        let (first, second) = value
207            .split_once(':')
208            .ok_or_else(|| anyhow!("Missing ':' in {}", value))?;
209        let (sigverify, rest) = match first {
210            "ostree-image-signed" => (SignatureSource::ContainerPolicy, Cow::Borrowed(second)),
211            "ostree-unverified-image" => (
212                SignatureSource::ContainerPolicyAllowInsecure,
213                Cow::Borrowed(second),
214            ),
215            // Shorthand for ostree-unverified-image:registry:
216            "ostree-unverified-registry" => (
217                SignatureSource::ContainerPolicyAllowInsecure,
218                Cow::Owned(format!("registry:{second}")),
219            ),
220            // This is a shorthand for ostree-remote-image with registry:
221            "ostree-remote-registry" => {
222                let (remote, rest) = second
223                    .split_once(':')
224                    .ok_or_else(|| anyhow!("Missing second ':' in {}", value))?;
225                (
226                    SignatureSource::OstreeRemote(remote.to_string()),
227                    Cow::Owned(format!("registry:{rest}")),
228                )
229            }
230            "ostree-remote-image" => {
231                let (remote, rest) = second
232                    .split_once(':')
233                    .ok_or_else(|| anyhow!("Missing second ':' in {}", value))?;
234                (
235                    SignatureSource::OstreeRemote(remote.to_string()),
236                    Cow::Borrowed(rest),
237                )
238            }
239            o => {
240                return Err(anyhow!("Invalid ostree image reference scheme: {}", o));
241            }
242        };
243        let imgref = rest.deref().try_into()?;
244        Ok(Self { sigverify, imgref })
245    }
246}
247
248impl FromStr for OstreeImageReference {
249    type Err = anyhow::Error;
250
251    fn from_str(s: &str) -> Result<Self> {
252        Self::try_from(s)
253    }
254}
255
256impl std::fmt::Display for Transport {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        let s = match self {
259            // TODO once skopeo supports this, canonicalize as registry:
260            Self::Registry => "docker://",
261            Self::OciArchive => "oci-archive:",
262            Self::DockerArchive => "docker-archive:",
263            Self::OciDir => "oci:",
264            Self::ContainerStorage => "containers-storage:",
265            Self::Dir => "dir:",
266            Self::DockerDaemon => "docker-daemon:",
267        };
268        f.write_str(s)
269    }
270}
271
272impl std::fmt::Display for ImageReference {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        write!(f, "{}{}", self.transport, self.name)
275    }
276}
277
278impl std::fmt::Display for SignatureSource {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        match self {
281            SignatureSource::OstreeRemote(r) => write!(f, "ostree-remote-image:{r}"),
282            SignatureSource::ContainerPolicy => write!(f, "ostree-image-signed"),
283            SignatureSource::ContainerPolicyAllowInsecure => {
284                write!(f, "ostree-unverified-image")
285            }
286        }
287    }
288}
289
290impl std::fmt::Display for OstreeImageReference {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match (&self.sigverify, &self.imgref) {
293            (SignatureSource::ContainerPolicyAllowInsecure, imgref)
294                if imgref.transport == Transport::Registry =>
295            {
296                // Because allow-insecure is the effective default, allow formatting
297                // without it.  Note this formatting is asymmetric and cannot be
298                // re-parsed.
299                if f.alternate() {
300                    write!(f, "{}", self.imgref)
301                } else {
302                    write!(f, "ostree-unverified-registry:{}", self.imgref.name)
303                }
304            }
305            (sigverify, imgref) => {
306                write!(f, "{sigverify}:{imgref}")
307            }
308        }
309    }
310}
311
312/// Represents the difference in layer/blob content between two OCI image manifests.
313#[derive(Debug, Serialize)]
314pub struct ManifestDiff<'a> {
315    /// The source container image manifest.
316    #[serde(skip)]
317    pub from: &'a oci_spec::image::ImageManifest,
318    /// The target container image manifest.
319    #[serde(skip)]
320    pub to: &'a oci_spec::image::ImageManifest,
321    /// Layers which are present in the old image but not the new image.
322    #[serde(skip)]
323    pub removed: Vec<&'a oci_spec::image::Descriptor>,
324    /// Layers which are present in the new image but not the old image.
325    #[serde(skip)]
326    pub added: Vec<&'a oci_spec::image::Descriptor>,
327    /// Total number of layers
328    pub total: u64,
329    /// Size of total number of layers.
330    pub total_size: u64,
331    /// Number of layers removed
332    pub n_removed: u64,
333    /// Size of the number of layers removed
334    pub removed_size: u64,
335    /// Number of packages added
336    pub n_added: u64,
337    /// Size of the number of layers added
338    pub added_size: u64,
339}
340
341impl<'a> ManifestDiff<'a> {
342    /// Compute the layer difference between two OCI image manifests.
343    pub fn new(
344        src: &'a oci_spec::image::ImageManifest,
345        dest: &'a oci_spec::image::ImageManifest,
346    ) -> Self {
347        let src_layers = src
348            .layers()
349            .iter()
350            .map(|l| (l.digest().digest(), l))
351            .collect::<HashMap<_, _>>();
352        let dest_layers = dest
353            .layers()
354            .iter()
355            .map(|l| (l.digest().digest(), l))
356            .collect::<HashMap<_, _>>();
357        let mut removed = Vec::new();
358        let mut added = Vec::new();
359        for (blobid, &descriptor) in src_layers.iter() {
360            if !dest_layers.contains_key(blobid) {
361                removed.push(descriptor);
362            }
363        }
364        removed.sort_by(|a, b| a.digest().digest().cmp(b.digest().digest()));
365        for (blobid, &descriptor) in dest_layers.iter() {
366            if !src_layers.contains_key(blobid) {
367                added.push(descriptor);
368            }
369        }
370        added.sort_by(|a, b| a.digest().digest().cmp(b.digest().digest()));
371
372        fn layersum<'a, I: Iterator<Item = &'a oci_spec::image::Descriptor>>(layers: I) -> u64 {
373            layers.map(|layer| layer.size()).sum()
374        }
375        let total = dest_layers.len() as u64;
376        let total_size = layersum(dest.layers().iter());
377        let n_removed = removed.len() as u64;
378        let n_added = added.len() as u64;
379        let removed_size = layersum(removed.iter().copied());
380        let added_size = layersum(added.iter().copied());
381        ManifestDiff {
382            from: src,
383            to: dest,
384            removed,
385            added,
386            total,
387            total_size,
388            n_removed,
389            removed_size,
390            n_added,
391            added_size,
392        }
393    }
394}
395
396impl ManifestDiff<'_> {
397    /// Prints the total, removed and added content between two OCI images
398    pub fn print(&self) {
399        let print_total = self.total;
400        let print_total_size = glib::format_size(self.total_size);
401        let print_n_removed = self.n_removed;
402        let print_removed_size = glib::format_size(self.removed_size);
403        let print_n_added = self.n_added;
404        let print_added_size = glib::format_size(self.added_size);
405        println!("Total new layers: {print_total:<4}  Size: {print_total_size}");
406        println!("Removed layers:   {print_n_removed:<4}  Size: {print_removed_size}");
407        println!("Added layers:     {print_n_added:<4}  Size: {print_added_size}");
408    }
409}
410
411/// Apply default configuration for container image pulls to an existing configuration.
412/// For example, if `authfile` is not set, and `auth_anonymous` is `false`, and a global configuration file exists, it will be used.
413///
414/// If there is no configured explicit subprocess for skopeo, and the process is running
415/// as root, then a default isolation of running the process via `nobody` will be applied.
416pub fn merge_default_container_proxy_opts(
417    config: &mut containers_image_proxy::ImageProxyConfig,
418) -> Result<()> {
419    let user = rustix::process::getuid()
420        .is_root()
421        .then_some(isolation::DEFAULT_UNPRIVILEGED_USER);
422    merge_default_container_proxy_opts_with_isolation(config, user)
423}
424
425/// Apply default configuration for container image pulls, with optional support
426/// for isolation as an unprivileged user.
427pub fn merge_default_container_proxy_opts_with_isolation(
428    config: &mut containers_image_proxy::ImageProxyConfig,
429    isolation_user: Option<&str>,
430) -> Result<()> {
431    let auth_specified =
432        config.auth_anonymous || config.authfile.is_some() || config.auth_data.is_some();
433    if !auth_specified {
434        let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
435        config.auth_data = crate::globals::get_global_authfile(root)?.map(|a| a.1);
436        // If there's no auth data, then force on anonymous pulls to ensure
437        // that the container stack doesn't try to find it in the standard
438        // container paths.
439        if config.auth_data.is_none() {
440            config.auth_anonymous = true;
441        }
442    }
443    // By default, drop privileges, unless the higher level code
444    // has configured the skopeo command explicitly.
445    let isolation_user = config
446        .skopeo_cmd
447        .is_none()
448        .then_some(isolation_user.as_ref())
449        .flatten();
450    if let Some(user) = isolation_user {
451        // Read the default authfile if it exists and pass it via file descriptor
452        // which will ensure it's readable when we drop privileges.
453        if let Some(authfile) = config.authfile.take() {
454            config.auth_data = Some(std::fs::File::open(authfile)?);
455        }
456        let cmd = crate::isolation::unprivileged_subprocess("skopeo", user);
457        config.skopeo_cmd = Some(cmd);
458    }
459    Ok(())
460}
461
462/// Convenience helper to return the labels, if present.
463pub(crate) fn labels_of(
464    config: &oci_spec::image::ImageConfiguration,
465) -> Option<&HashMap<String, String>> {
466    config.config().as_ref().and_then(|c| c.labels().as_ref())
467}
468
469/// Retrieve the version number from an image configuration.
470pub fn version_for_config(config: &oci_spec::image::ImageConfiguration) -> Option<&str> {
471    if let Some(labels) = labels_of(config) {
472        for k in [oci_spec::image::ANNOTATION_VERSION, LABEL_VERSION] {
473            if let Some(v) = labels.get(k) {
474                return Some(v.as_str());
475            }
476        }
477    }
478    None
479}
480
481pub mod deploy;
482mod encapsulate;
483pub use encapsulate::*;
484mod unencapsulate;
485pub use unencapsulate::*;
486pub mod skopeo;
487pub mod store;
488mod update_detachedmeta;
489pub use update_detachedmeta::*;
490
491use crate::isolation;
492
493#[cfg(test)]
494mod tests {
495    use std::process::Command;
496
497    use containers_image_proxy::ImageProxyConfig;
498
499    use super::*;
500
501    #[test]
502    fn test_serializable_transport() {
503        for v in [
504            Transport::Registry,
505            Transport::ContainerStorage,
506            Transport::OciArchive,
507            Transport::DockerArchive,
508            Transport::OciDir,
509        ] {
510            assert_eq!(Transport::try_from(v.serializable_name()).unwrap(), v);
511        }
512    }
513
514    const INVALID_IRS: &[&str] = &["", "foo://", "docker:blah", "registry:", "foo:bar"];
515    const VALID_IRS: &[&str] = &[
516        "containers-storage:localhost/someimage",
517        "docker://quay.io/exampleos/blah:sometag",
518    ];
519
520    #[test]
521    fn test_imagereference() {
522        let ir: ImageReference = "registry:quay.io/exampleos/blah".try_into().unwrap();
523        assert_eq!(ir.transport, Transport::Registry);
524        assert_eq!(ir.name, "quay.io/exampleos/blah");
525        assert_eq!(ir.to_string(), "docker://quay.io/exampleos/blah");
526
527        for &v in VALID_IRS {
528            ImageReference::try_from(v).unwrap();
529        }
530
531        for &v in INVALID_IRS {
532            if ImageReference::try_from(v).is_ok() {
533                panic!("Should fail to parse: {v}")
534            }
535        }
536        struct Case {
537            s: &'static str,
538            transport: Transport,
539            name: &'static str,
540        }
541        for case in [
542            Case {
543                s: "oci:somedir",
544                transport: Transport::OciDir,
545                name: "somedir",
546            },
547            Case {
548                s: "dir:/some/dir/blah",
549                transport: Transport::Dir,
550                name: "/some/dir/blah",
551            },
552            Case {
553                s: "oci-archive:/path/to/foo.ociarchive",
554                transport: Transport::OciArchive,
555                name: "/path/to/foo.ociarchive",
556            },
557            Case {
558                s: "docker-archive:/path/to/foo.dockerarchive",
559                transport: Transport::DockerArchive,
560                name: "/path/to/foo.dockerarchive",
561            },
562            Case {
563                s: "containers-storage:localhost/someimage:blah",
564                transport: Transport::ContainerStorage,
565                name: "localhost/someimage:blah",
566            },
567        ] {
568            let ir: ImageReference = case.s.try_into().unwrap();
569            assert_eq!(ir.transport, case.transport);
570            assert_eq!(ir.name, case.name);
571            let reserialized = ir.to_string();
572            assert_eq!(case.s, reserialized.as_str());
573        }
574    }
575
576    #[test]
577    fn test_ostreeimagereference() {
578        // Test both long form `ostree-remote-image:$myremote:registry` and the
579        // shorthand `ostree-remote-registry:$myremote`.
580        let ir_s = "ostree-remote-image:myremote:registry:quay.io/exampleos/blah";
581        let ir_registry = "ostree-remote-registry:myremote:quay.io/exampleos/blah";
582        for &ir_s in &[ir_s, ir_registry] {
583            let ir: OstreeImageReference = ir_s.try_into().unwrap();
584            assert_eq!(
585                ir.sigverify,
586                SignatureSource::OstreeRemote("myremote".to_string())
587            );
588            assert_eq!(ir.imgref.transport, Transport::Registry);
589            assert_eq!(ir.imgref.name, "quay.io/exampleos/blah");
590            assert_eq!(
591                ir.to_string(),
592                "ostree-remote-image:myremote:docker://quay.io/exampleos/blah"
593            );
594        }
595
596        // Also verify our FromStr impls
597
598        let ir: OstreeImageReference = ir_s.try_into().unwrap();
599        assert_eq!(ir, OstreeImageReference::from_str(ir_s).unwrap());
600        // test our Eq implementation
601        assert_eq!(&ir, &OstreeImageReference::try_from(ir_registry).unwrap());
602
603        let ir_s = "ostree-image-signed:docker://quay.io/exampleos/blah";
604        let ir: OstreeImageReference = ir_s.try_into().unwrap();
605        assert_eq!(ir.sigverify, SignatureSource::ContainerPolicy);
606        assert_eq!(ir.imgref.transport, Transport::Registry);
607        assert_eq!(ir.imgref.name, "quay.io/exampleos/blah");
608        assert_eq!(ir.to_string(), ir_s);
609        assert_eq!(format!("{:#}", &ir), ir_s);
610
611        let ir_s = "ostree-unverified-image:docker://quay.io/exampleos/blah";
612        let ir: OstreeImageReference = ir_s.try_into().unwrap();
613        assert_eq!(ir.sigverify, SignatureSource::ContainerPolicyAllowInsecure);
614        assert_eq!(ir.imgref.transport, Transport::Registry);
615        assert_eq!(ir.imgref.name, "quay.io/exampleos/blah");
616        assert_eq!(
617            ir.to_string(),
618            "ostree-unverified-registry:quay.io/exampleos/blah"
619        );
620        let ir_shorthand =
621            OstreeImageReference::try_from("ostree-unverified-registry:quay.io/exampleos/blah")
622                .unwrap();
623        assert_eq!(&ir_shorthand, &ir);
624        assert_eq!(format!("{:#}", &ir), "docker://quay.io/exampleos/blah");
625    }
626
627    #[test]
628    fn test_merge_authopts() {
629        // Verify idempotence of authentication processing
630        let mut c = ImageProxyConfig::default();
631        let authf = std::fs::File::open("/dev/null").unwrap();
632        c.auth_data = Some(authf);
633        super::merge_default_container_proxy_opts_with_isolation(&mut c, None).unwrap();
634        assert!(!c.auth_anonymous);
635        assert!(c.authfile.is_none());
636        assert!(c.auth_data.is_some());
637        assert!(c.skopeo_cmd.is_none());
638        super::merge_default_container_proxy_opts_with_isolation(&mut c, None).unwrap();
639        assert!(!c.auth_anonymous);
640        assert!(c.authfile.is_none());
641        assert!(c.auth_data.is_some());
642        assert!(c.skopeo_cmd.is_none());
643
644        // Verify interaction with explicit isolation
645        let mut c = ImageProxyConfig {
646            skopeo_cmd: Some(Command::new("skopeo")),
647            ..Default::default()
648        };
649        super::merge_default_container_proxy_opts_with_isolation(&mut c, Some("foo")).unwrap();
650        assert_eq!(c.skopeo_cmd.unwrap().get_program(), "skopeo");
651    }
652}