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