Skip to main content

bootc_lib/
cli.rs

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