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