bootc_lib/
spec.rs

1//! The definition for host system state.
2
3use std::fmt::Display;
4
5use std::str::FromStr;
6
7use anyhow::Result;
8use ostree_ext::container::Transport;
9use ostree_ext::oci_spec::distribution::Reference;
10use ostree_ext::oci_spec::image::Digest;
11use ostree_ext::{container::OstreeImageReference, oci_spec};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15use crate::bootc_composefs::boot::BootType;
16use crate::{k8sapitypes, status::Slot};
17
18const API_VERSION: &str = "org.containers.bootc/v1";
19const KIND: &str = "BootcHost";
20/// The default object name we use; there's only one.
21pub(crate) const OBJECT_NAME: &str = "host";
22
23#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
24#[serde(rename_all = "camelCase")]
25/// The core host definition
26pub struct Host {
27    /// Metadata
28    #[serde(flatten)]
29    pub resource: k8sapitypes::Resource,
30    /// The spec
31    #[serde(default)]
32    pub spec: HostSpec,
33    /// The status
34    #[serde(default)]
35    pub status: HostStatus,
36}
37
38/// Configuration for system boot ordering.
39
40#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
41#[serde(rename_all = "camelCase")]
42pub enum BootOrder {
43    /// The staged or booted deployment will be booted next
44    #[default]
45    Default,
46    /// The rollback deployment will be booted next
47    Rollback,
48}
49
50#[derive(
51    clap::ValueEnum, Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, Default,
52)]
53#[serde(rename_all = "camelCase")]
54/// The container storage backend
55pub enum Store {
56    /// Use the ostree-container storage backend.
57    #[default]
58    #[value(alias = "ostreecontainer")] // default is kebab-case
59    OstreeContainer,
60}
61
62#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
63#[serde(rename_all = "camelCase")]
64/// The host specification
65pub struct HostSpec {
66    /// The host image
67    pub image: Option<ImageReference>,
68    /// If set, and there is a rollback deployment, it will be set for the next boot.
69    #[serde(default)]
70    pub boot_order: BootOrder,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
74/// An image signature
75#[serde(rename_all = "camelCase")]
76pub enum ImageSignature {
77    /// Fetches will use the named ostree remote for signature verification of the ostree commit.
78    OstreeRemote(String),
79    /// Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy.
80    ContainerPolicy,
81    /// No signature verification will be performed
82    Insecure,
83}
84
85/// A container image reference with attached transport and signature verification
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
87#[serde(rename_all = "camelCase")]
88pub struct ImageReference {
89    /// The container image reference
90    pub image: String,
91    /// The container image transport
92    pub transport: String,
93    /// Signature verification type
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub signature: Option<ImageSignature>,
96}
97
98/// If the reference is in :tag@digest form, strip the tag.
99fn canonicalize_reference(reference: Reference) -> Option<Reference> {
100    // No tag? Just pass through.
101    reference.tag()?;
102
103    // No digest? Also pass through.
104    let digest = reference.digest()?;
105    // Otherwise, replace with the digest
106    Some(reference.clone_with_digest(digest.to_owned()))
107}
108
109impl ImageReference {
110    /// Returns a canonicalized version of this image reference, preferring the digest over the tag if both are present.
111    pub fn canonicalize(self) -> Result<Self> {
112        // TODO maintain a proper transport enum in the spec here
113        let transport = Transport::try_from(self.transport.as_str())?;
114        match transport {
115            Transport::Registry => {
116                let reference: oci_spec::distribution::Reference = self.image.parse()?;
117
118                // Check if the image reference needs canonicicalization
119                let Some(reference) = canonicalize_reference(reference) else {
120                    return Ok(self);
121                };
122
123                let r = ImageReference {
124                    image: reference.to_string(),
125                    transport: self.transport.clone(),
126                    signature: self.signature.clone(),
127                };
128                Ok(r)
129            }
130            _ => {
131                // For other transports, we don't do any canonicalization
132                Ok(self)
133            }
134        }
135    }
136
137    /// Parse the transport string into a Transport enum.
138    pub fn transport(&self) -> Result<Transport> {
139        Transport::try_from(self.transport.as_str())
140            .map_err(|e| anyhow::anyhow!("Invalid transport '{}': {}", self.transport, e))
141    }
142
143    /// Convert to a container reference string suitable for use with container storage APIs.
144    /// For registry transport, returns just the image name. For other transports, prepends the transport.
145    pub fn to_transport_image(&self) -> Result<String> {
146        if self.transport()? == Transport::Registry {
147            // For registry transport, the image name is already in the right format
148            Ok(self.image.clone())
149        } else {
150            // For other transports (containers-storage, oci, etc.), prepend the transport
151            Ok(format!("{}:{}", self.transport, self.image))
152        }
153    }
154}
155
156/// The status of the booted image
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
158#[serde(rename_all = "camelCase")]
159pub struct ImageStatus {
160    /// The currently booted image
161    pub image: ImageReference,
162    /// The version string, if any
163    pub version: Option<String>,
164    /// The build timestamp, if any
165    pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
166    /// The digest of the fetched image (e.g. sha256:a0...);
167    pub image_digest: String,
168    /// The hardware architecture of this image
169    pub architecture: String,
170}
171
172/// A bootable entry
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
174#[serde(rename_all = "camelCase")]
175pub struct BootEntryOstree {
176    /// The name of the storage for /etc and /var content
177    pub stateroot: String,
178    /// The ostree commit checksum
179    pub checksum: String,
180    /// The deployment serial
181    pub deploy_serial: u32,
182}
183
184/// Bootloader type to determine whether system was booted via Grub or Systemd
185#[derive(
186    clap::ValueEnum, Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
187)]
188pub enum Bootloader {
189    /// Use Grub as the bootloader
190    #[default]
191    Grub,
192    /// Use SystemdBoot as the bootloader
193    Systemd,
194}
195
196impl Display for Bootloader {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        let string = match self {
199            Bootloader::Grub => "grub",
200            Bootloader::Systemd => "systemd",
201        };
202
203        write!(f, "{}", string)
204    }
205}
206
207impl FromStr for Bootloader {
208    type Err = anyhow::Error;
209
210    fn from_str(value: &str) -> Result<Self> {
211        match value {
212            "grub" => Ok(Self::Grub),
213            "systemd" => Ok(Self::Systemd),
214            unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")),
215        }
216    }
217}
218
219/// A bootable entry
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
221#[serde(rename_all = "camelCase")]
222pub struct BootEntryComposefs {
223    /// The erofs verity
224    pub verity: String,
225    /// Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry
226    pub boot_type: BootType,
227    /// Whether we boot using systemd or grub
228    pub bootloader: Bootloader,
229    /// The sha256sum of vmlinuz + initrd
230    /// Only `Some` for Type1 boot entries
231    pub boot_digest: Option<String>,
232}
233
234/// A bootable entry
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
236#[serde(rename_all = "camelCase")]
237pub struct BootEntry {
238    /// The image reference
239    pub image: Option<ImageStatus>,
240    /// The last fetched cached update metadata
241    pub cached_update: Option<ImageStatus>,
242    /// Whether this boot entry is not compatible (has origin changes bootc does not understand)
243    pub incompatible: bool,
244    /// Whether this entry will be subject to garbage collection
245    pub pinned: bool,
246    /// This is true if (relative to the booted system) this is a possible target for a soft reboot
247    #[serde(default)]
248    pub soft_reboot_capable: bool,
249    /// Whether this deployment is in download-only mode (prevented from automatic finalization on shutdown).
250    /// This is set via --download-only on the CLI.
251    #[serde(default)]
252    pub download_only: bool,
253    /// The container storage backend
254    #[serde(default)]
255    pub store: Option<Store>,
256    /// If this boot entry is ostree based, the corresponding state
257    pub ostree: Option<BootEntryOstree>,
258    /// If this boot entry is composefs based, the corresponding state
259    pub composefs: Option<BootEntryComposefs>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
263#[serde(rename_all = "camelCase")]
264#[non_exhaustive]
265/// The detected type of running system.  Note that this is not exhaustive
266/// and new variants may be added in the future.
267pub enum HostType {
268    /// The current system is deployed in a bootc compatible way.
269    BootcHost,
270}
271
272/// The status of the host system
273#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
274#[serde(rename_all = "camelCase")]
275pub struct HostStatus {
276    /// The staged image for the next boot
277    pub staged: Option<BootEntry>,
278    /// The booted image; this will be unset if the host is not bootc compatible.
279    pub booted: Option<BootEntry>,
280    /// The previously booted image
281    pub rollback: Option<BootEntry>,
282    /// Other deployments (i.e. pinned)
283    #[serde(skip_serializing_if = "Vec::is_empty")]
284    #[serde(default)]
285    pub other_deployments: Vec<BootEntry>,
286    /// Set to true if the rollback entry is queued for the next boot.
287    #[serde(default)]
288    pub rollback_queued: bool,
289
290    /// The detected type of system
291    #[serde(rename = "type")]
292    pub ty: Option<HostType>,
293}
294
295pub(crate) struct DeploymentEntry<'a> {
296    pub(crate) ty: Option<Slot>,
297    pub(crate) deployment: &'a BootEntryComposefs,
298    pub(crate) pinned: bool,
299    pub(crate) soft_reboot_capable: bool,
300}
301
302/// The result of a `bootc container inspect` command.
303#[derive(Debug, Serialize)]
304#[serde(rename_all = "kebab-case")]
305pub(crate) struct ContainerInspect {
306    /// Kernel arguments embedded in the container image.
307    pub(crate) kargs: Vec<String>,
308    /// Information about the kernel in the container image.
309    pub(crate) kernel: Option<crate::kernel::Kernel>,
310}
311
312impl Host {
313    /// Create a new host
314    pub fn new(spec: HostSpec) -> Self {
315        let metadata = k8sapitypes::ObjectMeta {
316            name: Some(OBJECT_NAME.to_owned()),
317            ..Default::default()
318        };
319        Self {
320            resource: k8sapitypes::Resource {
321                api_version: API_VERSION.to_owned(),
322                kind: KIND.to_owned(),
323                metadata,
324            },
325            spec,
326            status: Default::default(),
327        }
328    }
329
330    /// Filter out the requested slot
331    pub fn filter_to_slot(&mut self, slot: Slot) {
332        match slot {
333            Slot::Staged => {
334                self.status.booted = None;
335                self.status.rollback = None;
336            }
337            Slot::Booted => {
338                self.status.staged = None;
339                self.status.rollback = None;
340            }
341            Slot::Rollback => {
342                self.status.staged = None;
343                self.status.booted = None;
344            }
345        }
346    }
347
348    pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
349        let cfs = self
350            .status
351            .booted
352            .as_ref()
353            .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
354            .require_composefs()?;
355
356        Ok(cfs)
357    }
358
359    /// Returns all composefs deployments in a list
360    #[fn_error_context::context("Getting all composefs deployments")]
361    pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
362        let mut all_deps = vec![];
363
364        let booted = self.require_composefs_booted()?;
365        all_deps.push(DeploymentEntry {
366            ty: Some(Slot::Booted),
367            deployment: booted,
368            pinned: false,
369            soft_reboot_capable: false,
370        });
371
372        if let Some(staged) = &self.status.staged {
373            all_deps.push(DeploymentEntry {
374                ty: Some(Slot::Staged),
375                deployment: staged.require_composefs()?,
376                pinned: false,
377                soft_reboot_capable: staged.soft_reboot_capable,
378            });
379        }
380
381        if let Some(rollback) = &self.status.rollback {
382            all_deps.push(DeploymentEntry {
383                ty: Some(Slot::Rollback),
384                deployment: rollback.require_composefs()?,
385                pinned: false,
386                soft_reboot_capable: rollback.soft_reboot_capable,
387            });
388        }
389
390        for pinned in &self.status.other_deployments {
391            all_deps.push(DeploymentEntry {
392                ty: None,
393                deployment: pinned.require_composefs()?,
394                pinned: true,
395                soft_reboot_capable: pinned.soft_reboot_capable,
396            });
397        }
398
399        Ok(all_deps)
400    }
401}
402
403impl Default for Host {
404    fn default() -> Self {
405        Self::new(Default::default())
406    }
407}
408
409impl HostSpec {
410    /// Validate a spec state transition; some changes cannot be made simultaneously,
411    /// such as fetching a new image and doing a rollback.
412    pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
413        let rollback = self.boot_order != new.boot_order;
414        let image_change = self.image != new.image;
415        if rollback && image_change {
416            anyhow::bail!("Invalid state transition: rollback and image change");
417        }
418        Ok(())
419    }
420}
421
422impl BootOrder {
423    pub(crate) fn swap(&self) -> Self {
424        match self {
425            BootOrder::Default => BootOrder::Rollback,
426            BootOrder::Rollback => BootOrder::Default,
427        }
428    }
429}
430
431impl Display for ImageReference {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        // For the default of fetching from a remote registry, just output the image name
434        if f.alternate() && self.signature.is_none() && self.transport == "registry" {
435            self.image.fmt(f)
436        } else {
437            let ostree_imgref = OstreeImageReference::from(self.clone());
438            ostree_imgref.fmt(f)
439        }
440    }
441}
442
443impl ImageStatus {
444    pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
445        use std::str::FromStr;
446        Ok(Digest::from_str(&self.image_digest)?)
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use std::str::FromStr;
453
454    use super::*;
455
456    #[test]
457    fn test_canonicalize_reference() {
458        // expand this
459        let passthrough = [
460            ("quay.io/example/someimage:latest"),
461            ("quay.io/example/someimage"),
462            ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
463        ];
464        let mapped = [
465            (
466                "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
467                "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
468            ),
469            (
470                "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
471                "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
472            ),
473        ];
474        for &v in passthrough.iter() {
475            let reference = Reference::from_str(v).unwrap();
476            assert!(reference.tag().is_none() || reference.digest().is_none());
477            assert!(canonicalize_reference(reference).is_none());
478        }
479        for &(initial, expected) in mapped.iter() {
480            let reference = Reference::from_str(initial).unwrap();
481            assert!(reference.tag().is_some());
482            assert!(reference.digest().is_some());
483            let canonicalized = canonicalize_reference(reference).unwrap();
484            assert_eq!(canonicalized.to_string(), expected);
485        }
486    }
487
488    #[test]
489    fn test_image_reference_canonicalize() {
490        let sample_digest =
491            "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
492
493        let test_cases = [
494            // When both a tag and digest are present, the digest should be used
495            (
496                format!("quay.io/example/someimage:latest@{sample_digest}"),
497                format!("quay.io/example/someimage@{sample_digest}"),
498                "registry",
499            ),
500            // When only a digest is present, it should be used
501            (
502                format!("quay.io/example/someimage@{sample_digest}"),
503                format!("quay.io/example/someimage@{sample_digest}"),
504                "registry",
505            ),
506            // When only a tag is present, it should be preserved
507            (
508                "quay.io/example/someimage:latest".to_string(),
509                "quay.io/example/someimage:latest".to_string(),
510                "registry",
511            ),
512            // When no tag or digest is present, preserve the original image name
513            (
514                "quay.io/example/someimage".to_string(),
515                "quay.io/example/someimage".to_string(),
516                "registry",
517            ),
518            // When used with a local image (i.e. from containers-storage), the functionality should
519            // be the same as previous cases
520            (
521                "localhost/someimage:latest".to_string(),
522                "localhost/someimage:latest".to_string(),
523                "registry",
524            ),
525            (
526                format!("localhost/someimage:latest@{sample_digest}"),
527                format!("localhost/someimage@{sample_digest}"),
528                "registry",
529            ),
530            // Other cases are not canonicalized
531            (
532                format!("quay.io/example/someimage:latest@{sample_digest}"),
533                format!("quay.io/example/someimage:latest@{sample_digest}"),
534                "containers-storage",
535            ),
536            (
537                "/path/to/dir:latest".to_string(),
538                "/path/to/dir:latest".to_string(),
539                "oci",
540            ),
541            (
542                "/tmp/repo".to_string(),
543                "/tmp/repo".to_string(),
544                "oci-archive",
545            ),
546            (
547                "/tmp/image-dir".to_string(),
548                "/tmp/image-dir".to_string(),
549                "dir",
550            ),
551        ];
552
553        for (initial, expected, transport) in test_cases {
554            let imgref = ImageReference {
555                image: initial.to_string(),
556                transport: transport.to_string(),
557                signature: None,
558            };
559
560            let canonicalized = imgref.canonicalize();
561            if let Err(e) = canonicalized {
562                panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
563            }
564            let canonicalized = canonicalized.unwrap();
565            assert_eq!(
566                canonicalized.image, expected,
567                "Mismatch for transport {transport}"
568            );
569            assert_eq!(canonicalized.transport, transport);
570            assert_eq!(canonicalized.signature, None);
571        }
572    }
573
574    #[test]
575    fn test_unimplemented_oci_tagged_digested() {
576        let imgref = ImageReference {
577            image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
578            transport: "oci".to_string(),
579            signature: None
580        };
581        let canonicalized = imgref.clone().canonicalize().unwrap();
582        // TODO For now this is known to incorrectly pass
583        assert_eq!(imgref, canonicalized);
584    }
585
586    #[test]
587    fn test_parse_spec_v1_null() {
588        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
589        let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
590        assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
591    }
592
593    #[test]
594    fn test_parse_spec_v1a1_orig() {
595        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
596        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
597        assert_eq!(
598            host.spec.image.as_ref().unwrap().image.as_str(),
599            "quay.io/example/someimage:latest"
600        );
601    }
602
603    #[test]
604    fn test_parse_spec_v1a1() {
605        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
606        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
607        assert_eq!(
608            host.spec.image.as_ref().unwrap().image.as_str(),
609            "quay.io/otherexample/otherimage:latest"
610        );
611        assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
612    }
613
614    #[test]
615    fn test_parse_ostreeremote() {
616        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
617        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
618        assert_eq!(
619            host.spec.image.as_ref().unwrap().signature,
620            Some(ImageSignature::OstreeRemote("fedora".into()))
621        );
622    }
623
624    #[test]
625    fn test_display_imgref() {
626        let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
627        let s = OstreeImageReference::from_str(src).unwrap();
628        let s = ImageReference::from(s);
629        let displayed = format!("{s}");
630        assert_eq!(displayed.as_str(), src);
631        // Alternative display should be short form
632        assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
633
634        let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
635        let s = OstreeImageReference::from_str(src).unwrap();
636        let s = ImageReference::from(s);
637        let displayed = format!("{s}");
638        assert_eq!(displayed.as_str(), src);
639        assert_eq!(format!("{s:#}"), src);
640    }
641
642    #[test]
643    fn test_store_from_str() {
644        use clap::ValueEnum;
645
646        // should be case-insensitive, kebab-case optional
647        assert!(Store::from_str("Ostree-Container", true).is_ok());
648        assert!(Store::from_str("OstrEeContAiner", true).is_ok());
649        assert!(Store::from_str("invalid", true).is_err());
650    }
651
652    #[test]
653    fn test_host_filter_to_slot() {
654        fn create_host() -> Host {
655            let mut host = Host::default();
656            host.status.staged = Some(default_boot_entry());
657            host.status.booted = Some(default_boot_entry());
658            host.status.rollback = Some(default_boot_entry());
659            host
660        }
661
662        fn default_boot_entry() -> BootEntry {
663            BootEntry {
664                image: None,
665                cached_update: None,
666                incompatible: false,
667                soft_reboot_capable: false,
668                pinned: false,
669                download_only: false,
670                store: None,
671                ostree: None,
672                composefs: None,
673            }
674        }
675
676        fn assert_host_state(
677            host: &Host,
678            staged: Option<BootEntry>,
679            booted: Option<BootEntry>,
680            rollback: Option<BootEntry>,
681        ) {
682            assert_eq!(host.status.staged, staged);
683            assert_eq!(host.status.booted, booted);
684            assert_eq!(host.status.rollback, rollback);
685        }
686
687        let mut host = create_host();
688        host.filter_to_slot(Slot::Staged);
689        assert_host_state(&host, Some(default_boot_entry()), None, None);
690
691        let mut host = create_host();
692        host.filter_to_slot(Slot::Booted);
693        assert_host_state(&host, None, Some(default_boot_entry()), None);
694
695        let mut host = create_host();
696        host.filter_to_slot(Slot::Rollback);
697        assert_host_state(&host, None, None, Some(default_boot_entry()));
698    }
699
700    #[test]
701    fn test_to_transport_image() {
702        // Test registry transport (should return only the image name)
703        let registry_ref = ImageReference {
704            transport: "registry".to_string(),
705            image: "quay.io/example/foo:latest".to_string(),
706            signature: None,
707        };
708        assert_eq!(
709            registry_ref.to_transport_image().unwrap(),
710            "quay.io/example/foo:latest"
711        );
712
713        // Test containers-storage transport
714        let storage_ref = ImageReference {
715            transport: "containers-storage".to_string(),
716            image: "localhost/bootc".to_string(),
717            signature: None,
718        };
719        assert_eq!(
720            storage_ref.to_transport_image().unwrap(),
721            "containers-storage:localhost/bootc"
722        );
723
724        // Test oci transport
725        let oci_ref = ImageReference {
726            transport: "oci".to_string(),
727            image: "/path/to/image".to_string(),
728            signature: None,
729        };
730        assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
731    }
732}