Skip to main content

bootc_lib/
cli.rs

1//! # Bootable container image CLI
2//!
3//! Command line tool to manage bootable ostree-based containers.
4
5use std::ffi::{CString, OsStr, OsString};
6use std::fs::File;
7use std::io::{BufWriter, Seek, SeekFrom};
8use std::os::fd::AsFd;
9use std::os::unix::process::CommandExt;
10use std::process::Command;
11
12use anyhow::{Context, Result, anyhow, ensure};
13use camino::{Utf8Path, Utf8PathBuf};
14use cap_std_ext::cap_std;
15use cap_std_ext::cap_std::fs::Dir;
16use clap::CommandFactory;
17use clap::Parser;
18use clap::ValueEnum;
19use composefs::dumpfile;
20use composefs::fsverity;
21use composefs::fsverity::FsVerityHashValue;
22use composefs_ctl::composefs;
23use composefs_ctl::composefs_boot;
24use composefs_ctl::composefs_oci;
25
26use composefs_boot::BootOps as _;
27use etc_merge::{compute_diff, print_diff};
28use fn_error_context::context;
29use indoc::indoc;
30use ocidir::cap_std::ambient_authority;
31use ostree::gio;
32use ostree_container::store::PrepareResult;
33use ostree_ext::container as ostree_container;
34
35use ostree_ext::keyfileext::KeyFileExt;
36use ostree_ext::ostree;
37use ostree_ext::sysroot::SysrootLock;
38use schemars::schema_for;
39use serde::{Deserialize, Serialize};
40
41use crate::bootc_composefs::delete::delete_composefs_deployment;
42use crate::bootc_composefs::gc::{GCOpts, composefs_gc};
43use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot};
44use crate::bootc_composefs::{
45    digest::{compute_composefs_digest, new_temp_composefs_repo},
46    finalize::{composefs_backend_finalize, get_etc_diff},
47    rollback::composefs_rollback,
48    state::composefs_usr_overlay,
49    switch::switch_composefs,
50    update::upgrade_composefs,
51};
52use crate::deploy::{MergeState, RequiredHostSpec};
53use crate::podstorage::set_additional_image_store;
54use crate::progress_jsonl::{ProgressWriter, RawProgressFd};
55use crate::spec::FilesystemOverlayAccessMode;
56use crate::spec::Host;
57use crate::spec::ImageReference;
58use crate::status::get_host;
59use crate::store::{BootedOstree, Storage};
60use crate::store::{BootedStorage, BootedStorageKind};
61use crate::utils::sigpolicy_from_opt;
62use crate::{bootc_composefs, lints};
63
64/// Shared progress options
65#[derive(Debug, Parser, PartialEq, Eq)]
66pub(crate) struct ProgressOptions {
67    /// File descriptor number which must refer to an open pipe.
68    ///
69    /// Progress is written as JSON lines to this file descriptor.
70    #[clap(long, hide = true)]
71    pub(crate) progress_fd: Option<RawProgressFd>,
72}
73
74impl TryFrom<ProgressOptions> for ProgressWriter {
75    type Error = anyhow::Error;
76
77    fn try_from(value: ProgressOptions) -> Result<Self> {
78        let r = value
79            .progress_fd
80            .map(TryInto::try_into)
81            .transpose()?
82            .unwrap_or_default();
83        Ok(r)
84    }
85}
86
87/// Perform an upgrade operation
88#[derive(Debug, Parser, PartialEq, Eq)]
89pub(crate) struct UpgradeOpts {
90    /// Don't display progress
91    #[clap(long)]
92    pub(crate) quiet: bool,
93
94    /// Check if an update is available without applying it.
95    ///
96    /// This only downloads updated metadata, not the full image layers.
97    #[clap(long, conflicts_with = "apply")]
98    pub(crate) check: bool,
99
100    /// Restart or reboot into the new target image.
101    ///
102    /// Currently, this always reboots. Future versions may support userspace-only restart.
103    #[clap(long, conflicts_with = "check")]
104    pub(crate) apply: bool,
105
106    /// Configure soft reboot behavior.
107    ///
108    /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot.
109    #[clap(long = "soft-reboot", conflicts_with = "check")]
110    pub(crate) soft_reboot: Option<SoftRebootMode>,
111
112    /// Download and stage the update without applying it.
113    ///
114    /// Download the update and ensure it's retained on disk for the lifetime of this system boot,
115    /// but it will not be applied on reboot. If the system is rebooted without applying the update,
116    /// the image will be eligible for garbage collection again.
117    #[clap(long, conflicts_with_all = ["check", "apply"])]
118    pub(crate) download_only: bool,
119
120    /// Apply a staged deployment that was previously downloaded with --download-only.
121    ///
122    /// This unlocks the staged deployment without fetching updates from the container image source.
123    /// The deployment will be applied on the next shutdown or reboot. Use with --apply to
124    /// reboot immediately.
125    #[clap(long, conflicts_with_all = ["check", "download_only"])]
126    pub(crate) from_downloaded: bool,
127
128    /// Upgrade to a different tag of the currently booted image.
129    ///
130    /// This derives the target image by replacing the tag portion of the current
131    /// booted image reference.
132    #[clap(long)]
133    pub(crate) tag: Option<String>,
134
135    #[clap(flatten)]
136    pub(crate) progress: ProgressOptions,
137}
138
139/// Perform an switch operation
140#[derive(Debug, Parser, PartialEq, Eq)]
141pub(crate) struct SwitchOpts {
142    /// Don't display progress
143    #[clap(long)]
144    pub(crate) quiet: bool,
145
146    /// Restart or reboot into the new target image.
147    ///
148    /// Currently, this always reboots. Future versions may support userspace-only restart.
149    #[clap(long)]
150    pub(crate) apply: bool,
151
152    /// Configure soft reboot behavior.
153    ///
154    /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot.
155    #[clap(long = "soft-reboot")]
156    pub(crate) soft_reboot: Option<SoftRebootMode>,
157
158    /// The transport; e.g. registry, oci, oci-archive, docker-daemon, containers-storage.  Defaults to `registry`.
159    #[clap(long, default_value = "registry")]
160    pub(crate) transport: String,
161
162    /// This argument is deprecated and does nothing.
163    #[clap(long, hide = true)]
164    pub(crate) no_signature_verification: bool,
165
166    /// This is the inverse of the previous `--target-no-signature-verification` (which is now
167    /// a no-op).
168    ///
169    /// Enabling this option enforces that `containers-policy.json` (see `man
170    /// containers-policy.json` for the full search path) includes a default
171    /// policy which requires signatures.
172    #[clap(long)]
173    pub(crate) enforce_container_sigpolicy: bool,
174
175    /// Don't create a new deployment, but directly mutate the booted state.
176    /// This is hidden because it's not something we generally expect to be done,
177    /// but this can be used in e.g. Anaconda %post to fixup
178    #[clap(long, hide = true)]
179    pub(crate) mutate_in_place: bool,
180
181    /// Retain reference to currently booted image
182    #[clap(long)]
183    pub(crate) retain: bool,
184
185    /// Use unified storage path to pull images (experimental)
186    ///
187    /// When enabled, this uses bootc's container storage (/usr/lib/bootc/storage) to pull
188    /// the image first, then imports it from there. This is the same approach used for
189    /// logically bound images.
190    #[clap(long = "experimental-unified-storage", hide = true)]
191    pub(crate) unified_storage_exp: bool,
192
193    /// Target image to use for the next boot.
194    pub(crate) target: String,
195
196    #[clap(flatten)]
197    pub(crate) progress: ProgressOptions,
198}
199
200/// Options controlling rollback
201#[derive(Debug, Parser, PartialEq, Eq)]
202pub(crate) struct RollbackOpts {
203    /// Restart or reboot into the rollback image.
204    ///
205    /// Currently, this option always reboots.  In the future this command
206    /// will detect the case where no kernel changes are queued, and perform
207    /// a userspace-only restart.
208    #[clap(long)]
209    pub(crate) apply: bool,
210
211    /// Configure soft reboot behavior.
212    ///
213    /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot.
214    #[clap(long = "soft-reboot")]
215    pub(crate) soft_reboot: Option<SoftRebootMode>,
216}
217
218/// Perform an edit operation
219#[derive(Debug, Parser, PartialEq, Eq)]
220pub(crate) struct EditOpts {
221    /// Use filename to edit system specification
222    #[clap(long, short = 'f')]
223    pub(crate) filename: Option<String>,
224
225    /// Don't display progress
226    #[clap(long)]
227    pub(crate) quiet: bool,
228}
229
230#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
231#[clap(rename_all = "lowercase")]
232pub(crate) enum OutputFormat {
233    /// Output in Human Readable format.
234    HumanReadable,
235    /// Output in YAML format.
236    Yaml,
237    /// Output in JSON format.
238    Json,
239}
240
241#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
242#[clap(rename_all = "lowercase")]
243pub(crate) enum SoftRebootMode {
244    /// Require a soft reboot; fail if not possible
245    Required,
246    /// Automatically use soft reboot if possible, otherwise use regular reboot
247    Auto,
248}
249
250/// Perform an status operation
251#[derive(Debug, Parser, PartialEq, Eq)]
252pub(crate) struct StatusOpts {
253    /// Output in JSON format.
254    ///
255    /// Superceded by the `format` option.
256    #[clap(long, hide = true)]
257    pub(crate) json: bool,
258
259    /// The output format.
260    #[clap(long)]
261    pub(crate) format: Option<OutputFormat>,
262
263    /// The desired format version. There is currently one supported
264    /// version, which is exposed as both `0` and `1`. Pass this
265    /// option to explicitly request it; it is possible that another future
266    /// version 2 or newer will be supported in the future.
267    #[clap(long)]
268    pub(crate) format_version: Option<u32>,
269
270    /// Only display status for the booted deployment.
271    #[clap(long)]
272    pub(crate) booted: bool,
273
274    /// Include additional fields in human readable format.
275    #[clap(long, short = 'v')]
276    pub(crate) verbose: bool,
277}
278
279/// Add a transient overlayfs on /usr
280#[derive(Debug, Parser, PartialEq, Eq)]
281pub(crate) struct UsrOverlayOpts {
282    /// Mount the overlayfs as read-only. A read-only overlayfs is useful since it may be remounted
283    /// as read/write in a private mount namespace and written to while the mount point remains
284    /// read-only to the rest of the system.
285    #[clap(long)]
286    pub(crate) read_only: bool,
287}
288
289#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
290pub(crate) enum InstallOpts {
291    /// Install to the target block device.
292    ///
293    /// This command must be invoked inside of the container, which will be
294    /// installed. The container must be run in `--privileged` mode, and hence
295    /// will be able to see all block devices on the system.
296    ///
297    /// The default storage layout uses the root filesystem type configured
298    /// in the container image, alongside any required system partitions such as
299    /// the EFI system partition. Use `install to-filesystem` for anything more
300    /// complex such as RAID, LVM, LUKS etc.
301    #[cfg(feature = "install-to-disk")]
302    ToDisk(crate::install::InstallToDiskOpts),
303    /// Install to an externally created filesystem structure.
304    ///
305    /// In this variant of installation, the root filesystem alongside any necessary
306    /// platform partitions (such as the EFI system partition) are prepared and mounted by an
307    /// external tool or script. The root filesystem is currently expected to be empty
308    /// by default.
309    ToFilesystem(crate::install::InstallToFilesystemOpts),
310    /// Install to the host root filesystem.
311    ///
312    /// This is a variant of `install to-filesystem` that is designed to install "alongside"
313    /// the running host root filesystem. Currently, the host root filesystem's `/boot` partition
314    /// will be wiped, but the content of the existing root will otherwise be retained, and will
315    /// need to be cleaned up if desired when rebooted into the new root.
316    ToExistingRoot(crate::install::InstallToExistingRootOpts),
317    /// Nondestructively create a fresh installation state inside an existing bootc system.
318    ///
319    /// This is a nondestructive variant of `install to-existing-root` that works only inside
320    /// an existing bootc system.
321    #[clap(hide = true)]
322    Reset(crate::install::InstallResetOpts),
323    /// Execute this as the penultimate step of an installation using `install to-filesystem`.
324    ///
325    Finalize {
326        /// Path to the mounted root filesystem.
327        root_path: Utf8PathBuf,
328    },
329    /// Intended for use in environments that are performing an ostree-based installation, not bootc.
330    ///
331    /// In this scenario the installation may be missing bootc specific features such as
332    /// kernel arguments, logically bound images and more. This command can be used to attempt
333    /// to reconcile. At the current time, the only tested environment is Anaconda using `ostreecontainer`
334    /// and it is recommended to avoid usage outside of that environment. Instead, ensure your
335    /// code is using `bootc install to-filesystem` from the start.
336    EnsureCompletion {},
337    /// Output JSON to stdout that contains the merged installation configuration
338    /// as it may be relevant to calling processes using `install to-filesystem`
339    /// that in particular want to discover the desired root filesystem type from the container image.
340    ///
341    /// At the current time, the only output key is `root-fs-type` which is a string-valued
342    /// filesystem name suitable for passing to `mkfs.$type`.
343    PrintConfiguration(crate::install::InstallPrintConfigurationOpts),
344}
345
346/// Subcommands which can be executed as part of a container build.
347#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
348pub(crate) enum ContainerOpts {
349    /// Output information about the container image.
350    ///
351    /// By default, a human-readable summary is output. Use --json or --format
352    /// to change the output format.
353    Inspect {
354        /// Operate on the provided rootfs.
355        #[clap(long, default_value = "/")]
356        rootfs: Utf8PathBuf,
357
358        /// Output in JSON format.
359        #[clap(long)]
360        json: bool,
361
362        /// The output format.
363        #[clap(long, conflicts_with = "json")]
364        format: Option<OutputFormat>,
365    },
366    /// Perform relatively inexpensive static analysis checks as part of a container
367    /// build.
368    ///
369    /// This is intended to be invoked via e.g. `RUN bootc container lint` as part
370    /// of a build process; it will error if any problems are detected.
371    Lint {
372        /// Operate on the provided rootfs.
373        #[clap(long, default_value = "/")]
374        rootfs: Utf8PathBuf,
375
376        /// Make warnings fatal.
377        #[clap(long)]
378        fatal_warnings: bool,
379
380        /// Instead of executing the lints, just print all available lints.
381        /// At the current time, this will output in YAML format because it's
382        /// reasonably human friendly. However, there is no commitment to
383        /// maintaining this exact format; do not parse it via code or scripts.
384        #[clap(long)]
385        list: bool,
386
387        /// Skip checking the targeted lints, by name. Use `--list` to discover the set
388        /// of available lints.
389        ///
390        /// Example: --skip nonempty-boot --skip baseimage-root
391        #[clap(long)]
392        skip: Vec<String>,
393
394        /// Don't truncate the output. By default, only a limited number of entries are
395        /// shown for each lint, followed by a count of remaining entries.
396        #[clap(long)]
397        no_truncate: bool,
398    },
399    /// Output the bootable composefs digest for a directory.
400    #[clap(hide = true)]
401    ComputeComposefsDigest {
402        /// Path to the filesystem root
403        #[clap(default_value = "/target")]
404        path: Utf8PathBuf,
405
406        /// Additionally generate a dumpfile written to the target path
407        #[clap(long)]
408        write_dumpfile_to: Option<Utf8PathBuf>,
409    },
410    /// Output the bootable composefs digest from container storage.
411    #[clap(hide = true)]
412    ComputeComposefsDigestFromStorage {
413        /// Additionally generate a dumpfile written to the target path
414        #[clap(long)]
415        write_dumpfile_to: Option<Utf8PathBuf>,
416
417        /// Identifier for image; if not provided, the running image will be used.
418        image: Option<String>,
419    },
420    /// Split kernel and rootfs from a container image
421    ///
422    /// This command extracts the kernel (vmlinuz and initramfs.img) from the
423    /// container rootfs and moves them to a separate output directory, organized
424    /// by kernel version
425    ///
426    /// Example:
427    ///   bootc container split-kernel-rootfs --rootfs /target-rootfs --output /out
428    SplitKernelAndRootfs {
429        /// Operate on the provided rootfs
430        #[clap(long, default_value = "/")]
431        rootfs: Utf8PathBuf,
432
433        /// Output directory for the extracted kernel files
434        #[clap(long)]
435        output: Utf8PathBuf,
436    },
437    /// Build a Unified Kernel Image (UKI) using ukify.
438    ///
439    /// This command computes the necessary arguments from the container image
440    /// (kernel, initrd, cmdline, os-release) and invokes ukify with them.
441    /// Any additional arguments after `--` are passed through to ukify unchanged.
442    ///
443    /// Example:
444    ///   bootc container ukify --rootfs /target -- --output /output/uki.efi
445    Ukify {
446        /// Operate on the provided rootfs.
447        #[clap(long, default_value = "/")]
448        rootfs: Utf8PathBuf,
449
450        /// Additional kernel arguments to append to the cmdline.
451        /// Can be specified multiple times.
452        /// This is a temporary workaround and will be removed.
453        #[clap(long = "karg", hide = true)]
454        kargs: Vec<String>,
455
456        /// Make fs-verity validation optional in case the filesystem doesn't support it
457        #[clap(long)]
458        allow_missing_verity: bool,
459
460        /// Write a dumpfile to this path
461        #[clap(long)]
462        write_dumpfile_to: Option<Utf8PathBuf>,
463
464        /// The directory containing the kernel and initramfs.img
465        /// Must be of the format /parent/$kernel_version
466        ///
467        /// Ex. /boot/6.18.7-100.fc42.x86_64
468        #[clap(long)]
469        kernel_dir: Option<Utf8PathBuf>,
470
471        /// Additional arguments to pass to ukify (after `--`).
472        #[clap(last = true)]
473        args: Vec<OsString>,
474    },
475    /// Export container filesystem as a tar archive.
476    ///
477    /// This command exports the container filesystem in a bootable format with proper
478    /// SELinux labeling. The output is written to stdout by default or to a specified file.
479    ///
480    /// Example:
481    ///   bootc container export /target > output.tar
482    #[clap(hide = true)]
483    Export {
484        /// Format for export output
485        #[clap(long, default_value = "tar")]
486        format: ExportFormat,
487
488        /// Output file (defaults to stdout)
489        #[clap(long, short = 'o')]
490        output: Option<Utf8PathBuf>,
491
492        /// Copy kernel and initramfs from /usr/lib/modules to /boot for legacy compatibility.
493        /// This is useful for installers that expect the kernel in /boot.
494        #[clap(long)]
495        kernel_in_boot: bool,
496
497        /// Disable SELinux labeling in the exported archive.
498        #[clap(long)]
499        disable_selinux: bool,
500
501        /// Path to the container filesystem root
502        target: Utf8PathBuf,
503    },
504}
505
506#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
507pub(crate) enum ExportFormat {
508    /// Export as tar archive
509    Tar,
510}
511
512/// Subcommands which operate on images.
513#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
514pub(crate) enum ImageCmdOpts {
515    /// Wrapper for `podman image list` in bootc storage.
516    List {
517        #[clap(allow_hyphen_values = true)]
518        args: Vec<OsString>,
519    },
520    /// Wrapper for `podman image build` in bootc storage.
521    Build {
522        #[clap(allow_hyphen_values = true)]
523        args: Vec<OsString>,
524    },
525    /// Pull image(s) into bootc storage.
526    Pull {
527        /// Image references to pull (e.g. quay.io/myorg/myimage:latest)
528        #[clap(required = true)]
529        images: Vec<String>,
530    },
531    /// Wrapper for `podman image push` in bootc storage.
532    Push {
533        #[clap(allow_hyphen_values = true)]
534        args: Vec<OsString>,
535    },
536}
537
538#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
539#[serde(rename_all = "kebab-case")]
540pub(crate) enum ImageListType {
541    /// List all images
542    #[default]
543    All,
544    /// List only logically bound images
545    Logical,
546    /// List only host images
547    Host,
548}
549
550impl std::fmt::Display for ImageListType {
551    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552        self.to_possible_value().unwrap().get_name().fmt(f)
553    }
554}
555
556#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
557#[serde(rename_all = "kebab-case")]
558pub(crate) enum ImageListFormat {
559    /// Human readable table format
560    #[default]
561    Table,
562    /// JSON format
563    Json,
564}
565impl std::fmt::Display for ImageListFormat {
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        self.to_possible_value().unwrap().get_name().fmt(f)
568    }
569}
570
571/// Subcommands which operate on images.
572#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
573pub(crate) enum ImageOpts {
574    /// List fetched images stored in the bootc storage.
575    ///
576    /// Note that these are distinct from images stored via e.g. `podman`.
577    List {
578        /// Type of image to list
579        #[clap(long = "type")]
580        #[arg(default_value_t)]
581        list_type: ImageListType,
582        #[clap(long = "format")]
583        #[arg(default_value_t)]
584        list_format: ImageListFormat,
585    },
586    /// Copy a container image from the bootc storage to `containers-storage:`.
587    ///
588    /// The source and target are both optional; if both are left unspecified,
589    /// via a simple invocation of `bootc image copy-to-storage`, then the default is to
590    /// push the currently booted image to `containers-storage` (as used by podman, etc.)
591    /// and tagged with the image name `localhost/bootc`,
592    ///
593    /// ## Copying a non-default container image
594    ///
595    /// It is also possible to copy an image other than the currently booted one by
596    /// specifying `--source`.
597    ///
598    /// ## Pulling images
599    ///
600    /// At the current time there is no explicit support for pulling images other than indirectly
601    /// via e.g. `bootc switch` or `bootc upgrade`.
602    CopyToStorage {
603        #[clap(long)]
604        /// The source image; if not specified, the booted image will be used.
605        source: Option<String>,
606
607        #[clap(long)]
608        /// The destination; if not specified, then the default is to push to `containers-storage:localhost/bootc`;
609        /// this will make the image accessible via e.g. `podman run localhost/bootc` and for builds.
610        target: Option<String>,
611    },
612    /// Re-pull the currently booted image into the bootc-owned container storage.
613    ///
614    /// This onboards the system to the unified storage path so that future
615    /// upgrade/switch operations can read from the bootc storage directly.
616    SetUnified,
617    /// Copy a container image from the default `containers-storage:` to the bootc-owned container storage.
618    PullFromDefaultStorage {
619        /// The image to pull
620        image: String,
621    },
622    /// Wrapper for selected `podman image` subcommands in bootc storage.
623    #[clap(subcommand)]
624    Cmd(ImageCmdOpts),
625}
626
627#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)]
628pub(crate) enum SchemaType {
629    Host,
630    Progress,
631}
632
633/// Options for consistency checking
634#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
635pub(crate) enum FsverityOpts {
636    /// Measure the fsverity digest of the target file.
637    Measure {
638        /// Path to file
639        path: Utf8PathBuf,
640    },
641    /// Enable fsverity on the target file.
642    Enable {
643        /// Ptah to file
644        path: Utf8PathBuf,
645    },
646}
647
648#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
649pub(crate) enum UkiSubcommands {
650    /// Extract kernel + initrd from a UKI
651    /// The output (vmlinuz + initramfs.img) is placed in a directory named
652    /// after the kernel version found in the UKI
653    Extract {
654        /// The path to the UKI PE
655        path: Utf8PathBuf,
656        /// The output path
657        output_path: Utf8PathBuf,
658    },
659}
660
661/// Hidden, internal only options
662#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
663pub(crate) enum InternalsOpts {
664    SystemdGenerator {
665        normal_dir: Utf8PathBuf,
666        #[allow(dead_code)]
667        early_dir: Option<Utf8PathBuf>,
668        #[allow(dead_code)]
669        late_dir: Option<Utf8PathBuf>,
670    },
671    FixupEtcFstab,
672    /// Remove orphaned and duplicate entries from /etc/shadow and /etc/gshadow
673    /// before systemd-sysusers runs.
674    SysusersSync,
675    /// Should only be used by `make update-generated`
676    PrintJsonSchema {
677        #[clap(long)]
678        of: SchemaType,
679    },
680    #[clap(subcommand)]
681    Fsverity(FsverityOpts),
682    /// Perform consistency checking.
683    Fsck,
684    /// Perform cleanup actions
685    Cleanup,
686    Relabel {
687        #[clap(long)]
688        /// Relabel using this path as root
689        as_path: Option<Utf8PathBuf>,
690
691        /// Relabel this path
692        path: Utf8PathBuf,
693    },
694    /// Relabel the overlay mount point inodes after SELinux policy load.
695    /// Called by the generated bootc-early-overlay-relabel unit.
696    RelabelOverlayMountpoints,
697    /// Proxy frontend for the `ostree-ext` CLI.
698    OstreeExt {
699        #[clap(allow_hyphen_values = true)]
700        args: Vec<OsString>,
701    },
702    /// Proxy frontend for the `cfsctl` CLI
703    Cfs {
704        #[clap(allow_hyphen_values = true)]
705        args: Vec<OsString>,
706    },
707    /// Proxy frontend for the legacy `ostree container` CLI.
708    OstreeContainer {
709        #[clap(allow_hyphen_values = true)]
710        args: Vec<OsString>,
711    },
712    /// Ensure that a composefs repository is initialized
713    TestComposefs,
714    /// Loopback device cleanup helper (internal use only)
715    LoopbackCleanupHelper {
716        /// Device path to clean up
717        #[clap(long)]
718        device: String,
719    },
720    /// Test loopback device allocation and cleanup (internal use only)
721    AllocateCleanupLoopback {
722        /// File path to create loopback device for
723        #[clap(long)]
724        file_path: Utf8PathBuf,
725    },
726    /// Invoked from ostree-ext to complete an installation.
727    BootcInstallCompletion {
728        /// Path to the sysroot
729        sysroot: Utf8PathBuf,
730
731        // The stateroot
732        stateroot: String,
733    },
734    /// Initiate a reboot the same way we would after --apply; intended
735    /// primarily for testing.
736    Reboot,
737    #[cfg(feature = "rhsm")]
738    /// Publish subscription-manager facts to /etc/rhsm/facts/bootc.facts
739    PublishRhsmFacts,
740    /// Internal command for testing etc-diff/etc-merge
741    DirDiff {
742        /// Directory path to the pristine_etc
743        pristine_etc: Utf8PathBuf,
744        /// Directory path to the current_etc
745        current_etc: Utf8PathBuf,
746        /// Directory path to the new_etc
747        new_etc: Utf8PathBuf,
748        /// Whether to perform the three way merge or not
749        #[clap(long)]
750        merge: bool,
751    },
752    #[cfg(feature = "docgen")]
753    /// Dump CLI structure as JSON for documentation generation
754    DumpCliJson,
755    PrepSoftReboot {
756        #[clap(required_unless_present = "reset")]
757        deployment: Option<String>,
758        #[clap(long, conflicts_with = "reset")]
759        reboot: bool,
760        #[clap(long, conflicts_with = "reboot")]
761        reset: bool,
762    },
763    ComposefsGC {
764        #[clap(long)]
765        dry_run: bool,
766        /// Exit with an error if GC would remove any objects or prune any symlinks.
767        /// Implies `--dry-run`.  Intended for use in tests and health-checks.
768        #[clap(long)]
769        assert_no_op: bool,
770        /// Prune the composefs repository in addition to boot binaries
771        #[clap(long)]
772        prune_repo: bool,
773    },
774    /// Block device inspection tools.
775    #[clap(subcommand)]
776    Blockdev(BlockdevOpts),
777    /// For various UKI operations
778    #[clap(subcommand)]
779    Uki(UkiSubcommands),
780}
781
782/// Subcommands for `bootc internals blockdev`.
783#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
784pub(crate) enum BlockdevOpts {
785    /// List block device information (as JSON) for a given device path.
786    ///
787    /// This runs lsblk and backfills any missing partition metadata,
788    /// including falling back to `blkid -p` when the udev database
789    /// is unavailable.
790    Ls {
791        /// Block device path (e.g. /dev/vda)
792        device: Utf8PathBuf,
793    },
794    /// List block device information (as JSON) for the device backing a filesystem.
795    ///
796    /// Takes a directory path, finds the underlying block device, and
797    /// outputs its full device tree with backfilled metadata.
798    LsFilesystem {
799        /// Filesystem path (e.g. /sysroot)
800        path: Utf8PathBuf,
801    },
802}
803
804/// Options for the `set-options-for-source` subcommand.
805#[derive(Debug, Parser, PartialEq, Eq)]
806pub(crate) struct SetOptionsForSourceOpts {
807    /// The name of the source that owns these kernel arguments.
808    ///
809    /// Must contain only alphanumeric characters, hyphens, or underscores.
810    /// Examples: "tuned", "admin", "bootc-kargs-d"
811    #[clap(long)]
812    pub(crate) source: String,
813
814    /// The kernel arguments to set for this source.
815    ///
816    /// If not provided, the source is removed and its options are
817    /// dropped from the merged `options` line.
818    #[clap(long)]
819    pub(crate) options: Option<String>,
820}
821
822/// Operations on Boot Loader Specification (BLS) entries.
823///
824/// These commands support managing kernel arguments from multiple independent
825/// sources (e.g., TuneD, admin, bootc kargs.d) by tracking argument ownership
826/// via `x-options-source-<name>` extension keys in BLS config files.
827///
828/// See <https://github.com/ostreedev/ostree/pull/3570>
829#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
830pub(crate) enum LoaderEntriesOpts {
831    /// Set or update the kernel arguments owned by a specific source.
832    ///
833    /// Each source's arguments are tracked via `x-options-source-<name>`
834    /// keys in BLS config files. The `options` line is recomputed as the
835    /// merge of all tracked sources plus any untracked (pre-existing) options.
836    ///
837    /// This stages a new deployment with the updated kernel arguments.
838    ///
839    /// ## Examples
840    ///
841    /// Add TuneD kernel arguments:
842    /// bootc loader-entries set-options-for-source --source tuned --options "isolcpus=1-3 nohz_full=1-3"
843    ///
844    /// Update TuneD kernel arguments:
845    /// bootc loader-entries set-options-for-source --source tuned --options "isolcpus=0-7"
846    ///
847    /// Remove TuneD kernel arguments:
848    /// bootc loader-entries set-options-for-source --source tuned
849    SetOptionsForSource(SetOptionsForSourceOpts),
850}
851
852#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
853pub(crate) enum StateOpts {
854    /// Remove all ostree deployments from this system
855    WipeOstree,
856}
857
858impl InternalsOpts {
859    /// The name of the binary we inject into /usr/lib/systemd/system-generators
860    const GENERATOR_BIN: &'static str = "bootc-systemd-generator";
861}
862
863/// Deploy and transactionally in-place with bootable container images.
864///
865/// The `bootc` project currently uses ostree-containers as a backend
866/// to support a model of bootable container images.  Once installed,
867/// whether directly via `bootc install` (executed as part of a container)
868/// or via another mechanism such as an OS installer tool, further
869/// updates can be pulled and `bootc upgrade`.
870#[derive(Debug, Parser, PartialEq, Eq)]
871#[clap(name = "bootc")]
872#[clap(rename_all = "kebab-case")]
873#[clap(version,long_version=clap::crate_version!())]
874#[allow(clippy::large_enum_variant)]
875pub(crate) enum Opt {
876    /// Download and queue an updated container image to apply.
877    ///
878    /// This does not affect the running system; updates operate in an "A/B" style by default.
879    ///
880    /// A queued update is visible as `staged` in `bootc status`.
881    ///
882    /// Currently by default, the update will be applied at shutdown time via `ostree-finalize-staged.service`.
883    /// There is also an explicit `bootc upgrade --apply` verb which will automatically take action (rebooting)
884    /// if the system has changed.
885    ///
886    /// However, in the future this is likely to change such that reboots outside of a `bootc upgrade --apply`
887    /// do *not* automatically apply the update in addition.
888    #[clap(alias = "update")]
889    Upgrade(UpgradeOpts),
890    /// Target a new container image reference to boot.
891    ///
892    /// This is almost exactly the same operation as `upgrade`, but additionally changes the container image reference
893    /// instead.
894    ///
895    /// ## Usage
896    ///
897    /// A common pattern is to have a management agent control operating system updates via container image tags;
898    /// for example, `quay.io/exampleos/someuser:v1.0` and `quay.io/exampleos/someuser:v1.1` where some machines
899    /// are tracking `:v1.0`, and as a rollout progresses, machines can be switched to `v:1.1`.
900    Switch(SwitchOpts),
901    /// Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot,
902    /// and the current will become rollback.  If there is a `staged` entry (an unapplied, queued upgrade)
903    /// then it will be discarded.
904    ///
905    /// Note that absent any additional control logic, if there is an active agent doing automated upgrades
906    /// (such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the
907    /// change here may be reverted.  It's recommended to only use this in concert with an agent that
908    /// is in active control.
909    ///
910    /// A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in
911    /// order to detect a rollback invocation.
912    #[command(after_help = indoc! {r#"
913        Note on Rollbacks and the `/etc` Directory:
914
915        When you perform a rollback (e.g., with `bootc rollback`), any
916        changes made to files in the `/etc` directory won't carry over
917        to the rolled-back deployment.  The `/etc` files will revert
918        to their state from that previous deployment instead.
919
920        This is because `bootc rollback` just reorders the existing
921        deployments. It doesn't create new deployments. The `/etc`
922        merges happen when new deployments are created.
923    "#})]
924    Rollback(RollbackOpts),
925    /// Apply full changes to the host specification.
926    ///
927    /// This command operates very similarly to `kubectl apply`; if invoked interactively,
928    /// then the current host specification will be presented in the system default `$EDITOR`
929    /// for interactive changes.
930    ///
931    /// It is also possible to directly provide new contents via `bootc edit --filename`.
932    ///
933    /// Only changes to the `spec` section are honored.
934    Edit(EditOpts),
935    /// Display status.
936    ///
937    /// Shows bootc system state. Outputs YAML by default, human-readable if terminal detected.
938    Status(StatusOpts),
939    /// Add a transient overlayfs on `/usr`.
940    ///
941    /// Allows temporary package installation that will be discarded on reboot.
942    #[clap(alias = "usroverlay")]
943    UsrOverlay(UsrOverlayOpts),
944    /// Install the running container to a target.
945    ///
946    /// Takes a container image and installs it to disk in a bootable format.
947    #[clap(subcommand)]
948    Install(InstallOpts),
949    /// Operations which can be executed as part of a container build.
950    #[clap(subcommand)]
951    Container(ContainerOpts),
952    /// Operations on container images.
953    ///
954    /// Stability: This interface may change in the future.
955    #[clap(subcommand, hide = true)]
956    Image(ImageOpts),
957    /// Operations on Boot Loader Specification (BLS) entries.
958    ///
959    /// Manage kernel arguments from multiple independent sources.
960    #[clap(subcommand)]
961    LoaderEntries(LoaderEntriesOpts),
962    /// Execute the given command in the host mount namespace
963    #[clap(hide = true)]
964    ExecInHostMountNamespace {
965        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
966        args: Vec<OsString>,
967    },
968    /// Modify the state of the system
969    #[clap(hide = true)]
970    #[clap(subcommand)]
971    State(StateOpts),
972    #[clap(subcommand)]
973    #[clap(hide = true)]
974    Internals(InternalsOpts),
975    ComposefsFinalizeStaged,
976    /// Diff current /etc configuration versus default
977    #[clap(hide = true)]
978    ConfigDiff,
979    /// Generate shell completion script for supported shells.
980    ///
981    /// Example: `bootc completion bash` prints a bash completion script to stdout.
982    #[clap(hide = true)]
983    Completion {
984        /// Shell type to generate (bash, zsh, fish)
985        #[clap(value_enum)]
986        shell: clap_complete::aot::Shell,
987    },
988    #[clap(hide = true)]
989    DeleteDeployment {
990        depl_id: String,
991    },
992}
993
994/// Ensure we've entered a mount namespace, so that we can remount
995/// `/sysroot` read-write
996/// TODO use <https://github.com/ostreedev/ostree/pull/2779> once
997/// we can depend on a new enough ostree
998#[context("Ensuring mountns")]
999pub(crate) fn ensure_self_unshared_mount_namespace() -> Result<()> {
1000    let uid = rustix::process::getuid();
1001    if !uid.is_root() {
1002        tracing::debug!("Not root, assuming no need to unshare");
1003        return Ok(());
1004    }
1005    let recurse_env = "_ostree_unshared";
1006    let ns_pid1 = std::fs::read_link("/proc/1/ns/mnt").context("Reading /proc/1/ns/mnt")?;
1007    let ns_self = std::fs::read_link("/proc/self/ns/mnt").context("Reading /proc/self/ns/mnt")?;
1008    // If we already appear to be in a mount namespace, or we're already pid1, we're done
1009    if ns_pid1 != ns_self {
1010        tracing::debug!("Already in a mount namespace");
1011        return Ok(());
1012    }
1013    if std::env::var_os(recurse_env).is_some() {
1014        let am_pid1 = rustix::process::getpid().is_init();
1015        if am_pid1 {
1016            tracing::debug!("We are pid 1");
1017            return Ok(());
1018        } else {
1019            anyhow::bail!("Failed to unshare mount namespace");
1020        }
1021    }
1022    bootc_utils::reexec::reexec_with_guardenv(recurse_env, &["unshare", "-m", "--"])
1023}
1024
1025/// Load global storage state, expecting that we're booted into a bootc system.
1026/// This prepares the process for write operations (re-exec, mount namespace, etc).
1027#[context("Initializing storage")]
1028pub(crate) async fn get_storage() -> Result<crate::store::BootedStorage> {
1029    let env = crate::store::Environment::detect()?;
1030    // Always call prepare_for_write() for write operations - it checks
1031    // for container, root privileges, mount namespace setup, etc.
1032    prepare_for_write()?;
1033    let r = BootedStorage::new(env)
1034        .await?
1035        .ok_or_else(|| anyhow!("System not booted via bootc"))?;
1036    Ok(r)
1037}
1038
1039#[context("Querying root privilege")]
1040pub(crate) fn require_root(is_container: bool) -> Result<()> {
1041    ensure!(
1042        rustix::process::getuid().is_root(),
1043        if is_container {
1044            "The user inside the container from which you are running this command must be root"
1045        } else {
1046            "This command must be executed as the root user"
1047        }
1048    );
1049
1050    ensure!(
1051        rustix::thread::capability_is_in_bounding_set(rustix::thread::CapabilitySet::SYS_ADMIN)?,
1052        if is_container {
1053            "The container must be executed with full privileges (e.g. --privileged flag)"
1054        } else {
1055            "This command requires full root privileges (CAP_SYS_ADMIN)"
1056        }
1057    );
1058
1059    tracing::trace!("Verified uid 0 with CAP_SYS_ADMIN");
1060
1061    Ok(())
1062}
1063
1064/// Check if a deployment has soft reboot capability
1065fn has_soft_reboot_capability(deployment: Option<&crate::spec::BootEntry>) -> bool {
1066    deployment.map(|d| d.soft_reboot_capable).unwrap_or(false)
1067}
1068
1069/// Prepare a soft reboot for the given deployment
1070#[context("Preparing soft reboot")]
1071fn prepare_soft_reboot(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> Result<()> {
1072    let cancellable = ostree::gio::Cancellable::NONE;
1073    sysroot
1074        .deployment_set_soft_reboot(deployment, false, cancellable)
1075        .context("Failed to prepare soft-reboot")?;
1076    Ok(())
1077}
1078
1079/// Handle soft reboot based on the configured mode
1080#[context("Handling soft reboot")]
1081fn handle_soft_reboot<F>(
1082    soft_reboot_mode: Option<SoftRebootMode>,
1083    entry: Option<&crate::spec::BootEntry>,
1084    deployment_type: &str,
1085    execute_soft_reboot: F,
1086) -> Result<()>
1087where
1088    F: FnOnce() -> Result<()>,
1089{
1090    let Some(mode) = soft_reboot_mode else {
1091        return Ok(());
1092    };
1093
1094    let can_soft_reboot = has_soft_reboot_capability(entry);
1095    match mode {
1096        SoftRebootMode::Required => {
1097            if can_soft_reboot {
1098                execute_soft_reboot()?;
1099            } else {
1100                anyhow::bail!(
1101                    "Soft reboot was required but {} deployment is not soft-reboot capable",
1102                    deployment_type
1103                );
1104            }
1105        }
1106        SoftRebootMode::Auto => {
1107            if can_soft_reboot {
1108                execute_soft_reboot()?;
1109            }
1110        }
1111    }
1112    Ok(())
1113}
1114
1115/// Handle soft reboot for staged deployments (used by upgrade and switch)
1116#[context("Handling staged soft reboot")]
1117fn handle_staged_soft_reboot(
1118    booted_ostree: &BootedOstree<'_>,
1119    soft_reboot_mode: Option<SoftRebootMode>,
1120    host: &crate::spec::Host,
1121) -> Result<()> {
1122    handle_soft_reboot(
1123        soft_reboot_mode,
1124        host.status.staged.as_ref(),
1125        "staged",
1126        || soft_reboot_staged(booted_ostree.sysroot),
1127    )
1128}
1129
1130/// Perform a soft reboot for a staged deployment
1131#[context("Soft reboot staged deployment")]
1132fn soft_reboot_staged(sysroot: &SysrootLock) -> Result<()> {
1133    println!("Staged deployment is soft-reboot capable, preparing for soft-reboot...");
1134
1135    let deployments_list = sysroot.deployments();
1136    let staged_deployment = deployments_list
1137        .iter()
1138        .find(|d| d.is_staged())
1139        .ok_or_else(|| anyhow::anyhow!("Failed to find staged deployment"))?;
1140
1141    prepare_soft_reboot(sysroot, staged_deployment)?;
1142    Ok(())
1143}
1144
1145/// Perform a soft reboot for a rollback deployment
1146#[context("Soft reboot rollback deployment")]
1147fn soft_reboot_rollback(booted_ostree: &BootedOstree<'_>) -> Result<()> {
1148    println!("Rollback deployment is soft-reboot capable, preparing for soft-reboot...");
1149
1150    let deployments_list = booted_ostree.sysroot.deployments();
1151    let target_deployment = deployments_list
1152        .first()
1153        .ok_or_else(|| anyhow::anyhow!("No rollback deployment found!"))?;
1154
1155    prepare_soft_reboot(booted_ostree.sysroot, target_deployment)
1156}
1157
1158/// A few process changes that need to be made for writing.
1159/// IMPORTANT: This may end up re-executing the current process,
1160/// so anything that happens before this should be idempotent.
1161#[context("Preparing for write")]
1162pub(crate) fn prepare_for_write() -> Result<()> {
1163    use std::sync::atomic::{AtomicBool, Ordering};
1164
1165    // This is intending to give "at most once" semantics to this
1166    // function. We should never invoke this from multiple threads
1167    // at the same time, but verifying "on main thread" is messy.
1168    // Yes, using SeqCst is likely overkill, but there is nothing perf
1169    // sensitive about this.
1170    static ENTERED: AtomicBool = AtomicBool::new(false);
1171    if ENTERED.load(Ordering::SeqCst) {
1172        return Ok(());
1173    }
1174    if ostree_ext::container_utils::running_in_container() {
1175        anyhow::bail!("Detected container; this command requires a booted host system.");
1176    }
1177    crate::cli::require_root(false)?;
1178    ensure_self_unshared_mount_namespace()?;
1179    if crate::lsm::selinux_enabled()? && !crate::lsm::selinux_ensure_install()? {
1180        tracing::debug!("Do not have install_t capabilities");
1181    }
1182    ENTERED.store(true, Ordering::SeqCst);
1183    Ok(())
1184}
1185
1186/// Implementation of the `bootc upgrade` CLI command.
1187#[context("Upgrading")]
1188async fn upgrade(
1189    opts: UpgradeOpts,
1190    storage: &Storage,
1191    booted_ostree: &BootedOstree<'_>,
1192) -> Result<()> {
1193    let repo = &booted_ostree.repo();
1194
1195    let host = crate::status::get_status(booted_ostree)?.1;
1196    let current_image = host.spec.image.as_ref();
1197
1198    // Handle --tag: derive target from current image + new tag
1199    let derived_image = if let Some(ref tag) = opts.tag {
1200        let image = current_image.ok_or_else(|| {
1201            anyhow::anyhow!("--tag requires a booted image with a specified source")
1202        })?;
1203        Some(image.with_tag(tag)?)
1204    } else {
1205        None
1206    };
1207
1208    let imgref = derived_image.as_ref().or(current_image);
1209    let prog: ProgressWriter = opts.progress.try_into()?;
1210
1211    // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
1212    if imgref.is_none() {
1213        let booted_incompatible = host.status.booted.as_ref().is_some_and(|b| b.incompatible);
1214
1215        let staged_incompatible = host.status.staged.as_ref().is_some_and(|b| b.incompatible);
1216
1217        if booted_incompatible || staged_incompatible {
1218            return Err(anyhow::anyhow!(
1219                "Deployment contains local rpm-ostree modifications; cannot upgrade via bootc. You can run `rpm-ostree reset` to undo the modifications."
1220            ));
1221        }
1222    }
1223
1224    let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
1225    // Use the derived image reference (if --tag was specified) instead of the spec's image
1226    let spec = RequiredHostSpec { image: imgref };
1227    let booted_image = host
1228        .status
1229        .booted
1230        .as_ref()
1231        .map(|b| b.query_image(repo))
1232        .transpose()?
1233        .flatten();
1234    // Find the currently queued digest, if any before we pull
1235    let staged = host.status.staged.as_ref();
1236    let staged_image = staged.as_ref().and_then(|s| s.image.as_ref());
1237    let mut changed = false;
1238
1239    // Handle --from-downloaded: unlock existing staged deployment without fetching from image source
1240    if opts.from_downloaded {
1241        let ostree = storage.get_ostree()?;
1242        let staged_deployment = ostree
1243            .staged_deployment()
1244            .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
1245
1246        if staged_deployment.is_finalization_locked() {
1247            ostree.change_finalization(&staged_deployment)?;
1248            println!("Staged deployment will now be applied on reboot");
1249        } else {
1250            println!("Staged deployment is already set to apply on reboot");
1251        }
1252
1253        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1254        if opts.apply {
1255            crate::reboot::reboot()?;
1256        }
1257        return Ok(());
1258    }
1259
1260    // Ensure the bootc storage directory is initialized; the --check path
1261    // needs this for update_mtime() and the non-check path needs it for
1262    // unified pull detection.
1263    let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await?;
1264
1265    if opts.check {
1266        let ostree_imgref = imgref.clone().into();
1267        let mut imp =
1268            crate::deploy::new_importer(repo, &ostree_imgref, Some(&booted_ostree.deployment))
1269                .await?;
1270        match imp.prepare().await? {
1271            PrepareResult::AlreadyPresent(_) => {
1272                println!("No changes in: {ostree_imgref:#}");
1273            }
1274            PrepareResult::Ready(r) => {
1275                crate::deploy::check_bootc_label(&r.config);
1276                println!("Update available for: {ostree_imgref:#}");
1277                if let Some(version) = r.version() {
1278                    println!("  Version: {version}");
1279                }
1280                println!("  Digest: {}", r.manifest_digest);
1281                changed = true;
1282                if let Some(previous_image) = booted_image.as_ref() {
1283                    let diff =
1284                        ostree_container::ManifestDiff::new(&previous_image.manifest, &r.manifest);
1285                    diff.print();
1286                }
1287            }
1288        }
1289    } else {
1290        let fetched = if use_unified {
1291            crate::deploy::pull_unified(
1292                repo,
1293                imgref,
1294                None,
1295                opts.quiet,
1296                prog.clone(),
1297                storage,
1298                Some(&booted_ostree.deployment),
1299            )
1300            .await?
1301        } else {
1302            crate::deploy::pull(
1303                repo,
1304                imgref,
1305                None,
1306                opts.quiet,
1307                prog.clone(),
1308                Some(&booted_ostree.deployment),
1309            )
1310            .await?
1311        };
1312        let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
1313        let fetched_digest = &fetched.manifest_digest;
1314        tracing::debug!("staged: {staged_digest:?}");
1315        tracing::debug!("fetched: {fetched_digest}");
1316        let staged_unchanged = staged_digest
1317            .as_ref()
1318            .map(|d| d == fetched_digest)
1319            .unwrap_or_default();
1320        let booted_unchanged = booted_image
1321            .as_ref()
1322            .map(|img| &img.manifest_digest == fetched_digest)
1323            .unwrap_or_default();
1324        if staged_unchanged {
1325            let staged_deployment = storage.get_ostree()?.staged_deployment();
1326            let mut download_only_changed = false;
1327
1328            if let Some(staged) = staged_deployment {
1329                // Handle download-only mode based on flags
1330                if opts.download_only {
1331                    // --download-only: set download-only mode
1332                    if !staged.is_finalization_locked() {
1333                        storage.get_ostree()?.change_finalization(&staged)?;
1334                        println!("Image downloaded, but will not be applied on reboot");
1335                        download_only_changed = true;
1336                    }
1337                } else if !opts.check {
1338                    // --apply or no flags: clear download-only mode
1339                    // (skip if --check, which is read-only)
1340                    if staged.is_finalization_locked() {
1341                        storage.get_ostree()?.change_finalization(&staged)?;
1342                        println!("Staged deployment will now be applied on reboot");
1343                        download_only_changed = true;
1344                    }
1345                }
1346            } else if opts.download_only || opts.apply {
1347                anyhow::bail!("No staged deployment found");
1348            }
1349
1350            if !download_only_changed {
1351                println!("Staged update present, not changed");
1352            }
1353
1354            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1355            if opts.apply {
1356                crate::reboot::reboot()?;
1357            }
1358        } else if booted_unchanged {
1359            println!("No update available.")
1360        } else {
1361            let stateroot = booted_ostree.stateroot();
1362            let from = MergeState::from_stateroot(storage, &stateroot)?;
1363            crate::deploy::stage(
1364                storage,
1365                from,
1366                &fetched,
1367                &spec,
1368                prog.clone(),
1369                opts.download_only,
1370            )
1371            .await?;
1372            changed = true;
1373            if let Some(prev) = booted_image.as_ref() {
1374                if let Some(fetched_manifest) = fetched.get_manifest(repo)? {
1375                    let diff =
1376                        ostree_container::ManifestDiff::new(&prev.manifest, &fetched_manifest);
1377                    diff.print();
1378                }
1379            }
1380        }
1381    }
1382    if changed {
1383        storage.update_mtime()?;
1384
1385        if opts.soft_reboot.is_some() {
1386            // At this point we have new staged deployment and the host definition has changed.
1387            // We need the updated host status before we check if we can prepare the soft-reboot.
1388            let updated_host = crate::status::get_status(booted_ostree)?.1;
1389            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1390        }
1391
1392        if opts.apply {
1393            crate::reboot::reboot()?;
1394        }
1395    } else {
1396        tracing::debug!("No changes");
1397    }
1398
1399    Ok(())
1400}
1401pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result<ImageReference> {
1402    let transport = ostree_container::Transport::try_from(opts.transport.as_str())?;
1403    let imgref = ostree_container::ImageReference {
1404        transport,
1405        name: opts.target.to_string(),
1406    };
1407    let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy);
1408    let target = ostree_container::OstreeImageReference { sigverify, imgref };
1409    let target = ImageReference::from(target);
1410
1411    return Ok(target);
1412}
1413
1414/// Implementation of the `bootc switch` CLI command for ostree backend.
1415#[context("Switching (ostree)")]
1416async fn switch_ostree(
1417    opts: SwitchOpts,
1418    storage: &Storage,
1419    booted_ostree: &BootedOstree<'_>,
1420) -> Result<()> {
1421    let target = imgref_for_switch(&opts)?;
1422    let prog: ProgressWriter = opts.progress.try_into()?;
1423    let cancellable = gio::Cancellable::NONE;
1424
1425    let repo = &booted_ostree.repo();
1426    let (_, host) = crate::status::get_status(booted_ostree)?;
1427
1428    let new_spec = {
1429        let mut new_spec = host.spec.clone();
1430        new_spec.image = Some(target.clone());
1431        new_spec
1432    };
1433
1434    if new_spec == host.spec {
1435        println!("Image specification is unchanged.");
1436        if opts.apply && host.status.staged.is_some() {
1437            crate::reboot::reboot()?;
1438        }
1439        return Ok(());
1440    }
1441
1442    // Log the switch operation to systemd journal
1443    const SWITCH_JOURNAL_ID: &str = "7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1";
1444    let old_image = host
1445        .spec
1446        .image
1447        .as_ref()
1448        .map(|i| i.image.as_str())
1449        .unwrap_or("none");
1450
1451    tracing::info!(
1452        message_id = SWITCH_JOURNAL_ID,
1453        bootc.old_image_reference = old_image,
1454        bootc.new_image_reference = &target.image,
1455        bootc.new_image_transport = &target.transport,
1456        "Switching from image {} to {}",
1457        old_image,
1458        target.image
1459    );
1460
1461    let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
1462
1463    // Determine whether to use unified storage path.
1464    // If explicitly requested via flag, use unified storage directly.
1465    // Otherwise, auto-detect based on whether the image exists in bootc storage.
1466    let use_unified = if opts.unified_storage_exp {
1467        true
1468    } else {
1469        crate::deploy::image_exists_in_unified_storage(storage, &target).await?
1470    };
1471
1472    let fetched = if use_unified {
1473        crate::deploy::pull_unified(
1474            repo,
1475            &target,
1476            None,
1477            opts.quiet,
1478            prog.clone(),
1479            storage,
1480            Some(&booted_ostree.deployment),
1481        )
1482        .await?
1483    } else {
1484        crate::deploy::pull(
1485            repo,
1486            &target,
1487            None,
1488            opts.quiet,
1489            prog.clone(),
1490            Some(&booted_ostree.deployment),
1491        )
1492        .await?
1493    };
1494
1495    if !opts.retain {
1496        // By default, we prune the previous ostree ref so it will go away after later upgrades
1497        if let Some(booted_origin) = booted_ostree.deployment.origin() {
1498            if let Some(ostree_ref) = booted_origin.optional_string("origin", "refspec")? {
1499                let (remote, ostree_ref) =
1500                    ostree::parse_refspec(&ostree_ref).context("Failed to parse ostree ref")?;
1501                repo.set_ref_immediate(remote.as_deref(), &ostree_ref, None, cancellable)?;
1502            }
1503        }
1504    }
1505
1506    let stateroot = booted_ostree.stateroot();
1507    let from = MergeState::from_stateroot(storage, &stateroot)?;
1508    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1509
1510    storage.update_mtime()?;
1511
1512    if opts.soft_reboot.is_some() {
1513        // At this point we have staged the deployment and the host definition has changed.
1514        // We need the updated host status before we check if we can prepare the soft-reboot.
1515        let updated_host = crate::status::get_status(booted_ostree)?.1;
1516        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1517    }
1518
1519    if opts.apply {
1520        crate::reboot::reboot()?;
1521    }
1522
1523    Ok(())
1524}
1525
1526/// Implementation of the `bootc switch` CLI command.
1527#[context("Switching")]
1528async fn switch(opts: SwitchOpts) -> Result<()> {
1529    // If we're doing an in-place mutation, we shortcut most of the rest of the work here
1530    // TODO: what we really want here is Storage::detect_from_root() that also handles
1531    // composefs. But for now this just assumes ostree.
1532    if opts.mutate_in_place {
1533        let target = imgref_for_switch(&opts)?;
1534        let deployid = {
1535            // Clone to pass into helper thread
1536            let target = target.clone();
1537            let root = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1538            tokio::task::spawn_blocking(move || {
1539                crate::deploy::switch_origin_inplace(&root, &target)
1540            })
1541            .await??
1542        };
1543        println!("Updated {deployid} to pull from {target}");
1544        return Ok(());
1545    }
1546    let storage = &get_storage().await?;
1547    match storage.kind()? {
1548        BootedStorageKind::Ostree(booted_ostree) => {
1549            switch_ostree(opts, storage, &booted_ostree).await
1550        }
1551        BootedStorageKind::Composefs(booted_cfs) => {
1552            switch_composefs(opts, storage, &booted_cfs).await
1553        }
1554    }
1555}
1556
1557/// Implementation of the `bootc rollback` CLI command for ostree backend.
1558#[context("Rollback (ostree)")]
1559async fn rollback_ostree(
1560    opts: &RollbackOpts,
1561    storage: &Storage,
1562    booted_ostree: &BootedOstree<'_>,
1563) -> Result<()> {
1564    crate::deploy::rollback(storage).await?;
1565
1566    if opts.soft_reboot.is_some() {
1567        // Get status of rollback deployment to check soft-reboot capability
1568        let host = crate::status::get_status(booted_ostree)?.1;
1569
1570        handle_soft_reboot(
1571            opts.soft_reboot,
1572            host.status.rollback.as_ref(),
1573            "rollback",
1574            || soft_reboot_rollback(booted_ostree),
1575        )?;
1576    }
1577
1578    Ok(())
1579}
1580
1581/// Implementation of the `bootc rollback` CLI command.
1582#[context("Rollback")]
1583async fn rollback(opts: &RollbackOpts) -> Result<()> {
1584    let storage = &get_storage().await?;
1585    match storage.kind()? {
1586        BootedStorageKind::Ostree(booted_ostree) => {
1587            rollback_ostree(opts, storage, &booted_ostree).await
1588        }
1589        BootedStorageKind::Composefs(booted_cfs) => composefs_rollback(storage, &booted_cfs).await,
1590    }
1591}
1592
1593/// Implementation of the `bootc edit` CLI command for ostree backend.
1594#[context("Editing spec (ostree)")]
1595async fn edit_ostree(
1596    opts: EditOpts,
1597    storage: &Storage,
1598    booted_ostree: &BootedOstree<'_>,
1599) -> Result<()> {
1600    let repo = &booted_ostree.repo();
1601    let (_, host) = crate::status::get_status(booted_ostree)?;
1602
1603    let new_host: Host = if let Some(filename) = opts.filename {
1604        let mut r = std::io::BufReader::new(std::fs::File::open(filename)?);
1605        serde_yaml::from_reader(&mut r)?
1606    } else {
1607        let tmpf = tempfile::NamedTempFile::with_suffix(".yaml")?;
1608        serde_yaml::to_writer(std::io::BufWriter::new(tmpf.as_file()), &host)?;
1609        crate::utils::spawn_editor(&tmpf)?;
1610        tmpf.as_file().seek(std::io::SeekFrom::Start(0))?;
1611        serde_yaml::from_reader(&mut tmpf.as_file())?
1612    };
1613
1614    if new_host.spec == host.spec {
1615        println!("Edit cancelled, no changes made.");
1616        return Ok(());
1617    }
1618    host.spec.verify_transition(&new_host.spec)?;
1619    let new_spec = RequiredHostSpec::from_spec(&new_host.spec)?;
1620
1621    let prog = ProgressWriter::default();
1622
1623    // We only support two state transitions right now; switching the image,
1624    // or flipping the bootloader ordering.
1625    if host.spec.boot_order != new_host.spec.boot_order {
1626        return crate::deploy::rollback(storage).await;
1627    }
1628
1629    let fetched = crate::deploy::pull(
1630        repo,
1631        new_spec.image,
1632        None,
1633        opts.quiet,
1634        prog.clone(),
1635        Some(&booted_ostree.deployment),
1636    )
1637    .await?;
1638
1639    // TODO gc old layers here
1640
1641    let stateroot = booted_ostree.stateroot();
1642    let from = MergeState::from_stateroot(storage, &stateroot)?;
1643    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1644
1645    storage.update_mtime()?;
1646
1647    Ok(())
1648}
1649
1650/// Implementation of the `bootc edit` CLI command.
1651#[context("Editing spec")]
1652async fn edit(opts: EditOpts) -> Result<()> {
1653    let storage = &get_storage().await?;
1654    match storage.kind()? {
1655        BootedStorageKind::Ostree(booted_ostree) => {
1656            edit_ostree(opts, storage, &booted_ostree).await
1657        }
1658        BootedStorageKind::Composefs(_) => {
1659            anyhow::bail!("Edit is not yet supported for composefs backend")
1660        }
1661    }
1662}
1663
1664/// Implementation of `bootc usroverlay`
1665async fn usroverlay(access_mode: FilesystemOverlayAccessMode) -> Result<()> {
1666    // This is just a pass-through today.  At some point we may make this a libostree API
1667    // or even oxidize it.
1668    let args = match access_mode {
1669        // In this context, "--transient" means "read-only overlay"
1670        FilesystemOverlayAccessMode::ReadOnly => ["admin", "unlock", "--transient"].as_slice(),
1671
1672        FilesystemOverlayAccessMode::ReadWrite => ["admin", "unlock"].as_slice(),
1673    };
1674    Err(Command::new("ostree").args(args).exec().into())
1675}
1676
1677/// Join the host IPC namespace if we're in an isolated one and have
1678/// sufficient privileges. The default for `podman run` is a separate IPC
1679/// namespace, which for e.g. `bootc install` can cause failures where tools
1680/// like udev/cryptsetup expect semaphores to be in sync with the host.
1681/// While we do want callers to pass `--ipc=host`, we don't want to force
1682/// them to need to either.
1683///
1684/// Requires `CAP_SYS_ADMIN` (needed for `setns()`); silently skipped when
1685/// running unprivileged (e.g. during RPM build for manpage generation).
1686/// Also skipped when `/proc/1/ns/ipc` is not accessible, which can happen
1687/// in restricted build environments (e.g. Tekton/Buildah containers) where
1688/// `/proc` is masked even for processes with `CAP_SYS_ADMIN`.
1689fn join_host_ipc_namespace() -> Result<()> {
1690    let caps = rustix::thread::capabilities(None).context("capget")?;
1691    if !caps
1692        .effective
1693        .contains(rustix::thread::CapabilitySet::SYS_ADMIN)
1694    {
1695        return Ok(());
1696    }
1697    let ns_pid1 = match std::fs::read_link("/proc/1/ns/ipc") {
1698        Ok(v) => v,
1699        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1700            return Ok(());
1701        }
1702        Err(e) => return Err(e).context("reading /proc/1/ns/ipc"),
1703    };
1704    let ns_self = std::fs::read_link("/proc/self/ns/ipc").context("reading /proc/self/ns/ipc")?;
1705    if ns_pid1 != ns_self {
1706        let pid1ipcns = std::fs::File::open("/proc/1/ns/ipc").context("open pid1 ipcns")?;
1707        rustix::thread::move_into_link_name_space(
1708            pid1ipcns.as_fd(),
1709            Some(rustix::thread::LinkNameSpaceType::InterProcessCommunication),
1710        )
1711        .context("setns(ipc)")?;
1712    }
1713    Ok(())
1714}
1715
1716/// Perform process global initialization. This should be called as early as possible
1717/// in the standard `main` function.
1718#[allow(unsafe_code)]
1719pub fn global_init() -> Result<()> {
1720    join_host_ipc_namespace()?;
1721    // In some cases we re-exec with a temporary binary,
1722    // so ensure that the syslog identifier is set.
1723    ostree::glib::set_prgname(bootc_utils::NAME.into());
1724    if let Err(e) = rustix::thread::set_name(&CString::new(bootc_utils::NAME).unwrap()) {
1725        // This shouldn't ever happen
1726        eprintln!("failed to set name: {e}");
1727    }
1728    // Silence SELinux log warnings
1729    ostree::SePolicy::set_null_log();
1730    let am_root = rustix::process::getuid().is_root();
1731    // Work around bootc-image-builder not setting HOME, in combination with podman (really c/common)
1732    // bombing out if it is unset.
1733    if std::env::var_os("HOME").is_none() && am_root {
1734        // Setting the environment is thread-unsafe, but we ask calling code
1735        // to invoke this as early as possible. (In practice, that's just the cli's `main.rs`)
1736        // xref https://internals.rust-lang.org/t/synchronized-ffi-access-to-posix-environment-variable-functions/15475
1737        // SAFETY: Called early in main() before any threads are spawned.
1738        unsafe {
1739            std::env::set_var("HOME", "/root");
1740        }
1741    }
1742    Ok(())
1743}
1744
1745/// Parse the provided arguments and execute.
1746/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program.
1747pub async fn run_from_iter<I>(args: I) -> Result<()>
1748where
1749    I: IntoIterator,
1750    I::Item: Into<OsString> + Clone,
1751{
1752    run_from_opt(Opt::parse_including_static(args)).await
1753}
1754
1755/// Find the base binary name from argv0 (without a full path). The empty string
1756/// is never returned; instead a fallback string is used. If the input is not valid
1757/// UTF-8, a default is used.
1758fn callname_from_argv0(argv0: &OsStr) -> &str {
1759    let default = "bootc";
1760    std::path::Path::new(argv0)
1761        .file_name()
1762        .and_then(|s| s.to_str())
1763        .filter(|s| !s.is_empty())
1764        .unwrap_or(default)
1765}
1766
1767impl Opt {
1768    /// In some cases (e.g. systemd generator) we dispatch specifically on argv0.  This
1769    /// requires some special handling in clap.
1770    fn parse_including_static<I>(args: I) -> Self
1771    where
1772        I: IntoIterator,
1773        I::Item: Into<OsString> + Clone,
1774    {
1775        let mut args = args.into_iter();
1776        let first = if let Some(first) = args.next() {
1777            let first: OsString = first.into();
1778            let argv0 = callname_from_argv0(&first);
1779            tracing::debug!("argv0={argv0:?}");
1780            let mapped = match argv0 {
1781                InternalsOpts::GENERATOR_BIN => {
1782                    Some(["bootc", "internals", "systemd-generator"].as_slice())
1783                }
1784                "ostree-container" | "ostree-ima-sign" | "ostree-provisional-repair" => {
1785                    Some(["bootc", "internals", "ostree-ext"].as_slice())
1786                }
1787                _ => None,
1788            };
1789            if let Some(base_args) = mapped {
1790                let base_args = base_args.iter().map(OsString::from);
1791                return Opt::parse_from(base_args.chain(args.map(|i| i.into())));
1792            }
1793            Some(first)
1794        } else {
1795            None
1796        };
1797        Opt::parse_from(first.into_iter().chain(args.map(|i| i.into())))
1798    }
1799}
1800
1801/// Internal (non-generic/monomorphized) primary CLI entrypoint
1802async fn run_from_opt(opt: Opt) -> Result<()> {
1803    let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1804    match opt {
1805        Opt::Upgrade(opts) => {
1806            let storage = &get_storage().await?;
1807            match storage.kind()? {
1808                BootedStorageKind::Ostree(booted_ostree) => {
1809                    upgrade(opts, storage, &booted_ostree).await
1810                }
1811                BootedStorageKind::Composefs(booted_cfs) => {
1812                    upgrade_composefs(opts, storage, &booted_cfs).await
1813                }
1814            }
1815        }
1816        Opt::Switch(opts) => switch(opts).await,
1817        Opt::Rollback(opts) => {
1818            rollback(&opts).await?;
1819            if opts.apply {
1820                crate::reboot::reboot()?;
1821            }
1822            Ok(())
1823        }
1824        Opt::Edit(opts) => edit(opts).await,
1825        Opt::UsrOverlay(opts) => {
1826            use crate::store::Environment;
1827            let env = Environment::detect()?;
1828            let access_mode = if opts.read_only {
1829                FilesystemOverlayAccessMode::ReadOnly
1830            } else {
1831                FilesystemOverlayAccessMode::ReadWrite
1832            };
1833            match env {
1834                Environment::OstreeBooted => usroverlay(access_mode).await,
1835                Environment::ComposefsBooted(_) => composefs_usr_overlay(access_mode),
1836                _ => anyhow::bail!("usroverlay only applies on booted hosts"),
1837            }
1838        }
1839        Opt::Container(opts) => match opts {
1840            ContainerOpts::Inspect {
1841                rootfs,
1842                json,
1843                format,
1844            } => crate::status::container_inspect(&rootfs, json, format),
1845            ContainerOpts::Lint {
1846                rootfs,
1847                fatal_warnings,
1848                list,
1849                skip,
1850                no_truncate,
1851            } => {
1852                if list {
1853                    return lints::lint_list(std::io::stdout().lock());
1854                }
1855                let warnings = if fatal_warnings {
1856                    lints::WarningDisposition::FatalWarnings
1857                } else {
1858                    lints::WarningDisposition::AllowWarnings
1859                };
1860                let root_type = if rootfs == "/" {
1861                    lints::RootType::Running
1862                } else {
1863                    lints::RootType::Alternative
1864                };
1865
1866                let root = &Dir::open_ambient_dir(rootfs, cap_std::ambient_authority())?;
1867                let skip = skip.iter().map(|s| s.as_str());
1868                lints::lint(
1869                    root,
1870                    warnings,
1871                    root_type,
1872                    skip,
1873                    std::io::stdout().lock(),
1874                    no_truncate,
1875                )?;
1876                Ok(())
1877            }
1878            ContainerOpts::SplitKernelAndRootfs { rootfs, output } => {
1879                use crate::kernel::{KernelType, find_kernel};
1880
1881                let root = Dir::open_ambient_dir(&rootfs, ambient_authority())?;
1882
1883                let kernel_internal = find_kernel(&root)?
1884                    .ok_or_else(|| anyhow::anyhow!("No kernel found in rootfs"))?;
1885
1886                if kernel_internal.kernel.unified {
1887                    anyhow::bail!("UKIs are not supported");
1888                }
1889
1890                match &kernel_internal.k_type {
1891                    KernelType::Vmlinuz { path, initramfs } => {
1892                        let kver = &kernel_internal.kernel.version;
1893                        let kernel_output_dir = output.join(kver);
1894                        std::fs::create_dir_all(&kernel_output_dir)?;
1895
1896                        let vmlinuz_src = rootfs.join(path);
1897                        let initramfs_src = rootfs.join(initramfs);
1898                        let vmlinuz_dst = kernel_output_dir.join("vmlinuz");
1899                        let initramfs_dst = kernel_output_dir.join("initramfs.img");
1900
1901                        std::fs::rename(&vmlinuz_src, &vmlinuz_dst).context("Moving vmlinuz")?;
1902                        std::fs::rename(&initramfs_src, &initramfs_dst)
1903                            .context("Moving initramfs")?;
1904                    }
1905
1906                    KernelType::Uki { .. } => {
1907                        anyhow::bail!("UKIs are not supported");
1908                    }
1909                }
1910
1911                Ok(())
1912            }
1913            ContainerOpts::ComputeComposefsDigest {
1914                path,
1915                write_dumpfile_to,
1916            } => {
1917                let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref()).await?;
1918                println!("{digest}");
1919                Ok(())
1920            }
1921            ContainerOpts::ComputeComposefsDigestFromStorage {
1922                write_dumpfile_to,
1923                image,
1924            } => {
1925                let (_td_guard, repo) = new_temp_composefs_repo()?;
1926
1927                let mut proxycfg = crate::deploy::new_proxy_config();
1928
1929                let image = if let Some(image) = image {
1930                    image
1931                } else {
1932                    let host_container_store = Utf8Path::new("/run/host-container-storage");
1933                    // If no image is provided, assume that we're running in a container in privileged mode
1934                    // with access to the container storage.
1935                    let container_info = crate::containerenv::get_container_execution_info(&root)?;
1936                    let iid = container_info.imageid;
1937                    tracing::debug!("Computing digest of {iid}");
1938
1939                    if !host_container_store.try_exists()? {
1940                        anyhow::bail!(
1941                            "Must be readonly mount of host container store: {host_container_store}"
1942                        );
1943                    }
1944                    // And ensure we're finding the image in the host storage
1945                    let mut cmd = Command::new(bootc_utils::skopeo_bin());
1946                    set_additional_image_store(&mut cmd, "/run/host-container-storage");
1947                    proxycfg.skopeo_cmd = Some(cmd);
1948                    iid
1949                };
1950
1951                let imgref = format!("containers-storage:{image}");
1952                let host_store = std::path::Path::new("/run/host-container-storage");
1953                let opts = composefs_oci::PullOptions {
1954                    img_proxy_config: Some(proxycfg),
1955                    additional_image_stores: &[host_store],
1956                    ..Default::default()
1957                };
1958                let pull_result = composefs_oci::pull(&repo, &imgref, None, opts)
1959                    .await
1960                    .context("Pulling image")?;
1961                let mut fs = composefs_oci::image::create_filesystem(
1962                    &repo,
1963                    &pull_result.config_digest,
1964                    Some(&pull_result.config_verity),
1965                )
1966                .context("Populating fs")?;
1967                fs.transform_for_boot(&repo).context("Preparing for boot")?;
1968                let id = fs.compute_image_id(repo.erofs_version());
1969                println!("{}", id.to_hex());
1970
1971                if let Some(path) = write_dumpfile_to.as_deref() {
1972                    let mut w = File::create(path)
1973                        .with_context(|| format!("Opening {path}"))
1974                        .map(BufWriter::new)?;
1975                    dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
1976                }
1977
1978                Ok(())
1979            }
1980            ContainerOpts::Ukify {
1981                rootfs,
1982                kargs,
1983                allow_missing_verity,
1984                write_dumpfile_to,
1985                kernel_dir,
1986                args,
1987            } => {
1988                let kernel = match kernel_dir {
1989                    Some(kernel_dir) => {
1990                        let kver = kernel_dir
1991                            .components()
1992                            .last()
1993                            .ok_or_else(|| anyhow::anyhow!("Could not determine kernel version"))?;
1994
1995                        Some(crate::kernel::KernelInternal {
1996                            kernel: crate::kernel::Kernel {
1997                                unified: false,
1998                                version: kver.to_string(),
1999                            },
2000                            k_type: crate::kernel::KernelType::Vmlinuz {
2001                                path: kernel_dir.join("vmlinuz"),
2002                                initramfs: kernel_dir.join("initramfs.img"),
2003                            },
2004                        })
2005                    }
2006
2007                    None => None,
2008                };
2009
2010                crate::ukify::build_ukify(
2011                    &rootfs,
2012                    &kargs,
2013                    &args,
2014                    kernel,
2015                    allow_missing_verity,
2016                    write_dumpfile_to.as_deref(),
2017                )
2018                .await
2019            }
2020            ContainerOpts::Export {
2021                format,
2022                target,
2023                output,
2024                kernel_in_boot,
2025                disable_selinux,
2026            } => {
2027                crate::container_export::export(
2028                    &format,
2029                    &target,
2030                    output.as_deref(),
2031                    kernel_in_boot,
2032                    disable_selinux,
2033                )
2034                .await
2035            }
2036        },
2037        Opt::Completion { shell } => {
2038            use clap_complete::aot::generate;
2039
2040            let mut cmd = Opt::command();
2041            let mut stdout = std::io::stdout();
2042            let bin_name = "bootc";
2043            generate(shell, &mut cmd, bin_name, &mut stdout);
2044            Ok(())
2045        }
2046        Opt::Image(opts) => match opts {
2047            ImageOpts::List {
2048                list_type,
2049                list_format,
2050            } => crate::image::list_entrypoint(list_type, list_format).await,
2051
2052            ImageOpts::CopyToStorage { source, target } => {
2053                // We get "host" here to avoid deadlock in the ostree path
2054                let host = get_host().await?;
2055
2056                let storage = get_storage().await?;
2057
2058                match storage.kind()? {
2059                    BootedStorageKind::Ostree(..) => {
2060                        crate::image::push_entrypoint(
2061                            &storage,
2062                            &host,
2063                            source.as_deref(),
2064                            target.as_deref(),
2065                        )
2066                        .await
2067                    }
2068                    BootedStorageKind::Composefs(booted) => {
2069                        bootc_composefs::export::export_repo_to_image(
2070                            &storage,
2071                            &booted,
2072                            source.as_deref(),
2073                            target.as_deref(),
2074                        )
2075                        .await
2076                    }
2077                }
2078            }
2079            ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
2080            ImageOpts::PullFromDefaultStorage { image } => {
2081                let storage = get_storage().await?;
2082                storage
2083                    .get_ensure_imgstore()?
2084                    .pull_from_host_storage(&image)
2085                    .await
2086            }
2087            ImageOpts::Cmd(opt) => {
2088                let storage = get_storage().await?;
2089                let imgstore = storage.get_ensure_imgstore()?;
2090                match opt {
2091                    ImageCmdOpts::List { args } => {
2092                        crate::image::imgcmd_entrypoint(imgstore, "list", &args).await
2093                    }
2094                    ImageCmdOpts::Build { args } => {
2095                        crate::image::imgcmd_entrypoint(imgstore, "build", &args).await
2096                    }
2097                    ImageCmdOpts::Pull { images } => {
2098                        for image in &images {
2099                            imgstore.pull_with_progress(image).await?;
2100                        }
2101                        Ok(())
2102                    }
2103                    ImageCmdOpts::Push { args } => {
2104                        crate::image::imgcmd_entrypoint(imgstore, "push", &args).await
2105                    }
2106                }
2107            }
2108        },
2109        Opt::Install(opts) => match opts {
2110            #[cfg(feature = "install-to-disk")]
2111            InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await,
2112            InstallOpts::ToFilesystem(opts) => {
2113                crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip)
2114                    .await
2115            }
2116            InstallOpts::ToExistingRoot(opts) => {
2117                crate::install::install_to_existing_root(opts).await
2118            }
2119            InstallOpts::Reset(opts) => crate::install::install_reset(opts).await,
2120            InstallOpts::PrintConfiguration(opts) => crate::install::print_configuration(opts),
2121            InstallOpts::EnsureCompletion {} => {
2122                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2123                crate::install::completion::run_from_anaconda(rootfs).await
2124            }
2125            InstallOpts::Finalize { root_path } => {
2126                crate::install::install_finalize(&root_path).await
2127            }
2128        },
2129        Opt::LoaderEntries(opts) => match opts {
2130            LoaderEntriesOpts::SetOptionsForSource(opts) => {
2131                let storage = get_storage().await?;
2132                let sysroot = storage.get_ostree()?;
2133                crate::loader_entries::set_options_for_source_staged(
2134                    sysroot,
2135                    &opts.source,
2136                    opts.options.as_deref(),
2137                )?;
2138                Ok(())
2139            }
2140        },
2141        Opt::ExecInHostMountNamespace { args } => {
2142            crate::install::exec_in_host_mountns(args.as_slice())
2143        }
2144        Opt::Status(opts) => super::status::status(opts).await,
2145        Opt::Internals(opts) => match opts {
2146            InternalsOpts::SystemdGenerator {
2147                normal_dir,
2148                early_dir: _,
2149                late_dir: _,
2150            } => {
2151                let unit_dir = &Dir::open_ambient_dir(normal_dir, cap_std::ambient_authority())?;
2152                crate::generator::generator(root, unit_dir)
2153            }
2154            InternalsOpts::OstreeExt { args } => {
2155                ostree_ext::cli::run_from_iter(["ostree-ext".into()].into_iter().chain(args)).await
2156            }
2157            InternalsOpts::OstreeContainer { args } => {
2158                ostree_ext::cli::run_from_iter(
2159                    ["ostree-ext".into(), "container".into()]
2160                        .into_iter()
2161                        .chain(args),
2162                )
2163                .await
2164            }
2165            InternalsOpts::TestComposefs => {
2166                // This is a stub to be replaced
2167                let storage = get_storage().await?;
2168                let cfs = storage.get_ensure_composefs()?;
2169                let testdata = b"some test data";
2170                let testdata_digest = hex::encode(openssl::sha::sha256(testdata));
2171                let mut w = cfs.create_stream(0)?;
2172                w.write_inline(testdata);
2173                let object = cfs
2174                    .write_stream(w, &testdata_digest, Some("testobject"))?
2175                    .to_hex();
2176                assert_eq!(
2177                    object,
2178                    "84245c6936db9939dda9c1fbeafdcbd2b49f7605354c88d4f016c4d941551f45bad0fbcdbee12ba8adfe4fb63541de57ac02729edbacdb556325e342b89d340d"
2179                );
2180                Ok(())
2181            }
2182            // We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.
2183            InternalsOpts::Fsverity(args) => match args {
2184                FsverityOpts::Measure { path } => {
2185                    let fd =
2186                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
2187                    let digest: fsverity::Sha256HashValue = fsverity::measure_verity(&fd)?;
2188                    let digest = digest.to_hex();
2189                    println!("{digest}");
2190                    Ok(())
2191                }
2192                FsverityOpts::Enable { path } => {
2193                    let fd =
2194                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
2195                    fsverity::enable_verity_raw::<fsverity::Sha256HashValue>(&fd)?;
2196                    Ok(())
2197                }
2198            },
2199            InternalsOpts::Cfs { args } => composefs_ctl::run_from_iter(args.iter()).await,
2200            InternalsOpts::Reboot => crate::reboot::reboot(),
2201            InternalsOpts::Fsck => {
2202                let storage = &get_storage().await?;
2203                crate::fsck::fsck(&storage, std::io::stdout().lock()).await?;
2204                Ok(())
2205            }
2206            InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root),
2207            InternalsOpts::SysusersSync => crate::sysusers_cleanup::run(&root),
2208            InternalsOpts::PrintJsonSchema { of } => {
2209                let schema = match of {
2210                    SchemaType::Host => schema_for!(crate::spec::Host),
2211                    SchemaType::Progress => schema_for!(crate::progress_jsonl::Event),
2212                };
2213                let mut stdout = std::io::stdout().lock();
2214                serde_json::to_writer_pretty(&mut stdout, &schema)?;
2215                Ok(())
2216            }
2217            InternalsOpts::Cleanup => {
2218                let storage = get_storage().await?;
2219                crate::deploy::cleanup(&storage).await
2220            }
2221            InternalsOpts::Relabel { as_path, path } => {
2222                let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2223                let path = path.strip_prefix("/")?;
2224                let sepolicy =
2225                    &ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)?;
2226                crate::lsm::relabel_recurse(root, path, as_path.as_deref(), sepolicy)?;
2227                Ok(())
2228            }
2229            InternalsOpts::RelabelOverlayMountpoints => {
2230                crate::generator::relabel_overlay_mountpoints()
2231            }
2232            InternalsOpts::BootcInstallCompletion { sysroot, stateroot } => {
2233                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2234                crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await
2235            }
2236            InternalsOpts::LoopbackCleanupHelper { device } => {
2237                crate::blockdev::run_loopback_cleanup_helper(&device).await
2238            }
2239            InternalsOpts::AllocateCleanupLoopback { file_path: _ } => {
2240                // Create a temporary file for testing
2241                let temp_file =
2242                    tempfile::NamedTempFile::new().context("Failed to create temporary file")?;
2243                let temp_path = temp_file.path();
2244
2245                // Create a loopback device
2246                let loopback = crate::blockdev::LoopbackDevice::new(temp_path)
2247                    .context("Failed to create loopback device")?;
2248
2249                println!("Created loopback device: {}", loopback.path());
2250
2251                // Close the device to test cleanup
2252                loopback
2253                    .close()
2254                    .context("Failed to close loopback device")?;
2255
2256                println!("Successfully closed loopback device");
2257                Ok(())
2258            }
2259            #[cfg(feature = "rhsm")]
2260            InternalsOpts::PublishRhsmFacts => crate::rhsm::publish_facts(&root).await,
2261            #[cfg(feature = "docgen")]
2262            InternalsOpts::DumpCliJson => {
2263                use clap::CommandFactory;
2264                let cmd = Opt::command();
2265                let json = crate::cli_json::dump_cli_json(&cmd)?;
2266                println!("{}", json);
2267                Ok(())
2268            }
2269            InternalsOpts::DirDiff {
2270                pristine_etc,
2271                current_etc,
2272                new_etc,
2273                merge,
2274            } => {
2275                let pristine_etc =
2276                    Dir::open_ambient_dir(pristine_etc, cap_std::ambient_authority())?;
2277                let current_etc = Dir::open_ambient_dir(current_etc, cap_std::ambient_authority())?;
2278                let new_etc = Dir::open_ambient_dir(new_etc, cap_std::ambient_authority())?;
2279
2280                let (p, c, n) =
2281                    etc_merge::traverse_etc(&pristine_etc, &current_etc, Some(&new_etc))?;
2282
2283                let n = n
2284                    .as_ref()
2285                    .ok_or_else(|| anyhow::anyhow!("Failed to get new directory tree"))?;
2286
2287                let diff = compute_diff(&p, &c, &n)?;
2288                print_diff(&diff, &mut std::io::stdout());
2289
2290                if merge {
2291                    etc_merge::merge(&current_etc, &c, &new_etc, &n, &diff)?;
2292                }
2293
2294                Ok(())
2295            }
2296            InternalsOpts::PrepSoftReboot {
2297                deployment,
2298                reboot,
2299                reset,
2300            } => {
2301                let storage = &get_storage().await?;
2302
2303                match storage.kind()? {
2304                    BootedStorageKind::Ostree(..) => {
2305                        // TODO: Call ostree implementation?
2306                        anyhow::bail!("soft-reboot only implemented for composefs")
2307                    }
2308
2309                    BootedStorageKind::Composefs(booted_cfs) => {
2310                        if reset {
2311                            return reset_soft_reboot();
2312                        }
2313
2314                        prepare_soft_reboot_composefs(
2315                            &storage,
2316                            &booted_cfs,
2317                            deployment.as_deref(),
2318                            SoftRebootMode::Required,
2319                            reboot,
2320                        )
2321                        .await
2322                    }
2323                }
2324            }
2325            InternalsOpts::ComposefsGC {
2326                dry_run,
2327                assert_no_op,
2328                prune_repo,
2329            } => {
2330                let storage = &get_storage().await?;
2331
2332                match storage.kind()? {
2333                    BootedStorageKind::Ostree(..) => {
2334                        anyhow::bail!("composefs-gc only works for composefs backend");
2335                    }
2336
2337                    BootedStorageKind::Composefs(booted_cfs) => {
2338                        let dry_run = dry_run || assert_no_op;
2339                        let gc_result = composefs_gc(
2340                            storage,
2341                            &booted_cfs,
2342                            GCOpts {
2343                                dry_run,
2344                                prune_repo,
2345                            },
2346                        )
2347                        .await?;
2348
2349                        if dry_run {
2350                            println!("Dry run (no files deleted)");
2351                        }
2352
2353                        println!(
2354                            "Objects: {} removed ({} bytes)",
2355                            gc_result.objects_removed, gc_result.objects_bytes
2356                        );
2357
2358                        if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 {
2359                            println!(
2360                                "Pruned symlinks: {} images, {} streams",
2361                                gc_result.images_pruned, gc_result.streams_pruned
2362                            );
2363                        }
2364
2365                        if assert_no_op {
2366                            let is_noop = gc_result.objects_removed == 0
2367                                && gc_result.images_pruned == 0
2368                                && gc_result.streams_pruned == 0;
2369                            if !is_noop {
2370                                anyhow::bail!(
2371                                    "--assert-no-op: GC would remove {} object(s), {} image symlink(s), {} stream symlink(s) (issue #1808)",
2372                                    gc_result.objects_removed,
2373                                    gc_result.images_pruned,
2374                                    gc_result.streams_pruned,
2375                                );
2376                            }
2377                        }
2378
2379                        Ok(())
2380                    }
2381                }
2382            }
2383            InternalsOpts::Blockdev(opts) => {
2384                let dev = match opts {
2385                    BlockdevOpts::Ls { device } => crate::blockdev::list_dev(&device)?,
2386                    BlockdevOpts::LsFilesystem { path } => {
2387                        let dir = Dir::open_ambient_dir(&path, cap_std::ambient_authority())?;
2388                        crate::blockdev::list_dev_by_dir(&dir)?
2389                    }
2390                };
2391                serde_json::to_writer_pretty(std::io::stdout().lock(), &dev)?;
2392                println!();
2393                Ok(())
2394            }
2395            InternalsOpts::Uki(uki_opts) => match uki_opts {
2396                UkiSubcommands::Extract { path, output_path } => {
2397                    let mut uki_file =
2398                        std::fs::File::open(&path).with_context(|| format!("Opening {path}"))?;
2399
2400                    let uname =
2401                        composefs_boot::uki::get_text_section_buffered(&mut uki_file, ".uname")
2402                            .context("Getting uname")?;
2403
2404                    std::fs::create_dir_all(&output_path).context("Creating output directory")?;
2405
2406                    let output_dir = Dir::open_ambient_dir(&output_path, ambient_authority())
2407                        .context("Opening output dir")?;
2408                    output_dir.create_dir(&uname)?;
2409
2410                    let output_dir = output_dir.open_dir(&uname)?;
2411
2412                    for (section_name, file_name) in
2413                        [(".linux", "vmlinuz"), (".initrd", "initramfs.img")]
2414                    {
2415                        uki_file
2416                            .seek(SeekFrom::Start(0))
2417                            .context("Seeking to start")?;
2418                        let section =
2419                            composefs_boot::uki::get_section_buffered(&mut uki_file, section_name)
2420                                .with_context(|| format!("Getting {section_name} section"))?;
2421                        output_dir
2422                            .write(file_name, section)
2423                            .with_context(|| format!("Writing {file_name}"))?;
2424                    }
2425
2426                    Ok(())
2427                }
2428            },
2429        },
2430        Opt::State(opts) => match opts {
2431            StateOpts::WipeOstree => {
2432                let sysroot = ostree::Sysroot::new_default();
2433                sysroot.load(gio::Cancellable::NONE)?;
2434                crate::deploy::wipe_ostree(sysroot).await?;
2435                Ok(())
2436            }
2437        },
2438
2439        Opt::ComposefsFinalizeStaged => {
2440            let storage = &get_storage().await?;
2441            match storage.kind()? {
2442                BootedStorageKind::Ostree(_) => {
2443                    anyhow::bail!("ComposefsFinalizeStaged is only supported for composefs backend")
2444                }
2445                BootedStorageKind::Composefs(booted_cfs) => {
2446                    composefs_backend_finalize(storage, &booted_cfs).await
2447                }
2448            }
2449        }
2450
2451        Opt::ConfigDiff => {
2452            let storage = &get_storage().await?;
2453            match storage.kind()? {
2454                BootedStorageKind::Ostree(_) => {
2455                    anyhow::bail!("ConfigDiff is only supported for composefs backend")
2456                }
2457                BootedStorageKind::Composefs(booted_cfs) => {
2458                    get_etc_diff(storage, &booted_cfs).await
2459                }
2460            }
2461        }
2462
2463        Opt::DeleteDeployment { depl_id } => {
2464            let storage = &get_storage().await?;
2465            match storage.kind()? {
2466                BootedStorageKind::Ostree(_) => {
2467                    anyhow::bail!("DeleteDeployment is only supported for composefs backend")
2468                }
2469                BootedStorageKind::Composefs(booted_cfs) => {
2470                    delete_composefs_deployment(&depl_id, storage, &booted_cfs).await
2471                }
2472            }
2473        }
2474    }
2475}
2476
2477#[cfg(test)]
2478mod tests {
2479    use super::*;
2480
2481    #[test]
2482    fn test_callname() {
2483        use std::os::unix::ffi::OsStrExt;
2484
2485        // Cases that change
2486        let mapped_cases = [
2487            ("", "bootc"),
2488            ("/foo/bar", "bar"),
2489            ("/foo/bar/", "bar"),
2490            ("foo/bar", "bar"),
2491            ("../foo/bar", "bar"),
2492            ("usr/bin/ostree-container", "ostree-container"),
2493        ];
2494        for (input, output) in mapped_cases {
2495            assert_eq!(
2496                output,
2497                callname_from_argv0(OsStr::new(input)),
2498                "Handling mapped case {input}"
2499            );
2500        }
2501
2502        // Invalid UTF-8
2503        assert_eq!("bootc", callname_from_argv0(OsStr::from_bytes(b"foo\x80")));
2504
2505        // Cases that are identical
2506        let ident_cases = ["foo", "bootc"];
2507        for case in ident_cases {
2508            assert_eq!(
2509                case,
2510                callname_from_argv0(OsStr::new(case)),
2511                "Handling ident case {case}"
2512            );
2513        }
2514    }
2515
2516    #[test]
2517    fn test_parse_install_args() {
2518        // Verify we still process the legacy --target-no-signature-verification
2519        let o = Opt::try_parse_from([
2520            "bootc",
2521            "install",
2522            "to-filesystem",
2523            "--target-no-signature-verification",
2524            "/target",
2525        ])
2526        .unwrap();
2527        let o = match o {
2528            Opt::Install(InstallOpts::ToFilesystem(fsopts)) => fsopts,
2529            o => panic!("Expected filesystem opts, not {o:?}"),
2530        };
2531        assert!(o.target_opts.target_no_signature_verification);
2532        assert_eq!(o.filesystem_opts.root_path.as_str(), "/target");
2533        // Ensure we default to old bound images behavior
2534        assert_eq!(
2535            o.config_opts.bound_images,
2536            crate::install::BoundImagesOpt::Stored
2537        );
2538    }
2539
2540    #[test]
2541    fn test_parse_opts() {
2542        assert!(matches!(
2543            Opt::parse_including_static(["bootc", "status"]),
2544            Opt::Status(StatusOpts {
2545                json: false,
2546                format: None,
2547                format_version: None,
2548                booted: false,
2549                verbose: false
2550            })
2551        ));
2552        assert!(matches!(
2553            Opt::parse_including_static(["bootc", "status", "--format-version=0"]),
2554            Opt::Status(StatusOpts {
2555                format_version: Some(0),
2556                ..
2557            })
2558        ));
2559
2560        // Test verbose long form
2561        assert!(matches!(
2562            Opt::parse_including_static(["bootc", "status", "--verbose"]),
2563            Opt::Status(StatusOpts { verbose: true, .. })
2564        ));
2565
2566        // Test verbose short form
2567        assert!(matches!(
2568            Opt::parse_including_static(["bootc", "status", "-v"]),
2569            Opt::Status(StatusOpts { verbose: true, .. })
2570        ));
2571    }
2572
2573    #[test]
2574    fn test_parse_generator() {
2575        assert!(matches!(
2576            Opt::parse_including_static([
2577                "/usr/lib/systemd/system/bootc-systemd-generator",
2578                "/run/systemd/system"
2579            ]),
2580            Opt::Internals(InternalsOpts::SystemdGenerator { normal_dir, .. }) if normal_dir == "/run/systemd/system"
2581        ));
2582    }
2583
2584    #[test]
2585    fn test_parse_ostree_ext() {
2586        assert!(matches!(
2587            Opt::parse_including_static(["bootc", "internals", "ostree-container"]),
2588            Opt::Internals(InternalsOpts::OstreeContainer { .. })
2589        ));
2590
2591        fn peel(o: Opt) -> Vec<OsString> {
2592            match o {
2593                Opt::Internals(InternalsOpts::OstreeExt { args }) => args,
2594                o => panic!("unexpected {o:?}"),
2595            }
2596        }
2597        let args = peel(Opt::parse_including_static([
2598            "/usr/libexec/libostree/ext/ostree-ima-sign",
2599            "ima-sign",
2600            "--repo=foo",
2601            "foo",
2602            "bar",
2603            "baz",
2604        ]));
2605        assert_eq!(
2606            args.as_slice(),
2607            ["ima-sign", "--repo=foo", "foo", "bar", "baz"]
2608        );
2609
2610        let args = peel(Opt::parse_including_static([
2611            "/usr/libexec/libostree/ext/ostree-container",
2612            "container",
2613            "image",
2614            "pull",
2615        ]));
2616        assert_eq!(args.as_slice(), ["container", "image", "pull"]);
2617    }
2618
2619    #[test]
2620    fn test_parse_upgrade_options() {
2621        // Test upgrade with --tag
2622        let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1"]).unwrap();
2623        match o {
2624            Opt::Upgrade(opts) => {
2625                assert_eq!(opts.tag, Some("v1.1".to_string()));
2626            }
2627            _ => panic!("Expected Upgrade variant"),
2628        }
2629
2630        // Test that --tag works with --check (should compose naturally)
2631        let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1", "--check"]).unwrap();
2632        match o {
2633            Opt::Upgrade(opts) => {
2634                assert_eq!(opts.tag, Some("v1.1".to_string()));
2635                assert!(opts.check);
2636            }
2637            _ => panic!("Expected Upgrade variant"),
2638        }
2639    }
2640
2641    #[test]
2642    fn test_image_reference_with_tag() {
2643        // Test basic tag replacement for registry transport
2644        let current = ImageReference {
2645            image: "quay.io/example/myapp:v1.0".to_string(),
2646            transport: "registry".to_string(),
2647            signature: None,
2648        };
2649        let result = current.with_tag("v1.1").unwrap();
2650        assert_eq!(result.image, "quay.io/example/myapp:v1.1");
2651        assert_eq!(result.transport, "registry");
2652
2653        // Test tag replacement with digest (digest should be stripped for registry)
2654        let current_with_digest = ImageReference {
2655            image: "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
2656            transport: "registry".to_string(),
2657            signature: None,
2658        };
2659        let result = current_with_digest.with_tag("v2.0").unwrap();
2660        assert_eq!(result.image, "quay.io/example/myapp:v2.0");
2661
2662        // Test that non-registry transport works (containers-storage)
2663        let containers_storage = ImageReference {
2664            image: "localhost/myapp:v1.0".to_string(),
2665            transport: "containers-storage".to_string(),
2666            signature: None,
2667        };
2668        let result = containers_storage.with_tag("v1.1").unwrap();
2669        assert_eq!(result.image, "localhost/myapp:v1.1");
2670        assert_eq!(result.transport, "containers-storage");
2671
2672        // Test digest stripping for non-registry transport
2673        let containers_storage_with_digest = ImageReference {
2674            image:
2675                "localhost/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
2676                    .to_string(),
2677            transport: "containers-storage".to_string(),
2678            signature: None,
2679        };
2680        let result = containers_storage_with_digest.with_tag("v2.0").unwrap();
2681        assert_eq!(result.image, "localhost/myapp:v2.0");
2682        assert_eq!(result.transport, "containers-storage");
2683
2684        // Test image without tag (edge case)
2685        let no_tag = ImageReference {
2686            image: "localhost/myapp".to_string(),
2687            transport: "containers-storage".to_string(),
2688            signature: None,
2689        };
2690        let result = no_tag.with_tag("v1.0").unwrap();
2691        assert_eq!(result.image, "localhost/myapp:v1.0");
2692        assert_eq!(result.transport, "containers-storage");
2693    }
2694
2695    #[test]
2696    fn test_generate_completion_scripts_contain_commands() {
2697        use clap_complete::aot::{Shell, generate};
2698
2699        // For each supported shell, generate the completion script and
2700        // ensure obvious subcommands appear in the output. This mirrors
2701        // the style of completion checks used in other projects (e.g.
2702        // podman) where the generated script is examined for expected
2703        // tokens.
2704
2705        // `completion` is intentionally hidden from --help / suggestions;
2706        // ensure other visible subcommands are present instead.
2707        let want = ["install", "upgrade"];
2708
2709        for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
2710            let mut cmd = Opt::command();
2711            let mut buf = Vec::new();
2712            generate(shell, &mut cmd, "bootc", &mut buf);
2713            let s = String::from_utf8(buf).expect("completion should be utf8");
2714            for w in &want {
2715                assert!(s.contains(w), "{shell:?} completion missing {w}");
2716            }
2717        }
2718    }
2719}