Skip to main content

bootc_lib/
install.rs

1//! # Writing a container to a block device in a bootable way
2//!
3//! This module implements the core installation logic for bootc, enabling a container
4//! image to be written to storage in a bootable form. It bridges the gap between
5//! OCI container images and traditional bootable Linux systems.
6//!
7//! ## Overview
8//!
9//! The installation process transforms a container image into a bootable system by:
10//!
11//! 1. **Preparing the environment**: Validating we're running in a privileged container,
12//!    handling SELinux re-execution if needed, and loading configuration.
13//!
14//! 2. **Setting up storage**: Either creating partitions (`to-disk`) or using
15//!    externally-prepared filesystems (`to-filesystem`).
16//!
17//! 3. **Deploying the image**: Pulling the container image into an ostree repository
18//!    and creating a deployment, or setting up a composefs-based root.
19//!
20//! 4. **Installing the bootloader**: Using bootupd, systemd-boot, or zipl depending
21//!    on architecture and configuration.
22//!
23//! 5. **Finalizing**: Trimming the filesystem, flushing writes, and freezing/thawing
24//!    the journal.
25//!
26//! ## Installation Modes
27//!
28//! ### `bootc install to-disk`
29//!
30//! Creates a complete bootable system on a block device. This is the simplest path
31//! and handles partitioning automatically using the Discoverable Partitions
32//! Specification (DPS). The partition layout includes:
33//!
34//! - **ESP** (EFI System Partition): Required for UEFI boot
35//! - **BIOS boot partition**: For legacy boot on x86_64
36//! - **Boot partition**: Optional, used when LUKS encryption is enabled
37//! - **Root partition**: Uses architecture-specific DPS type GUIDs for auto-discovery
38//!
39//! ### `bootc install to-filesystem`
40//!
41//! Installs to a pre-mounted filesystem, allowing external tools to handle complex
42//! storage layouts (RAID, LVM, custom LUKS configurations). The caller is responsible
43//! for creating and mounting the filesystem, then providing appropriate `--karg`
44//! options or mount specifications.
45//!
46//! ### `bootc install to-existing-root`
47//!
48//! "Alongside" installation mode that converts an existing Linux system. The boot
49//! partition is wiped and replaced, but the root filesystem content is preserved
50//! until reboot. Post-reboot, the old system is accessible at `/sysroot` for
51//! data migration.
52//!
53//! ### `bootc install reset`
54//!
55//! Creates a new stateroot within an existing bootc system, effectively providing
56//! a factory-reset capability without touching other stateroots.
57//!
58//! ## Storage Backends
59//!
60//! ### OSTree Backend (Default)
61//!
62//! Uses ostree-ext to convert container layers into an ostree repository. The
63//! deployment is created via `ostree admin deploy`, and bootloader entries are
64//! managed via BLS (Boot Loader Specification) files.
65//!
66//! ### Composefs Backend (Experimental)
67//!
68//! Alternative backend using composefs overlayfs for the root filesystem. Provides
69//! stronger integrity guarantees via fs-verity and supports UKI (Unified Kernel
70//! Images) for measured boot scenarios.
71//!
72//! ## Discoverable Partitions Specification (DPS)
73//!
74//! As of bootc 1.11, partitions are created with DPS type GUIDs from the
75//! [UAPI Group specification](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/).
76//! This enables:
77//!
78//! - **Auto-discovery**: systemd-gpt-auto-generator can mount partitions without
79//!   explicit configuration
80//! - **Architecture awareness**: Root partition types are architecture-specific,
81//!   preventing cross-architecture boot issues
82//! - **Future extensibility**: Enables systemd-repart for declarative partition
83//!   management
84//!
85//! See [`bootc_blockdev::dps`] for the partition type GUIDs.
86//!
87//! ## Installation Flow
88//!
89//! The high-level flow is:
90//!
91//! 1. **CLI entry** → [`install_to_disk`], [`install_to_filesystem`], or [`install_to_existing_root`]
92//! 2. **Preparation** → [`prepare_install`] validates environment, handles SELinux, loads config
93//! 3. **Storage setup** → (to-disk only) [`baseline::install_create_rootfs`] partitions and formats
94//! 4. **Deployment** → [`install_to_filesystem_impl`] branches to OSTree or Composefs backend
95//! 5. **Bootloader** → [`crate::bootloader::install_via_bootupd`] or architecture-specific installer
96//! 6. **Finalization** → [`finalize_filesystem`] trims, flushes, and freezes the filesystem
97//!
98//! For a visual diagram of this flow, see the bootc documentation.
99//!
100//! ## Key Types
101//!
102//! - [`State`]: Immutable global state for the installation, including source image
103//!   info, SELinux state, configuration, and composefs options.
104//!
105//! - [`RootSetup`]: Represents the prepared root filesystem, including mount paths,
106//!   device information, boot partition specs, and kernel arguments.
107//!
108//! - [`SourceInfo`]: Information about the source container image, including the
109//!   ostree-container reference and whether SELinux labels are present.
110//!
111//! - [`SELinuxFinalState`]: Tracks SELinux handling during installation (enabled,
112//!   disabled, host-disabled, or force-disabled).
113//!
114//! ## Configuration
115//!
116//! Installation is configured via TOML files loaded from multiple paths in
117//! systemd-style priority order:
118//!
119//! - `/usr/lib/bootc/install/*.toml` - Distribution/image defaults
120//! - `/etc/bootc/install/*.toml` - Local overrides
121//!
122//! Files are merged alphanumerically, with higher-numbered files taking precedence.
123//! See [`config::InstallConfiguration`] for the schema.
124//!
125//! Key configurable options include:
126//! - Root filesystem type (xfs, ext4, btrfs)
127//! - Allowed block setups (direct, tpm2-luks)
128//! - Default kernel arguments
129//! - Architecture-specific overrides
130//!
131//! ## Submodules
132//!
133//! - [`baseline`]: The "baseline" installer for simple partitioning (to-disk)
134//! - [`config`]: TOML configuration parsing and merging
135//! - [`completion`]: Post-installation hooks for external installers (Anaconda)
136//! - [`osconfig`]: SSH key injection and OS configuration
137//! - [`aleph`]: Installation provenance tracking (.bootc-aleph.json)
138//! - `osbuild`: Helper APIs for bootc-image-builder integration
139
140// This sub-module is the "basic" installer that handles creating basic block device
141// and filesystem setup.
142mod aleph;
143#[cfg(feature = "install-to-disk")]
144pub(crate) mod baseline;
145pub(crate) mod completion;
146pub(crate) mod config;
147mod osbuild;
148pub(crate) mod osconfig;
149
150use std::collections::HashMap;
151use std::io::Write;
152use std::os::fd::{AsFd, AsRawFd};
153use std::os::unix::process::CommandExt;
154use std::path::Path;
155use std::process;
156use std::process::Command;
157use std::str::FromStr;
158use std::sync::Arc;
159use std::time::Duration;
160
161use aleph::InstallAleph;
162use anyhow::{Context, Result, anyhow, ensure};
163use bootc_utils::CommandRunExt;
164use camino::Utf8Path;
165use camino::Utf8PathBuf;
166use canon_json::CanonJsonSerialize;
167use cap_std::fs::{Dir, MetadataExt};
168use cap_std_ext::cap_std;
169use cap_std_ext::cap_std::fs::FileType;
170use cap_std_ext::cap_std::fs_utf8::DirEntry as DirEntryUtf8;
171use cap_std_ext::cap_tempfile::TempDir;
172use cap_std_ext::cmdext::CapStdExtCommandExt;
173use cap_std_ext::prelude::CapStdExtDirExt;
174use clap::ValueEnum;
175use fn_error_context::context;
176use linux_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
177use ostree::gio;
178use ostree_ext::ostree;
179use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate};
180use ostree_ext::prelude::Cast;
181use ostree_ext::sysroot::{SysrootLock, allocate_new_stateroot, list_stateroots};
182use ostree_ext::{container as ostree_container, ostree_prepareroot};
183#[cfg(feature = "install-to-disk")]
184use rustix::fs::FileTypeExt;
185use rustix::fs::MetadataExt as _;
186use serde::{Deserialize, Serialize};
187
188#[cfg(feature = "install-to-disk")]
189use self::baseline::InstallBlockDeviceOpts;
190use crate::bootc_composefs::status::ComposefsCmdline;
191use crate::bootc_composefs::{
192    boot::setup_composefs_boot,
193    repo::{
194        finalize_fresh_repository_policy, initialize_composefs_repository,
195        pull_install_composefs_repository, tag_pulled_image, validate_repository_policy,
196    },
197    status::get_container_manifest_and_config,
198};
199use crate::bootc_kargs::{INITRD_ARG_PREFIX, ROOTFLAGS_KEY};
200use crate::boundimage::{BoundImage, ResolvedBoundImage};
201use crate::containerenv::ContainerExecutionInfo;
202use crate::deploy::{
203    MergeState, PreparedPullResult, PullProgress, prepare_for_pull, pull_from_prepared,
204    retry_pull_operation,
205};
206use crate::install::config::Filesystem as FilesystemEnum;
207use crate::lsm;
208use crate::progress_jsonl::ProgressWriter;
209use crate::spec::{Bootloader, ImageReference};
210use crate::store::Storage;
211use crate::task::Task;
212use crate::utils::sigpolicy_from_opt;
213use bootc_mount::Filesystem;
214use linux_kernel_cmdline::{bytes, utf8};
215
216/// The toplevel boot directory
217pub(crate) const BOOT: &str = "boot";
218/// Directory for transient runtime state
219#[cfg(feature = "install-to-disk")]
220const RUN_BOOTC: &str = "/run/bootc";
221/// The default path for the host rootfs
222const ALONGSIDE_ROOT_MOUNT: &str = "/target";
223/// Global flag to signal the booted system was provisioned via an alongside bootc install
224pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup";
225/// This is an ext4 special directory we need to ignore.
226const LOST_AND_FOUND: &str = "lost+found";
227/// The mount path for selinux
228const SELINUXFS: &str = "/sys/fs/selinux";
229/// The mount path for uefi
230pub(crate) const EFIVARFS: &str = "/sys/firmware/efi/efivars";
231pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64"));
232
233pub(crate) const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
234
235const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[
236    // Default to avoiding grub2-mkconfig etc.
237    ("sysroot.bootloader", "none"),
238    // Always flip this one on because we need to support alongside installs
239    // to systems without a separate boot partition.
240    ("sysroot.bootprefix", "true"),
241    ("sysroot.readonly", "true"),
242];
243
244/// Kernel argument used to specify we want the rootfs mounted read-write by default
245pub(crate) const RW_KARG: &str = "rw";
246
247#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248pub(crate) struct InstallTargetOpts {
249    // TODO: A size specifier which allocates free space for the root in *addition* to the base container image size
250    // pub(crate) root_additional_size: Option<String>
251    /// The transport; e.g. oci, oci-archive, containers-storage.  Defaults to `registry`.
252    #[clap(long, default_value = "registry")]
253    #[serde(default)]
254    pub(crate) target_transport: String,
255
256    /// Specify the image to fetch for subsequent updates
257    #[clap(long)]
258    pub(crate) target_imgref: Option<String>,
259
260    /// This command line argument does nothing; it exists for compatibility.
261    ///
262    /// As of newer versions of bootc, this value is enabled by default,
263    /// i.e. it is not enforced that a signature
264    /// verification policy is enabled.  Hence to enable it, one can specify
265    /// `--target-no-signature-verification=false`.
266    ///
267    /// It is likely that the functionality here will be replaced with a different signature
268    /// enforcement scheme in the future that integrates with `podman`.
269    #[clap(long, hide = true)]
270    #[serde(default)]
271    pub(crate) target_no_signature_verification: bool,
272
273    /// This is the inverse of the previous `--target-no-signature-verification` (which is now
274    /// a no-op).  Enabling this option enforces that `containers-policy.json` (see `man
275    /// containers-policy.json` for the full search path) includes a default policy which
276    /// requires signatures.
277    #[clap(long)]
278    #[serde(default)]
279    pub(crate) enforce_container_sigpolicy: bool,
280
281    /// Verify the image can be fetched from the bootc image. Updates may fail when the installation
282    /// host is authenticated with the registry but the pull secret is not in the bootc image.
283    #[clap(long)]
284    #[serde(default)]
285    pub(crate) run_fetch_check: bool,
286
287    /// Verify the image can be fetched from the bootc image. Updates may fail when the installation
288    /// host is authenticated with the registry but the pull secret is not in the bootc image.
289    #[clap(long)]
290    #[serde(default)]
291    pub(crate) skip_fetch_check: bool,
292
293    /// Use unified storage path to pull images (experimental)
294    ///
295    /// When enabled, this uses bootc's container storage (/usr/lib/bootc/storage) to pull
296    /// the image first, then imports it from there. This is the same approach used for
297    /// logically bound images.
298    #[clap(long = "experimental-unified-storage", hide = true)]
299    #[serde(default)]
300    pub(crate) unified_storage_exp: bool,
301}
302
303#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304pub(crate) struct InstallSourceOpts {
305    /// Install the system from an explicitly given source.
306    ///
307    /// By default, bootc install and install-to-filesystem assumes that it runs in a podman container, and
308    /// it takes the container image to install from the podman's container registry.
309    /// If --source-imgref is given, bootc uses it as the installation source, instead of the behaviour explained
310    /// in the previous paragraph. See skopeo(1) for accepted formats.
311    #[clap(long)]
312    pub(crate) source_imgref: Option<String>,
313}
314
315#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
316#[serde(rename_all = "kebab-case")]
317pub(crate) enum BoundImagesOpt {
318    /// Bound images must exist in the source's root container storage (default)
319    #[default]
320    Stored,
321    #[clap(hide = true)]
322    /// Do not resolve any "logically bound" images at install time.
323    Skip,
324    // TODO: Once we implement https://github.com/bootc-dev/bootc/issues/863 update this comment
325    // to mention source's root container storage being used as lookaside cache
326    /// Bound images will be pulled and stored directly in the target's bootc container storage
327    Pull,
328}
329
330impl std::fmt::Display for BoundImagesOpt {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        self.to_possible_value().unwrap().get_name().fmt(f)
333    }
334}
335
336#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337pub(crate) struct InstallConfigOpts {
338    /// Disable SELinux in the target (installed) system.
339    ///
340    /// This is currently necessary to install *from* a system with SELinux disabled
341    /// but where the target does have SELinux enabled.
342    #[clap(long)]
343    #[serde(default)]
344    pub(crate) disable_selinux: bool,
345
346    /// Add a kernel argument.  This option can be provided multiple times.
347    ///
348    /// Example: --karg=nosmt --karg=console=ttyS0,115200n8
349    #[clap(long)]
350    pub(crate) karg: Option<Vec<CmdlineOwned>>,
351
352    /// Remove a kernel argument.  This option can be provided multiple times.
353    ///
354    /// Example: --karg-delete=nosmt --karg=console=ttyS0,115200n8
355    #[clap(long)]
356    pub(crate) karg_delete: Option<Vec<String>>,
357
358    /// The path to an `authorized_keys` that will be injected into the `root` account.
359    ///
360    /// The implementation of this uses systemd `tmpfiles.d`, writing to a file named
361    /// `/etc/tmpfiles.d/bootc-root-ssh.conf`.  This will have the effect that by default,
362    /// the SSH credentials will be set if not present.  The intention behind this
363    /// is to allow mounting the whole `/root` home directory as a `tmpfs`, while still
364    /// getting the SSH key replaced on boot.
365    #[clap(long)]
366    root_ssh_authorized_keys: Option<Utf8PathBuf>,
367
368    /// Perform configuration changes suitable for a "generic" disk image.
369    /// At the moment:
370    ///
371    /// - All bootloader types will be installed
372    /// - Changes to the system firmware will be skipped
373    #[clap(long)]
374    #[serde(default)]
375    pub(crate) generic_image: bool,
376
377    /// How should logically bound images be retrieved.
378    #[clap(long)]
379    #[serde(default)]
380    #[arg(default_value_t)]
381    pub(crate) bound_images: BoundImagesOpt,
382
383    /// The stateroot name to use. Defaults to `default`.
384    #[clap(long)]
385    pub(crate) stateroot: Option<String>,
386
387    /// Don't pass --write-uuid to bootupd during bootloader installation.
388    #[clap(long)]
389    #[serde(default)]
390    pub(crate) bootupd_skip_boot_uuid: bool,
391
392    /// The bootloader to use.
393    #[clap(long)]
394    #[serde(default)]
395    pub(crate) bootloader: Option<Bootloader>,
396}
397
398#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)]
399pub(crate) struct UkiAddonOpts {
400    /// Experimental.
401    /// Name of the local/scoped UKI addons to install without the ".addon.efi" suffix.
402    /// This option can be provided multiple times if multiple addons are to be installed
403    /// (composefs backend only). On upgrade and switch, scoped addons already installed
404    /// are carried forward; passing this flag adds further addons on top.
405    #[clap(long = "uki-addon")]
406    #[serde(default, rename = "uki_addon")]
407    pub(crate) scoped: Option<Vec<String>>,
408
409    /// Experimental.
410    /// Name of the global UKI addons to install without the ".addon.efi" suffix.
411    /// This option can be provided multiple times if multiple addons are to be installed
412    /// (composefs backend only). On upgrade and switch, global addons already installed
413    /// are carried forward; passing this flag adds further addons on top.
414    #[clap(long = "global-uki-addon")]
415    #[serde(default, rename = "global_uki_addon")]
416    pub(crate) global: Option<Vec<String>>,
417}
418
419#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)]
420pub(crate) struct InstallComposefsOpts {
421    /// Use the composefs backend instead of ostree. This is the default for images with a UKI,
422    /// and for images with /usr/lib/composefs/setup-root-conf.toml and no ostree prepare-root.conf
423    #[clap(long, default_value_t)]
424    #[serde(default)]
425    pub(crate) composefs_backend: bool,
426
427    /// Make fs-verity optional: add the `?` marker to the composefs kernel argument
428    /// (`composefs.digest=?...` or `composefs=?...`) and don't require fs-verity in the
429    /// repository (composefs backend only). Installing onto ext4 created without `-O verity`
430    /// still fails; use ext4 with `-O verity`, or XFS.
431    ///
432    /// This is the default on XFS. For an image with a UKI or an aboot image, the command line
433    /// built into it decides instead (see `bootc container ukify --allow-missing-verity`).
434    #[clap(long, default_value_t)]
435    #[serde(default)]
436    pub(crate) allow_missing_verity: bool,
437
438    #[clap(flatten)]
439    #[serde(flatten)]
440    pub(crate) uki_addon_opts: UkiAddonOpts,
441}
442
443impl InstallComposefsOpts {
444    /// Check that the options fit together, once `composefs_backend` says
445    /// whether the composefs backend is used (passed, or selected by the image).
446    /// `config_stateroot` comes from the install configuration; the caller
447    /// passes `None` when `--stateroot` overrides it.
448    pub(crate) fn validate(
449        &self,
450        bootloader: Option<&Bootloader>,
451        stateroot: Option<&str>,
452        config_stateroot: Option<&str>,
453    ) -> Result<()> {
454        if self.composefs_backend {
455            let default = ostree_container::deploy::STATEROOT_DEFAULT;
456            if let Some(stateroot) = stateroot.filter(|&s| s != default) {
457                anyhow::bail!(
458                    "--stateroot {stateroot} is not supported with the composefs backend, which only uses {default}"
459                );
460            }
461            if let Some(stateroot) = config_stateroot.filter(|&s| s != default) {
462                anyhow::bail!(
463                    "stateroot {stateroot} in the install configuration is not supported with the composefs backend, which only uses {default}"
464                );
465            }
466        } else {
467            anyhow::ensure!(
468                !matches!(bootloader, Some(Bootloader::Ukiboot)),
469                "ukiboot requires the composefs backend"
470            );
471            anyhow::ensure!(
472                !self.allow_missing_verity,
473                "--allow-missing-verity requires the composefs backend"
474            );
475            anyhow::ensure!(
476                self.uki_addon_opts.scoped.is_none(),
477                "--uki-addon requires the composefs backend"
478            );
479            anyhow::ensure!(
480                self.uki_addon_opts.global.is_none(),
481                "--global-uki-addon requires the composefs backend"
482            );
483        }
484        Ok(())
485    }
486
487    /// The composefs backend doesn't install logically bound images yet
488    /// (<https://github.com/bootc-dev/bootc/issues/2540>), so fail rather
489    /// than install a system that is missing them.
490    pub(crate) fn validate_bound_images(
491        &self,
492        bound_images: BoundImagesOpt,
493        root: &Dir,
494    ) -> Result<()> {
495        if !self.composefs_backend || bound_images == BoundImagesOpt::Skip {
496            return Ok(());
497        }
498        let images = crate::boundimage::query_bound_images(root)?;
499        if !images.is_empty() {
500            let images = images
501                .iter()
502                .map(|i| i.image.as_str())
503                .collect::<Vec<_>>()
504                .join(", ");
505            anyhow::bail!(
506                "Logically bound images are not supported with the composefs backend (found {images}); use --bound-images skip to install without them"
507            );
508        }
509        Ok(())
510    }
511}
512
513#[cfg(feature = "install-to-disk")]
514#[derive(Debug, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)]
515pub(crate) struct InstallToDiskOpts {
516    #[clap(flatten)]
517    #[serde(flatten)]
518    pub(crate) block_opts: InstallBlockDeviceOpts,
519
520    #[clap(flatten)]
521    #[serde(flatten)]
522    pub(crate) source_opts: InstallSourceOpts,
523
524    #[clap(flatten)]
525    #[serde(flatten)]
526    pub(crate) target_opts: InstallTargetOpts,
527
528    #[clap(flatten)]
529    #[serde(flatten)]
530    pub(crate) config_opts: InstallConfigOpts,
531
532    /// Instead of targeting a block device, write to a file via loopback.
533    #[clap(long)]
534    #[serde(default)]
535    pub(crate) via_loopback: bool,
536
537    #[clap(flatten)]
538    #[serde(flatten)]
539    pub(crate) composefs_opts: InstallComposefsOpts,
540
541    /// Whether to use systemd-repart for partitioning.
542    /// This requires the image to have repart.d definitions
543    #[clap(long)]
544    #[serde(default)]
545    pub(crate) run_repart: bool,
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub(crate) enum AbootDiskLayout {
550    Android,
551    Ukiboot,
552}
553
554impl From<composefs_ctl::composefs_boot::bootloader::AbootEncoding> for AbootDiskLayout {
555    fn from(encoding: composefs_ctl::composefs_boot::bootloader::AbootEncoding) -> Self {
556        match encoding {
557            composefs_ctl::composefs_boot::bootloader::AbootEncoding::AndroidV2 => Self::Android,
558            composefs_ctl::composefs_boot::bootloader::AbootEncoding::Uki => Self::Ukiboot,
559        }
560    }
561}
562
563#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(rename_all = "kebab-case")]
565pub(crate) enum ReplaceMode {
566    /// Completely wipe the contents of the target filesystem.  This cannot
567    /// be done if the target filesystem is the one the system is booted from.
568    Wipe,
569    /// This is a destructive operation in the sense that the bootloader state
570    /// will have its contents wiped and replaced.  However,
571    /// the running system (and all files) will remain in place until reboot.
572    ///
573    /// As a corollary to this, you will also need to remove all the old operating
574    /// system binaries after the reboot into the target system; this can be done
575    /// with code in the new target system, or manually.
576    Alongside,
577}
578
579impl std::fmt::Display for ReplaceMode {
580    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        self.to_possible_value().unwrap().get_name().fmt(f)
582    }
583}
584
585/// Options for installing to a filesystem
586#[derive(Debug, Clone, clap::Args, PartialEq, Eq)]
587pub(crate) struct InstallTargetFilesystemOpts {
588    /// Path to the mounted root filesystem.
589    ///
590    /// By default, the filesystem UUID will be discovered and used for mounting.
591    /// To override this, use `--root-mount-spec`.
592    pub(crate) root_path: Utf8PathBuf,
593
594    /// Source device specification for the root filesystem.  For example, `UUID=2e9f4241-229b-4202-8429-62d2302382e1`.
595    /// If not provided, the UUID of the target filesystem will be used. This option is provided
596    /// as some use cases might prefer to mount by a label instead via e.g. `LABEL=rootfs`.
597    #[clap(long)]
598    pub(crate) root_mount_spec: Option<String>,
599
600    /// Mount specification for the /boot filesystem.
601    ///
602    /// This is optional. If `/boot` is detected as a mounted partition, then
603    /// its UUID will be used.
604    #[clap(long)]
605    pub(crate) boot_mount_spec: Option<String>,
606
607    /// Initialize the system in-place; at the moment, only one mode for this is implemented.
608    /// In the future, it may also be supported to set up an explicit "dual boot" system.
609    #[clap(long)]
610    pub(crate) replace: Option<ReplaceMode>,
611
612    /// If the target is the running system's root filesystem, this will skip any warnings.
613    #[clap(long)]
614    pub(crate) acknowledge_destructive: bool,
615
616    /// The default mode is to "finalize" the target filesystem by invoking `fstrim` and similar
617    /// operations, and finally mounting it readonly.  This option skips those operations.  It
618    /// is then the responsibility of the invoking code to perform those operations.
619    #[clap(long)]
620    pub(crate) skip_finalize: bool,
621}
622
623#[derive(Debug, Clone, clap::Parser, PartialEq, Eq)]
624pub(crate) struct InstallToFilesystemOpts {
625    #[clap(flatten)]
626    pub(crate) filesystem_opts: InstallTargetFilesystemOpts,
627
628    #[clap(flatten)]
629    pub(crate) source_opts: InstallSourceOpts,
630
631    #[clap(flatten)]
632    pub(crate) target_opts: InstallTargetOpts,
633
634    #[clap(flatten)]
635    pub(crate) config_opts: InstallConfigOpts,
636
637    #[clap(flatten)]
638    pub(crate) composefs_opts: InstallComposefsOpts,
639}
640
641#[derive(Debug, Clone, clap::Parser, PartialEq, Eq)]
642pub(crate) struct InstallToExistingRootOpts {
643    /// Configure how existing data is treated.
644    #[clap(long, default_value = "alongside")]
645    pub(crate) replace: Option<ReplaceMode>,
646
647    #[clap(flatten)]
648    pub(crate) source_opts: InstallSourceOpts,
649
650    #[clap(flatten)]
651    pub(crate) target_opts: InstallTargetOpts,
652
653    #[clap(flatten)]
654    pub(crate) config_opts: InstallConfigOpts,
655
656    /// Accept that this is a destructive action and skip a warning timer.
657    #[clap(long)]
658    pub(crate) acknowledge_destructive: bool,
659
660    /// Add the bootc-destructive-cleanup systemd service to delete files from
661    /// the previous install on first boot
662    #[clap(long)]
663    pub(crate) cleanup: bool,
664
665    /// Path to the mounted root; this is now not necessary to provide.
666    /// Historically it was necessary to ensure the host rootfs was mounted at here
667    /// via e.g. `-v /:/target`.
668    #[clap(default_value = ALONGSIDE_ROOT_MOUNT)]
669    pub(crate) root_path: Utf8PathBuf,
670
671    #[clap(flatten)]
672    pub(crate) composefs_opts: InstallComposefsOpts,
673}
674
675#[derive(Debug, clap::Parser, PartialEq, Eq)]
676pub(crate) struct InstallResetOpts {
677    /// Acknowledge that this command is experimental.
678    #[clap(long)]
679    pub(crate) experimental: bool,
680
681    #[clap(flatten)]
682    pub(crate) source_opts: InstallSourceOpts,
683
684    #[clap(flatten)]
685    pub(crate) target_opts: InstallTargetOpts,
686
687    /// Name of the target stateroot. If not provided, one will be automatically
688    /// generated of the form `s<year>-<serial>` where `<serial>` starts at zero and
689    /// increments automatically.
690    #[clap(long)]
691    pub(crate) stateroot: Option<String>,
692
693    /// Don't display progress
694    #[clap(long)]
695    pub(crate) quiet: bool,
696
697    #[clap(flatten)]
698    pub(crate) progress: crate::cli::ProgressOptions,
699
700    /// Restart or reboot into the new target image.
701    ///
702    /// Currently, this option always reboots.  In the future this command
703    /// will detect the case where no kernel changes are queued, and perform
704    /// a userspace-only restart.
705    #[clap(long)]
706    pub(crate) apply: bool,
707
708    /// Skip inheriting any automatically discovered root file system kernel arguments.
709    #[clap(long)]
710    no_root_kargs: bool,
711
712    /// Add a kernel argument.  This option can be provided multiple times.
713    ///
714    /// Example: --karg=nosmt --karg=console=ttyS0,115200n8
715    #[clap(long)]
716    karg: Option<Vec<CmdlineOwned>>,
717}
718
719#[derive(Debug, clap::Parser, PartialEq, Eq)]
720pub(crate) struct InstallPrintConfigurationOpts {
721    /// Print all configuration.
722    ///
723    /// Print configuration that is usually handled internally, like kargs.
724    #[clap(long)]
725    pub(crate) all: bool,
726}
727
728/// Global state captured from the container.
729#[derive(Debug, Clone)]
730pub(crate) struct SourceInfo {
731    /// Original image reference, retained for user-facing output and origin data.
732    pub(crate) imageref: ostree_container::ImageReference,
733    /// Manifest digest reported by `podman inspect`, if this is a running
734    /// container.  This is not the image's containers-storage config ID.
735    pub(crate) digest: Option<String>,
736    /// Running container's immutable containers-storage config ID, if any.
737    pub(crate) host_image_id: Option<String>,
738    /// Whether or not SELinux appears to be enabled in the source commit
739    pub(crate) selinux: bool,
740    /// Whether the source is available in the host mount namespace
741    pub(crate) in_host_mountns: bool,
742}
743
744// Shared read-only global state
745#[derive(Debug)]
746pub(crate) struct State {
747    pub(crate) source: SourceInfo,
748    /// Force SELinux off in target system
749    pub(crate) selinux_state: SELinuxFinalState,
750    #[allow(dead_code)]
751    pub(crate) config_opts: InstallConfigOpts,
752    pub(crate) target_opts: InstallTargetOpts,
753    pub(crate) target_imgref: ostree_container::OstreeImageReference,
754    #[allow(dead_code)]
755    pub(crate) ostree_prepareroot_config: HashMap<String, String>,
756    pub(crate) install_config: Option<config::InstallConfiguration>,
757    /// The parsed contents of the authorized_keys (not the file path)
758    pub(crate) root_ssh_authorized_keys: Option<String>,
759    #[allow(dead_code)]
760    pub(crate) host_is_container: bool,
761    /// The root filesystem of the running container
762    pub(crate) container_root: Dir,
763    pub(crate) tempdir: TempDir,
764
765    /// Set if we have determined that composefs is required
766    #[allow(dead_code)]
767    pub(crate) composefs_required: bool,
768
769    // If Some, then --composefs_native is passed
770    pub(crate) composefs_options: InstallComposefsOpts,
771    pub(crate) composefs_fsverity_supported: bool,
772    pub(crate) allow_missing_verity_explicit: bool,
773    #[cfg(feature = "install-to-disk")]
774    pub(crate) aboot_disk_layout: Option<AbootDiskLayout>,
775}
776
777// Shared read-only global state
778#[derive(Debug)]
779pub(crate) struct PostFetchState {
780    /// Detected bootloader type for the target system
781    pub(crate) detected_bootloader: crate::spec::Bootloader,
782}
783
784impl InstallTargetOpts {
785    pub(crate) fn imageref(&self) -> Result<Option<ostree_container::OstreeImageReference>> {
786        let Some(target_imgname) = self.target_imgref.as_deref() else {
787            return Ok(None);
788        };
789        let target_transport =
790            ostree_container::Transport::try_from(self.target_transport.as_str())?;
791        let target_imgref = ostree_container::OstreeImageReference {
792            sigverify: ostree_container::SignatureSource::ContainerPolicyAllowInsecure,
793            imgref: ostree_container::ImageReference {
794                transport: target_transport,
795                name: target_imgname.to_string(),
796            },
797        };
798        Ok(Some(target_imgref))
799    }
800}
801
802impl State {
803    #[context("Loading SELinux policy")]
804    pub(crate) fn load_policy(&self) -> Result<Option<ostree::SePolicy>> {
805        if !self.selinux_state.enabled() {
806            return Ok(None);
807        }
808        // We always use the physical container root to bootstrap policy
809        let r = lsm::new_sepolicy_at(&self.container_root)?
810            .ok_or_else(|| anyhow::anyhow!("SELinux enabled, but no policy found in root"))?;
811        // SAFETY: Policy must have a checksum here
812        tracing::debug!("Loaded SELinux policy: {}", r.csum().unwrap());
813        Ok(Some(r))
814    }
815
816    #[context("Finalizing state")]
817    #[allow(dead_code)]
818    pub(crate) fn consume(self) -> Result<()> {
819        self.tempdir.close()?;
820        // If we had invoked `setenforce 0`, then let's re-enable it.
821        if let SELinuxFinalState::Enabled(Some(guard)) = self.selinux_state {
822            guard.consume()?;
823        }
824        Ok(())
825    }
826
827    /// Return an error if kernel arguments are provided, for prebuilt boot artifacts
828    pub(crate) fn require_no_kargs_for_immutable_boot(&self) -> Result<()> {
829        if self
830            .config_opts
831            .karg
832            .as_ref()
833            .map(|v| !v.is_empty())
834            .unwrap_or_default()
835        {
836            anyhow::bail!(
837                "Cannot use externally specified kernel arguments with a prebuilt boot image"
838            );
839        }
840        Ok(())
841    }
842
843    fn stateroot(&self) -> &str {
844        // CLI takes precedence over config file
845        self.config_opts
846            .stateroot
847            .as_deref()
848            .or_else(|| {
849                self.install_config
850                    .as_ref()
851                    .and_then(|c| c.stateroot.as_deref())
852            })
853            .unwrap_or(ostree_ext::container::deploy::STATEROOT_DEFAULT)
854    }
855}
856
857/// A mount specification is a subset of a line in `/etc/fstab`.
858///
859/// There are 3 (ASCII) whitespace separated values:
860///
861/// `SOURCE TARGET [OPTIONS]`
862///
863/// Examples:
864///   - /dev/vda3 /boot ext4 ro
865///   - /dev/nvme0n1p4 /
866///   - /dev/sda2 /var/mnt xfs
867#[derive(Debug, Clone)]
868pub(crate) struct MountSpec {
869    pub(crate) source: String,
870    pub(crate) target: String,
871    pub(crate) fstype: String,
872    pub(crate) options: Option<String>,
873}
874
875impl MountSpec {
876    const AUTO: &'static str = "auto";
877
878    pub(crate) fn new(src: &str, target: &str) -> Self {
879        MountSpec {
880            source: src.to_string(),
881            target: target.to_string(),
882            fstype: Self::AUTO.to_string(),
883            options: None,
884        }
885    }
886
887    /// Construct a new mount that uses the provided uuid as a source.
888    pub(crate) fn new_uuid_src(uuid: &str, target: &str) -> Self {
889        Self::new(&format!("UUID={uuid}"), target)
890    }
891
892    pub(crate) fn get_source_uuid(&self) -> Option<&str> {
893        if let Some((t, rest)) = self.source.split_once('=') {
894            if t.eq_ignore_ascii_case("uuid") {
895                return Some(rest);
896            }
897        }
898        None
899    }
900
901    pub(crate) fn to_fstab(&self) -> String {
902        let options = self.options.as_deref().unwrap_or("defaults");
903        format!(
904            "{} {} {} {} 0 0",
905            self.source, self.target, self.fstype, options
906        )
907    }
908
909    /// Append a mount option
910    pub(crate) fn push_option(&mut self, opt: &str) {
911        let options = self.options.get_or_insert_with(Default::default);
912        if !options.is_empty() {
913            options.push(',');
914        }
915        options.push_str(opt);
916    }
917}
918
919impl FromStr for MountSpec {
920    type Err = anyhow::Error;
921
922    fn from_str(s: &str) -> Result<Self> {
923        let mut parts = s.split_ascii_whitespace().fuse();
924        let source = parts.next().unwrap_or_default();
925        if source.is_empty() {
926            tracing::debug!("Empty mount specification");
927            return Ok(Self {
928                source: String::new(),
929                target: String::new(),
930                fstype: Self::AUTO.into(),
931                options: None,
932            });
933        }
934        let target = parts
935            .next()
936            .ok_or_else(|| anyhow!("Missing target in mount specification {s}"))?;
937        let fstype = parts.next().unwrap_or(Self::AUTO);
938        let options = parts.next().map(ToOwned::to_owned);
939        Ok(Self {
940            source: source.to_string(),
941            fstype: fstype.to_string(),
942            target: target.to_string(),
943            options,
944        })
945    }
946}
947
948impl SourceInfo {
949    /// Return the source reference used by composefs.  Host-container imports
950    /// must use the config ID rather than the mutable human-facing tag.
951    pub(crate) fn composefs_fetch_reference(&self) -> ostree_container::ImageReference {
952        composefs_fetch_reference(&self.imageref, self.host_image_id.as_deref())
953    }
954
955    // Inspect container information and convert it to an ostree image reference
956    // that pulls from containers-storage.
957    #[context("Gathering source info from container env")]
958    pub(crate) fn from_container(
959        root: &Dir,
960        container_info: &ContainerExecutionInfo,
961    ) -> Result<Self> {
962        if !container_info.engine.starts_with("podman") {
963            anyhow::bail!("Currently this command only supports being executed via podman");
964        }
965        if container_info.imageid.is_empty() {
966            anyhow::bail!("Invalid empty imageid");
967        }
968        let imageref = ostree_container::ImageReference {
969            transport: ostree_container::Transport::ContainerStorage,
970            name: container_info.image.clone(),
971        };
972        tracing::debug!("Finding digest for image ID {}", container_info.imageid);
973        let digest = crate::podman::imageid_to_digest(&container_info.imageid)?;
974        Self::new(
975            imageref,
976            Some(digest),
977            Some(container_info.imageid.clone()),
978            root,
979            true,
980        )
981    }
982
983    #[context("Creating source info from a given imageref")]
984    pub(crate) fn from_imageref(imageref: &str, root: &Dir) -> Result<Self> {
985        let imageref = ostree_container::ImageReference::try_from(imageref)?;
986        Self::new(imageref, None, None, root, false)
987    }
988
989    fn have_selinux_from_repo(root: &Dir) -> Result<bool> {
990        let cancellable = ostree::gio::Cancellable::NONE;
991
992        let commit = Command::new("ostree")
993            .args(["--repo=/ostree/repo", "rev-parse", "--single"])
994            .run_get_string()?;
995        let repo = ostree::Repo::open_at_dir(root.as_fd(), "ostree/repo")?;
996        let root = repo
997            .read_commit(commit.trim(), cancellable)
998            .context("Reading commit")?
999            .0;
1000        let root = root.downcast_ref::<ostree::RepoFile>().unwrap();
1001        let xattrs = root.xattrs(cancellable)?;
1002        Ok(crate::lsm::xattrs_have_selinux(&xattrs))
1003    }
1004
1005    /// Construct a new source information structure
1006    fn new(
1007        imageref: ostree_container::ImageReference,
1008        digest: Option<String>,
1009        host_image_id: Option<String>,
1010        root: &Dir,
1011        in_host_mountns: bool,
1012    ) -> Result<Self> {
1013        let selinux = if Path::new("/ostree/repo").try_exists()? {
1014            Self::have_selinux_from_repo(root)?
1015        } else {
1016            lsm::have_selinux_policy(root)?
1017        };
1018        Ok(Self {
1019            imageref,
1020            digest,
1021            host_image_id,
1022            selinux,
1023            in_host_mountns,
1024        })
1025    }
1026}
1027
1028fn composefs_fetch_reference(
1029    imageref: &ostree_container::ImageReference,
1030    host_image_id: Option<&str>,
1031) -> ostree_container::ImageReference {
1032    host_image_id
1033        .map(|config_id| ostree_container::ImageReference {
1034            transport: ostree_container::Transport::ContainerStorage,
1035            name: config_id.to_owned(),
1036        })
1037        .unwrap_or_else(|| imageref.clone())
1038}
1039
1040pub(crate) fn print_configuration(opts: InstallPrintConfigurationOpts) -> Result<()> {
1041    let mut install_config = config::load_config()?.unwrap_or_default();
1042    if !opts.all {
1043        install_config.filter_to_external();
1044    }
1045    let stdout = std::io::stdout().lock();
1046    anyhow::Ok(install_config.to_canon_json_writer(stdout)?)
1047}
1048
1049#[context("Creating ostree deployment")]
1050async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result<(Storage, bool)> {
1051    let sepolicy = state.load_policy()?;
1052    let sepolicy = sepolicy.as_ref();
1053    // Load a fd for the mounted target physical root
1054    let rootfs_dir = &root_setup.physical_root;
1055    let cancellable = gio::Cancellable::NONE;
1056
1057    let stateroot = state.stateroot();
1058
1059    let has_ostree = rootfs_dir.try_exists("ostree/repo")?;
1060    if !has_ostree {
1061        Task::new("Initializing ostree layout", "ostree")
1062            .args(["admin", "init-fs", "--modern", "."])
1063            .cwd(rootfs_dir)?
1064            .run()?;
1065    } else {
1066        println!("Reusing extant ostree layout");
1067
1068        let path = ".".into();
1069        let _ = crate::utils::open_dir_remount_rw(rootfs_dir, path)
1070            .context("remounting target as read-write")?;
1071        crate::utils::remove_immutability(rootfs_dir, path)?;
1072    }
1073
1074    // Ensure that the physical root is labeled.
1075    // Another implementation: https://github.com/coreos/coreos-assembler/blob/3cd3307904593b3a131b81567b13a4d0b6fe7c90/src/create_disk.sh#L295
1076    crate::lsm::ensure_dir_labeled(rootfs_dir, "", Some("/".into()), 0o755.into(), sepolicy)?;
1077
1078    // If we're installing alongside existing ostree and there's a separate boot partition,
1079    // we need to mount it to the sysroot's /boot so ostree can write bootloader entries there
1080    if has_ostree && root_setup.boot.is_some() {
1081        if let Some(boot) = &root_setup.boot {
1082            let source_boot = &boot.source;
1083            let target_boot = root_setup.physical_root_path.join(BOOT);
1084            tracing::debug!("Mount {source_boot} to {target_boot} on ostree");
1085            bootc_mount::mount(source_boot, &target_boot)?;
1086        }
1087    }
1088
1089    // And also label /boot AKA xbootldr, if it exists
1090    if rootfs_dir.try_exists(BOOT)? {
1091        crate::lsm::ensure_dir_labeled(rootfs_dir, BOOT, None, 0o755.into(), sepolicy)?;
1092    }
1093
1094    // Build the list of ostree repo config options: defaults + install config
1095    let ostree_opts = state
1096        .install_config
1097        .as_ref()
1098        .and_then(|c| c.ostree.as_ref())
1099        .into_iter()
1100        .flat_map(|o| o.to_config_tuples());
1101
1102    let repo_config: Vec<_> = DEFAULT_REPO_CONFIG
1103        .iter()
1104        .copied()
1105        .chain(ostree_opts)
1106        .collect();
1107
1108    for (k, v) in repo_config.iter() {
1109        Command::new("ostree")
1110            .args(["config", "--repo", "ostree/repo", "set", k, v])
1111            .cwd_dir(rootfs_dir.try_clone()?)
1112            .run_capture_stderr()?;
1113    }
1114
1115    let sysroot = {
1116        let path = format!(
1117            "/proc/{}/fd/{}",
1118            process::id(),
1119            rootfs_dir.as_fd().as_raw_fd()
1120        );
1121        ostree::Sysroot::new(Some(&gio::File::for_path(path)))
1122    };
1123    sysroot.load(cancellable)?;
1124    let repo = &sysroot.repo();
1125
1126    let repo_verity_state = ostree_ext::fsverity::is_verity_enabled(&repo)?;
1127    let prepare_root_composefs = state
1128        .ostree_prepareroot_config
1129        .get("composefs.enabled")
1130        .map(|v| ComposefsState::from_str(&v))
1131        .transpose()?
1132        .unwrap_or(ComposefsState::default());
1133    if prepare_root_composefs.requires_fsverity() || repo_verity_state.desired == Tristate::Enabled
1134    {
1135        ostree_ext::fsverity::ensure_verity(repo).await?;
1136    }
1137
1138    if let Some(booted) = sysroot.booted_deployment() {
1139        if stateroot == booted.stateroot() {
1140            anyhow::bail!("Cannot redeploy over booted stateroot {stateroot}");
1141        }
1142    }
1143
1144    let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?;
1145
1146    // init_osname fails when ostree/deploy/{stateroot} already exists
1147    // the stateroot directory can be left over after a failed install attempt,
1148    // so only create it via init_osname if it doesn't exist
1149    // (ideally this would be handled by init_osname)
1150    let stateroot_path = format!("ostree/deploy/{stateroot}");
1151    if !sysroot_dir.try_exists(stateroot_path)? {
1152        sysroot
1153            .init_osname(stateroot, cancellable)
1154            .context("initializing stateroot")?;
1155    }
1156
1157    state.tempdir.create_dir("temp-run")?;
1158    let temp_run = state.tempdir.open_dir("temp-run")?;
1159
1160    // Bootstrap the initial labeling of the /ostree directory as usr_t
1161    // and create the imgstorage with the same labels as /var/lib/containers
1162    if let Some(policy) = sepolicy {
1163        let ostree_dir = rootfs_dir.open_dir("ostree")?;
1164        crate::lsm::ensure_dir_labeled(
1165            &ostree_dir,
1166            ".",
1167            Some("/usr".into()),
1168            0o755.into(),
1169            Some(policy),
1170        )?;
1171    }
1172
1173    sysroot.load(cancellable)?;
1174    let sysroot = SysrootLock::new_from_sysroot(&sysroot).await?;
1175    let storage = Storage::new_ostree(sysroot, &temp_run)?;
1176
1177    Ok((storage, has_ostree))
1178}
1179
1180async fn pull_ostree_install_once(
1181    repo: &ostree::Repo,
1182    imgref: &ImageReference,
1183    target_imgref: &ostree_container::OstreeImageReference,
1184    progress: PullProgress,
1185) -> Result<Box<crate::deploy::ImageState>> {
1186    let prepared = prepare_for_pull(repo, imgref, Some(target_imgref), None).await?;
1187    pull_ostree_install_from_prepared(repo, imgref, prepared, progress).await
1188}
1189
1190async fn pull_ostree_install_from_prepared(
1191    repo: &ostree::Repo,
1192    imgref: &ImageReference,
1193    prepared: PreparedPullResult,
1194    progress: PullProgress,
1195) -> Result<Box<crate::deploy::ImageState>> {
1196    match prepared {
1197        PreparedPullResult::AlreadyPresent(existing) => Ok(existing),
1198        PreparedPullResult::Ready(image_meta) => {
1199            crate::deploy::check_disk_space_ostree(repo, &image_meta, imgref)?;
1200            pull_from_prepared(imgref, progress, *image_meta).await
1201        }
1202    }
1203}
1204
1205#[context("Creating ostree deployment")]
1206async fn install_container(
1207    state: &State,
1208    root_setup: &RootSetup,
1209    sysroot: &ostree::Sysroot,
1210    storage: &Storage,
1211    has_ostree: bool,
1212) -> Result<(ostree::Deployment, InstallAleph)> {
1213    let sepolicy = state.load_policy()?;
1214    let sepolicy = sepolicy.as_ref();
1215    let stateroot = state.stateroot();
1216
1217    // TODO factor out this
1218    let (src_imageref, proxy_cfg) = if !state.source.in_host_mountns {
1219        (state.source.imageref.clone(), None)
1220    } else {
1221        let src_imageref = {
1222            // We always use exactly the digest of the running image to ensure predictability.
1223            let digest = state
1224                .source
1225                .digest
1226                .as_ref()
1227                .ok_or_else(|| anyhow::anyhow!("Missing container image digest"))?;
1228            let spec = crate::utils::digested_pullspec(&state.source.imageref.name, digest);
1229            ostree_container::ImageReference {
1230                transport: ostree_container::Transport::ContainerStorage,
1231                name: spec,
1232            }
1233        };
1234
1235        let proxy_cfg = crate::deploy::new_proxy_config();
1236        (src_imageref, Some(proxy_cfg))
1237    };
1238    let src_imageref = ostree_container::OstreeImageReference {
1239        // There are no signatures to verify since we're fetching the already
1240        // pulled container.
1241        sigverify: ostree_container::SignatureSource::ContainerPolicyAllowInsecure,
1242        imgref: src_imageref,
1243    };
1244
1245    // Pull the container image into the target root filesystem. Since this is
1246    // an install path, we don't need to fsync() individual layers.
1247    let spec_imgref = ImageReference::from(src_imageref.clone());
1248    let repo = &sysroot.repo();
1249    repo.set_disable_fsync(true);
1250
1251    // Determine whether to use unified storage path.
1252    // During install, we only use unified storage if explicitly requested.
1253    // Auto-detection (None) is only appropriate for upgrade/switch on a running system.
1254    let use_unified = state.target_opts.unified_storage_exp;
1255    let progress = PullProgress::new(false, ProgressWriter::default());
1256
1257    let pulled_image = if use_unified {
1258        tracing::info!("Using unified storage path for installation");
1259        let prepared = crate::deploy::prepare_for_pull_unified(
1260            repo,
1261            &spec_imgref,
1262            Some(&state.target_imgref),
1263            storage,
1264            None,
1265        )
1266        .await?;
1267        pull_ostree_install_from_prepared(repo, &spec_imgref, prepared, progress.clone()).await?
1268    } else {
1269        let operation =
1270            || pull_ostree_install_once(repo, &spec_imgref, &state.target_imgref, progress.clone());
1271        if spec_imgref.transport == "registry" {
1272            retry_pull_operation(&progress, operation).await?
1273        } else {
1274            operation().await?
1275        }
1276    };
1277
1278    repo.set_disable_fsync(false);
1279
1280    // We need to read the kargs from the target merged ostree commit before
1281    // we do the deployment.
1282    let merged_ostree_root = sysroot
1283        .repo()
1284        .read_commit(pulled_image.ostree_commit.as_str(), gio::Cancellable::NONE)?
1285        .0;
1286    let kargsd = crate::bootc_kargs::get_kargs_from_ostree_root(
1287        &sysroot.repo(),
1288        merged_ostree_root.downcast_ref().unwrap(),
1289        std::env::consts::ARCH,
1290    )?;
1291
1292    // If the target uses aboot, then we need to set that bootloader in the ostree
1293    // config before deploying the commit
1294    if ostree_ext::bootabletree::commit_has_aboot_img(&merged_ostree_root, None)? {
1295        tracing::debug!("Setting bootloader to aboot");
1296        Command::new("ostree")
1297            .args([
1298                "config",
1299                "--repo",
1300                "ostree/repo",
1301                "set",
1302                "sysroot.bootloader",
1303                "aboot",
1304            ])
1305            .cwd_dir(root_setup.physical_root.try_clone()?)
1306            .run_capture_stderr()
1307            .context("Setting bootloader config to aboot")?;
1308        sysroot.repo().reload_config(None::<&gio::Cancellable>)?;
1309    }
1310
1311    // Keep this in sync with install/completion.rs for the Anaconda fixups
1312    let install_config_kargs = state.install_config.as_ref().and_then(|c| c.kargs.as_ref());
1313    let install_config_karg_deletes = state
1314        .install_config
1315        .as_ref()
1316        .and_then(|c| c.karg_deletes.as_ref());
1317
1318    // Final kargs, in order:
1319    // - root filesystem kargs
1320    // - install config kargs
1321    // - kargs.d from container image
1322    // - args specified on the CLI
1323    let mut kargs = Cmdline::new();
1324    let mut karg_deletes = Vec::<&str>::new();
1325
1326    kargs.extend(&root_setup.kargs);
1327
1328    if let Some(install_config_kargs) = install_config_kargs {
1329        for karg in install_config_kargs {
1330            kargs.extend(&Cmdline::from(karg.as_str()));
1331        }
1332    }
1333
1334    kargs.extend(&kargsd);
1335
1336    // delete kargs before processing cli kargs, so cli kargs can override all other configs
1337    if let Some(install_config_karg_deletes) = install_config_karg_deletes {
1338        for karg_delete in install_config_karg_deletes {
1339            karg_deletes.push(karg_delete);
1340        }
1341    }
1342    if let Some(deletes) = state.config_opts.karg_delete.as_ref() {
1343        for karg_delete in deletes {
1344            karg_deletes.push(karg_delete);
1345        }
1346    }
1347    delete_kargs(&mut kargs, &karg_deletes);
1348
1349    if let Some(cli_kargs) = state.config_opts.karg.as_ref() {
1350        for karg in cli_kargs {
1351            kargs.extend(karg);
1352        }
1353    }
1354
1355    // Finally map into &[&str] for ostree_container
1356    let kargs_strs: Vec<&str> = kargs.iter_str().collect();
1357
1358    let mut options = ostree_container::deploy::DeployOpts::default();
1359    options.kargs = Some(kargs_strs.as_slice());
1360    options.target_imgref = Some(&state.target_imgref);
1361    options.proxy_cfg = proxy_cfg;
1362    options.skip_completion = true; // Must be set to avoid recursion!
1363    options.no_clean = has_ostree;
1364    let imgstate = crate::utils::async_task_with_spinner(
1365        "Deploying container image",
1366        ostree_container::deploy::deploy(&sysroot, stateroot, &src_imageref, Some(options)),
1367    )
1368    .await?;
1369
1370    let deployment = sysroot
1371        .deployments()
1372        .into_iter()
1373        .next()
1374        .ok_or_else(|| anyhow::anyhow!("Failed to find deployment"))?;
1375    // SAFETY: There must be a path
1376    let path = sysroot.deployment_dirpath(&deployment);
1377    let root = root_setup
1378        .physical_root
1379        .open_dir(path.as_str())
1380        .context("Opening deployment dir")?;
1381
1382    // And do another recursive relabeling pass over the ostree-owned directories
1383    // but avoid recursing into the deployment root (because that's a *distinct*
1384    // logical root).
1385    if let Some(policy) = sepolicy {
1386        let deployment_root_meta = root.dir_metadata()?;
1387        let deployment_root_devino = (deployment_root_meta.dev(), deployment_root_meta.ino());
1388        for d in ["ostree", BOOT] {
1389            let mut pathbuf = Utf8PathBuf::from(d);
1390            crate::lsm::ensure_dir_labeled_recurse(
1391                &root_setup.physical_root,
1392                &mut pathbuf,
1393                policy,
1394                Some(deployment_root_devino),
1395            )
1396            .with_context(|| format!("Recursive SELinux relabeling of {d}"))?;
1397        }
1398
1399        if let Some(cfs_super) = root.open_optional(ostree_prepareroot::COMPOSEFS_IMAGE)? {
1400            let label = crate::lsm::require_label(policy, "/usr".into(), 0o644)?;
1401            crate::lsm::set_security_selinux(cfs_super.as_fd(), label.as_bytes())?;
1402        } else {
1403            tracing::warn!(
1404                "Missing {}; composefs is not enabled?",
1405                ostree_prepareroot::COMPOSEFS_IMAGE
1406            );
1407        }
1408    }
1409
1410    // Write the entry for /boot to /etc/fstab.  TODO: Encourage OSes to use the karg?
1411    // Or better bind this with the grub data.
1412    // We omit it if the boot mountspec argument was empty
1413    if let Some(boot) = root_setup.boot.as_ref() {
1414        if !boot.source.is_empty() {
1415            crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| {
1416                writeln!(w, "{}", boot.to_fstab()).map_err(Into::into)
1417            })?;
1418        }
1419    }
1420
1421    if let Some(contents) = state.root_ssh_authorized_keys.as_deref() {
1422        osconfig::inject_root_ssh_authorized_keys(&root, &root, sepolicy, contents)?;
1423    }
1424
1425    let aleph = InstallAleph::new(
1426        &src_imageref,
1427        &state.target_imgref,
1428        &imgstate,
1429        &state.selinux_state,
1430    )?;
1431    Ok((deployment, aleph))
1432}
1433
1434pub(crate) fn delete_kargs(existing: &mut Cmdline, deletes: &Vec<&str>) {
1435    for delete in deletes {
1436        if let Some(param) = utf8::Parameter::parse(&delete) {
1437            if param.value().is_some() {
1438                existing.remove_exact(&param);
1439            } else {
1440                existing.remove(&param.key());
1441            }
1442        }
1443    }
1444}
1445
1446/// Run a command in the host mount namespace
1447pub(crate) fn run_in_host_mountns(cmd: &str) -> Result<Command> {
1448    let mut c = Command::new(bootc_utils::reexec::executable_path()?);
1449    c.lifecycle_bind()
1450        .args(["exec-in-host-mount-namespace", cmd]);
1451    Ok(c)
1452}
1453
1454#[context("Re-exec in host mountns")]
1455pub(crate) fn exec_in_host_mountns(args: &[std::ffi::OsString]) -> Result<()> {
1456    let (cmd, args) = args
1457        .split_first()
1458        .ok_or_else(|| anyhow::anyhow!("Missing command"))?;
1459    tracing::trace!("{cmd:?} {args:?}");
1460    let pid1mountns = std::fs::File::open("/proc/1/ns/mnt").context("open pid1 mountns")?;
1461    rustix::thread::move_into_link_name_space(
1462        pid1mountns.as_fd(),
1463        Some(rustix::thread::LinkNameSpaceType::Mount),
1464    )
1465    .context("setns")?;
1466    rustix::process::chdir("/").context("chdir")?;
1467    // Work around supermin doing chroot() and not pivot_root
1468    // https://github.com/libguestfs/supermin/blob/5230e2c3cd07e82bd6431e871e239f7056bf25ad/init/init.c#L288
1469    if !Utf8Path::new("/usr").try_exists().context("/usr")?
1470        && Utf8Path::new("/root/usr")
1471            .try_exists()
1472            .context("/root/usr")?
1473    {
1474        tracing::debug!("Using supermin workaround");
1475        rustix::process::chroot("/root").context("chroot")?;
1476    }
1477    Err(Command::new(cmd).args(args).arg0(bootc_utils::NAME).exec()).context("exec")?
1478}
1479
1480#[derive(Debug)]
1481pub(crate) struct RootSetup {
1482    #[cfg(feature = "install-to-disk")]
1483    luks_device: Option<String>,
1484    pub(crate) device_info: bootc_blockdev::Device,
1485    /// Absolute path to the location where we've mounted the physical
1486    /// root filesystem for the system we're installing.
1487    pub(crate) physical_root_path: Utf8PathBuf,
1488    /// Directory file descriptor for the above physical root.
1489    pub(crate) physical_root: Dir,
1490    /// Target root path /target.
1491    pub(crate) target_root_path: Option<Utf8PathBuf>,
1492    pub(crate) rootfs_uuid: Option<String>,
1493    pub(crate) aboot_disk_layout: Option<AbootDiskLayout>,
1494    pub(crate) disk_install: bool,
1495    /// True if we should skip finalizing
1496    skip_finalize: bool,
1497    boot: Option<MountSpec>,
1498    pub(crate) kargs: CmdlineOwned,
1499}
1500
1501fn require_boot_uuid(spec: &MountSpec) -> Result<&str> {
1502    spec.get_source_uuid()
1503        .ok_or_else(|| anyhow!("/boot is not specified via UUID= (this is currently required)"))
1504}
1505
1506impl RootSetup {
1507    /// Get the UUID= mount specifier for the /boot filesystem; if there isn't one, the root UUID will
1508    /// be returned.
1509    pub(crate) fn get_boot_uuid(&self) -> Result<Option<&str>> {
1510        self.boot.as_ref().map(require_boot_uuid).transpose()
1511    }
1512
1513    /// Get the boot mount spec, if a separate /boot partition exists.
1514    pub(crate) fn boot_mount_spec(&self) -> Option<&MountSpec> {
1515        self.boot.as_ref()
1516    }
1517
1518    // Drop any open file descriptors and return just the mount path and backing luks device, if any
1519    #[cfg(feature = "install-to-disk")]
1520    fn into_storage(self) -> (Utf8PathBuf, Option<String>) {
1521        (self.physical_root_path, self.luks_device)
1522    }
1523}
1524
1525#[derive(Debug)]
1526#[allow(dead_code)]
1527pub(crate) enum SELinuxFinalState {
1528    /// Host and target both have SELinux, but user forced it off for target
1529    ForceTargetDisabled,
1530    /// Host and target both have SELinux
1531    Enabled(Option<crate::lsm::SetEnforceGuard>),
1532    /// Host has SELinux disabled, target is enabled.
1533    HostDisabled,
1534    /// Neither host or target have SELinux
1535    Disabled,
1536}
1537
1538impl SELinuxFinalState {
1539    /// Returns true if the target system will have SELinux enabled.
1540    pub(crate) fn enabled(&self) -> bool {
1541        match self {
1542            SELinuxFinalState::ForceTargetDisabled | SELinuxFinalState::Disabled => false,
1543            SELinuxFinalState::Enabled(_) | SELinuxFinalState::HostDisabled => true,
1544        }
1545    }
1546
1547    /// Returns the canonical stringified version of self.  This is only used
1548    /// for debugging purposes.
1549    pub(crate) fn to_aleph(&self) -> &'static str {
1550        match self {
1551            SELinuxFinalState::ForceTargetDisabled => "force-target-disabled",
1552            SELinuxFinalState::Enabled(_) => "enabled",
1553            SELinuxFinalState::HostDisabled => "host-disabled",
1554            SELinuxFinalState::Disabled => "disabled",
1555        }
1556    }
1557}
1558
1559/// If we detect that the target ostree commit has SELinux labels,
1560/// and we aren't passed an override to disable it, then ensure
1561/// the running process is labeled with install_t so it can
1562/// write arbitrary labels.
1563pub(crate) fn reexecute_self_for_selinux_if_needed(
1564    srcdata: &SourceInfo,
1565    override_disable_selinux: bool,
1566    reexec_env: &[(&str, &str)],
1567) -> Result<SELinuxFinalState> {
1568    // If the target state has SELinux enabled, we need to check the host state.
1569    if srcdata.selinux {
1570        let host_selinux = crate::lsm::host_selinux_enabled()?;
1571        tracing::debug!("Target has SELinux, host={host_selinux}");
1572        let r = if override_disable_selinux {
1573            println!("notice: Target has SELinux enabled, overriding to disable");
1574            SELinuxFinalState::ForceTargetDisabled
1575        } else if host_selinux {
1576            // /sys/fs/selinuxfs is not normally mounted, so we do that now.
1577            // Because SELinux enablement status is cached process-wide and was very likely
1578            // already queried by something else (e.g. glib's constructor), we would also need
1579            // to re-exec.  But, selinux_ensure_install does that unconditionally right now too,
1580            // so let's just fall through to that.
1581            setup_sys_mount("selinuxfs", SELINUXFS)?;
1582            // This will re-execute the current process (once).
1583            let g = crate::lsm::selinux_ensure_install_or_setenforce(reexec_env)?;
1584            SELinuxFinalState::Enabled(g)
1585        } else {
1586            SELinuxFinalState::HostDisabled
1587        };
1588        Ok(r)
1589    } else {
1590        Ok(SELinuxFinalState::Disabled)
1591    }
1592}
1593
1594/// Trim, flush outstanding writes, and freeze/thaw the target mounted filesystem;
1595/// these steps prepare the filesystem for its first booted use.
1596pub(crate) fn finalize_filesystem(
1597    fsname: &str,
1598    root: &Dir,
1599    path: impl AsRef<Utf8Path>,
1600) -> Result<()> {
1601    let path = path.as_ref();
1602    // fstrim ensures the underlying block device knows about unused space
1603    Task::new(format!("Trimming {fsname}"), "fstrim")
1604        .args(["--quiet-unsupported", "-v", path.as_str()])
1605        .cwd(root)?
1606        .run()?;
1607    // Remounting readonly will flush outstanding writes and ensure we error out if there were background
1608    // writeback problems.
1609    Task::new(format!("Finalizing filesystem {fsname}"), "mount")
1610        .cwd(root)?
1611        .args(["-o", "remount,ro", path.as_str()])
1612        .run()?;
1613    // Finally, freezing (and thawing) the filesystem will flush the journal, which means the next boot is clean.
1614    // VFAT has no journal and does not support fsfreeze. Might need to be expanded in the future
1615    // to also *not* fsfreeze other filesystems.
1616    let fsdir = root.open_dir(path.as_str())?;
1617    let st = rustix::fs::fstatfs(fsdir.as_fd())?;
1618    if st.f_type == libc::MSDOS_SUPER_MAGIC {
1619        tracing::debug!("Filesystem {fsname} is VFAT, skipping fsfreeze");
1620    } else {
1621        for a in ["-f", "-u"] {
1622            Command::new("fsfreeze")
1623                .cwd_dir(root.try_clone()?)
1624                .args([a, path.as_str()])
1625                .run_capture_stderr()?;
1626        }
1627    }
1628    Ok(())
1629}
1630
1631/// A heuristic check that we were invoked with --pid=host
1632fn require_host_pidns() -> Result<()> {
1633    if rustix::process::getpid().is_init() {
1634        anyhow::bail!("This command must be run with the podman --pid=host flag")
1635    }
1636    tracing::trace!("OK: we're not pid 1");
1637    Ok(())
1638}
1639
1640/// Verify that we can access /proc/1, which will catch rootless podman (with --pid=host)
1641/// for example.
1642fn require_host_userns() -> Result<()> {
1643    let proc1 = "/proc/1";
1644    let pid1_uid = Path::new(proc1)
1645        .metadata()
1646        .with_context(|| format!("Querying {proc1}"))?
1647        .uid();
1648    // We must really be in a rootless container, or in some way
1649    // we're not part of the host user namespace.
1650    ensure!(
1651        pid1_uid == 0,
1652        "{proc1} is owned by {pid1_uid}, not zero; this command must be run in the root user namespace (e.g. not rootless podman)"
1653    );
1654    tracing::trace!("OK: we're in a matching user namespace with pid1");
1655    Ok(())
1656}
1657
1658/// Ensure that /tmp is a tmpfs because in some cases we might perform
1659/// operations which expect it (as it is on a proper host system).
1660/// Ideally we have people run this container via podman run --read-only-tmpfs
1661/// actually.
1662pub(crate) fn setup_tmp_mount() -> Result<()> {
1663    let st = rustix::fs::statfs("/tmp")?;
1664    if st.f_type == libc::TMPFS_MAGIC {
1665        tracing::trace!("Already have tmpfs /tmp")
1666    } else {
1667        // Note we explicitly also don't want a "nosuid" tmp, because that
1668        // suppresses our install_t transition
1669        Command::new("mount")
1670            .args(["tmpfs", "-t", "tmpfs", "/tmp"])
1671            .run_capture_stderr()?;
1672    }
1673    Ok(())
1674}
1675
1676/// By default, podman/docker etc. when passed `--privileged` mount `/sys` as read-only,
1677/// but non-recursively.  We selectively grab sub-filesystems that we need.
1678#[context("Ensuring sys mount {fspath} {fstype}")]
1679pub(crate) fn setup_sys_mount(fstype: &str, fspath: &str) -> Result<()> {
1680    tracing::debug!("Setting up sys mounts");
1681    let rootfs = format!("/proc/1/root/{fspath}");
1682    // Does mount point even exist in the host?
1683    if !Path::new(rootfs.as_str()).try_exists()? {
1684        return Ok(());
1685    }
1686
1687    // Now, let's find out if it's populated
1688    if std::fs::read_dir(rootfs)?.next().is_none() {
1689        return Ok(());
1690    }
1691
1692    // Check that the path that should be mounted is even populated.
1693    // Since we are dealing with /sys mounts here, if it's populated,
1694    // we can be at least a little certain that it's mounted.
1695    if Path::new(fspath).try_exists()? && std::fs::read_dir(fspath)?.next().is_some() {
1696        return Ok(());
1697    }
1698
1699    // This means the host has this mounted, so we should mount it too
1700    Command::new("mount")
1701        .args(["-t", fstype, fstype, fspath])
1702        .run_capture_stderr()?;
1703
1704    Ok(())
1705}
1706
1707/// Verify that we can load the manifest of the target image
1708#[context("Verifying fetch")]
1709async fn verify_target_fetch(
1710    tmpdir: &Dir,
1711    imgref: &ostree_container::OstreeImageReference,
1712) -> Result<()> {
1713    let tmpdir = &TempDir::new_in(&tmpdir)?;
1714    let tmprepo = &ostree::Repo::create_at_dir(tmpdir.as_fd(), ".", ostree::RepoMode::Bare, None)
1715        .context("Init tmp repo")?;
1716
1717    tracing::trace!("Verifying fetch for {imgref}");
1718    let mut imp =
1719        ostree_container::store::ImageImporter::new(tmprepo, imgref, Default::default()).await?;
1720    use ostree_container::store::PrepareResult;
1721    let prep = match imp.prepare().await? {
1722        // SAFETY: It's impossible that the image was already fetched into this newly created temporary repository
1723        PrepareResult::AlreadyPresent(_) => unreachable!(),
1724        PrepareResult::Ready(r) => r,
1725    };
1726    tracing::debug!("Fetched manifest with digest {}", prep.manifest_digest);
1727    Ok(())
1728}
1729
1730/// Carries the content of `--root-ssh-authorized-keys` across re-execs; see `prepare_install`.
1731const ROOT_SSH_AUTHORIZED_KEYS_ENV: &str = "_BOOTC_ROOT_SSH_AUTHORIZED_KEYS";
1732
1733/// Preparation for an install; validates and prepares some (thereafter immutable) global state.
1734async fn prepare_install(
1735    mut config_opts: InstallConfigOpts,
1736    source_opts: InstallSourceOpts,
1737    mut target_opts: InstallTargetOpts,
1738    mut composefs_options: InstallComposefsOpts,
1739    target_fs: Option<FilesystemEnum>,
1740    disk_install: bool,
1741) -> Result<Arc<State>> {
1742    tracing::trace!("Preparing install");
1743
1744    let allow_missing_verity_explicit = composefs_options.allow_missing_verity;
1745    let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())
1746        .context("Opening /")?;
1747
1748    let host_is_container = crate::containerenv::is_container(&rootfs);
1749    let external_source = source_opts.source_imgref.is_some();
1750    let (source, target_rootfs) = match source_opts.source_imgref {
1751        None => {
1752            ensure!(
1753                host_is_container,
1754                "Either --source-imgref must be defined or this command must be executed inside a podman container."
1755            );
1756
1757            crate::cli::require_root(true)?;
1758
1759            require_host_pidns()?;
1760            // Out of conservatism we only verify the host userns path when we're expecting
1761            // to do a self-install (e.g. not bootc-image-builder or equivalent).
1762            require_host_userns()?;
1763            let container_info = crate::containerenv::get_container_execution_info(&rootfs)?;
1764            // This command currently *must* be run inside a privileged container.
1765            match container_info.rootless.as_deref() {
1766                Some("1") => anyhow::bail!(
1767                    "Cannot install from rootless podman; this command must be run as root"
1768                ),
1769                Some(o) => tracing::debug!("rootless={o}"),
1770                // This one shouldn't happen except on old podman
1771                None => tracing::debug!(
1772                    "notice: Did not find rootless= entry in {}",
1773                    crate::containerenv::PATH,
1774                ),
1775            };
1776            tracing::trace!("Read container engine info {:?}", container_info);
1777
1778            let source = SourceInfo::from_container(&rootfs, &container_info)?;
1779            (source, Some(rootfs.try_clone()?))
1780        }
1781        Some(source) => {
1782            crate::cli::require_root(false)?;
1783            let source = SourceInfo::from_imageref(&source, &rootfs)?;
1784            (source, None)
1785        }
1786    };
1787
1788    // Load install configuration from TOML drop-in files early, so that
1789    // config values are available when constructing the target image reference.
1790    let install_config = config::load_config()?;
1791    if let Some(ref config) = install_config {
1792        tracing::debug!("Loaded install configuration");
1793        // Merge config file values into config_opts (CLI takes precedence)
1794        // Only apply config file value if CLI didn't explicitly set it
1795        if !config_opts.bootupd_skip_boot_uuid {
1796            config_opts.bootupd_skip_boot_uuid = config
1797                .bootupd
1798                .as_ref()
1799                .and_then(|b| b.skip_boot_uuid)
1800                .unwrap_or(false);
1801        }
1802
1803        if config_opts.bootloader.is_none() {
1804            config_opts.bootloader = config.bootloader.clone();
1805        }
1806
1807        if !target_opts.enforce_container_sigpolicy {
1808            target_opts.enforce_container_sigpolicy =
1809                config.enforce_container_sigpolicy.unwrap_or(false);
1810        }
1811    } else {
1812        tracing::debug!("No install configuration found");
1813    }
1814
1815    // Parse the target CLI image reference options and create the *target* image
1816    // reference, which defaults to pulling from a registry.
1817    if target_opts.target_no_signature_verification {
1818        // Perhaps log this in the future more prominently, but no reason to annoy people.
1819        tracing::debug!(
1820            "Use of --target-no-signature-verification flag which is enabled by default"
1821        );
1822    }
1823    let target_sigverify = sigpolicy_from_opt(target_opts.enforce_container_sigpolicy);
1824    let target_imgname = target_opts
1825        .target_imgref
1826        .as_deref()
1827        .unwrap_or(source.imageref.name.as_str());
1828    let target_transport =
1829        ostree_container::Transport::try_from(target_opts.target_transport.as_str())?;
1830    let target_imgref = ostree_container::OstreeImageReference {
1831        sigverify: target_sigverify,
1832        imgref: ostree_container::ImageReference {
1833            transport: target_transport,
1834            name: target_imgname.to_string(),
1835        },
1836    };
1837    tracing::debug!("Target image reference: {target_imgref}");
1838
1839    let kernel = target_rootfs
1840        .as_ref()
1841        .map(|rootfs| crate::kernel::find_kernel(rootfs, Some(&composefs_options.uki_addon_opts)))
1842        .transpose()?
1843        .flatten();
1844    let composefs_required = kernel.as_ref().is_some_and(|k| k.k_type.is_unified());
1845    #[cfg(feature = "install-to-disk")]
1846    let aboot_disk_layout = if disk_install {
1847        kernel.as_ref().and_then(|k| match k.k_type {
1848            crate::kernel::KernelType::Aboot { encoding, .. } => {
1849                Some(AbootDiskLayout::from(encoding))
1850            }
1851            _ => None,
1852        })
1853    } else {
1854        None
1855    };
1856
1857    tracing::debug!("Composefs required: {composefs_required}");
1858
1859    // ostree's prepare-root.conf is read from the running root even with
1860    // --source-imgref, like the install configuration: tools such as
1861    // bootc-image-builder run bootc from the image they install. Convert the
1862    // keyfile to a hashmap because GKeyFile isnt Send for probably bad reasons.
1863    let ostree_prepareroot_config = ostree_prepareroot::load_config_from_root(&rootfs)?
1864        .map(|kf| -> Result<HashMap<String, String>> {
1865            let mut r = HashMap::new();
1866            for grp in kf.groups() {
1867                for key in kf.keys(&grp)? {
1868                    let key = key.as_str();
1869                    let value = kf.value(&grp, key)?;
1870                    r.insert(format!("{grp}.{key}"), value.to_string());
1871                }
1872            }
1873            Ok(r)
1874        })
1875        .transpose()?;
1876
1877    // A UKI or aboot image requires the composefs backend, and a composefs-native image
1878    // without ostree's configuration defaults to it.
1879    let composefs_default = crate::bootc_composefs::image::defaults_to_composefs_backend(
1880        &rootfs,
1881        ostree_prepareroot_config.is_some(),
1882    )?;
1883    tracing::debug!("Composefs default: {composefs_default}");
1884    composefs_options.composefs_backend |= composefs_required || composefs_default;
1885    // An image may set a stateroot in its install configuration for its ostree
1886    // installs, so only check it when the image can't be installed with ostree.
1887    let config_stateroot = install_config
1888        .as_ref()
1889        .and_then(|c| c.stateroot.as_deref())
1890        .filter(|_| config_opts.stateroot.is_none())
1891        .filter(|_| composefs_required || composefs_default);
1892    composefs_options.validate(
1893        config_opts.bootloader.as_ref(),
1894        config_opts.stateroot.as_deref(),
1895        config_stateroot,
1896    )?;
1897
1898    composefs_options.validate_bound_images(config_opts.bound_images, &rootfs)?;
1899
1900    if !composefs_options.composefs_backend
1901        && (composefs_options.uki_addon_opts.scoped.is_some()
1902            || composefs_options.uki_addon_opts.global.is_some())
1903    {
1904        anyhow::bail!("UKI Addons are only supported on composefs backends");
1905    }
1906
1907    // Read the file eagerly so we error out early, and before the mount changes
1908    // below hide a file bind mounted under e.g. /tmp. We may re-exec further down
1909    // and run this again with those mounts in place, so pass the content to the
1910    // re-exec'd child via the environment.
1911    let root_ssh_authorized_keys = config_opts
1912        .root_ssh_authorized_keys
1913        .as_ref()
1914        .map(|p| -> Result<String> {
1915            use std::env::VarError;
1916            match std::env::var(ROOT_SSH_AUTHORIZED_KEYS_ENV) {
1917                // Set by our parent
1918                Ok(v) => Ok(v),
1919                Err(VarError::NotPresent) => {
1920                    std::fs::read_to_string(p).with_context(|| format!("Reading {p}"))
1921                }
1922                Err(e) => Err(e).with_context(|| format!("Parsing {ROOT_SSH_AUTHORIZED_KEYS_ENV}")),
1923            }
1924        })
1925        .transpose()?;
1926    let reexec_env: Vec<(&str, &str)> = root_ssh_authorized_keys
1927        .as_deref()
1928        .map(|v| (ROOT_SSH_AUTHORIZED_KEYS_ENV, v))
1929        .into_iter()
1930        .collect();
1931
1932    // We need to access devices that are set up by the host udev
1933    bootc_mount::ensure_mirrored_host_mount("/dev")?;
1934    // We need to read our own container image (and any logically bound images)
1935    // from the host container store.
1936    bootc_mount::ensure_mirrored_host_mount("/var/lib/containers")?;
1937    // In some cases we may create large files, and it's better not to have those
1938    // in our overlayfs.
1939    bootc_mount::ensure_mirrored_host_mount("/var/tmp")?;
1940    // udev state is required for running lsblk during install to-disk
1941    // see https://github.com/bootc-dev/bootc/pull/688
1942    bootc_mount::ensure_mirrored_host_mount("/run/udev")?;
1943    // We also always want /tmp to be a proper tmpfs on general principle.
1944    setup_tmp_mount()?;
1945    // Allocate a temporary directory we can use in various places to avoid
1946    // creating multiple.
1947    let tempdir = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1948    // And continue to init global state
1949    osbuild::adjust_for_bootc_image_builder(&rootfs, &tempdir)?;
1950
1951    if target_opts.run_fetch_check {
1952        verify_target_fetch(&tempdir, &target_imgref).await?;
1953    }
1954
1955    // Even though we require running in a container, the mounts we create should be specific
1956    // to this process, so let's enter a private mountns to avoid leaking them.
1957    if !external_source && std::env::var_os("BOOTC_SKIP_UNSHARE").is_none() {
1958        super::cli::ensure_self_unshared_mount_namespace(&reexec_env)?;
1959    }
1960
1961    setup_sys_mount("efivarfs", EFIVARFS)?;
1962
1963    // Now, deal with SELinux state.
1964    let selinux_state =
1965        reexecute_self_for_selinux_if_needed(&source, config_opts.disable_selinux, &reexec_env)?;
1966    tracing::debug!("SELinux state: {selinux_state:?}");
1967
1968    println!("Installing image: {:#}", &target_imgref);
1969    if let Some(digest) = source.digest.as_deref() {
1970        println!("Digest: {digest}");
1971    }
1972
1973    // Don't error out if a filesystem is not passed in via cli as we could have
1974    // repart.d definitions available
1975    let root_filesystem = target_fs.or(install_config
1976        .as_ref()
1977        .and_then(|c| c.filesystem_root())
1978        .and_then(|r| r.fstype));
1979
1980    let mut immutable_boot = false;
1981
1982    // For composefs backend, automatically disable fs-verity hard requirement if the
1983    // filesystem doesn't support it
1984    //
1985    // If we have a sealed UKI on our hands, then we can assume that user wanted fs-verity so
1986    // we hard require it in that particular case
1987    //
1988    // NOTE: This isn't really 100% accurate 100% of the time as the cmdline can be in an addon
1989    if let Some(root_filesystem) = root_filesystem {
1990        if let Some(kernel) = kernel.as_ref().filter(|k| k.k_type.is_unified()) {
1991            let allow_missing_fsverity = match kernel.k_type.cmdline() {
1992                Some(cmdline) => ComposefsCmdline::find_in_cmdline(cmdline)?
1993                    .is_some_and(|cfs_cmdline| cfs_cmdline.allow_missing_fsverity),
1994                None => false,
1995            };
1996
1997            if !allow_missing_fsverity {
1998                anyhow::ensure!(
1999                    root_filesystem.supports_fsverity(),
2000                    "Specified filesystem {root_filesystem} does not support fs-verity"
2001                );
2002            }
2003
2004            composefs_options.allow_missing_verity = allow_missing_fsverity;
2005            immutable_boot = true;
2006        }
2007
2008        // If `--allow-missing-verity` is already passed via CLI, don't modify
2009        if composefs_options.composefs_backend
2010            && !composefs_options.allow_missing_verity
2011            && !immutable_boot
2012        {
2013            composefs_options.allow_missing_verity = !root_filesystem.supports_fsverity();
2014        }
2015    }
2016
2017    tracing::info!(
2018        root_filesystem = root_filesystem
2019            .map(|f| f.to_string())
2020            .unwrap_or("None".into()),
2021        allow_missing_fsverity = composefs_options.allow_missing_verity,
2022        immutable_boot = immutable_boot,
2023        "ComposeFS install prep",
2024    );
2025
2026    if let Some(crate::spec::Bootloader::None) = config_opts.bootloader {
2027        if cfg!(target_arch = "s390x") {
2028            anyhow::bail!("Bootloader set to none is not supported for the s390x architecture");
2029        }
2030    }
2031
2032    // Only the ostree backend uses prepare-root.conf, and composefs-native
2033    // images needn't have one.
2034    let ostree_prepareroot_config = match ostree_prepareroot_config {
2035        Some(c) => c,
2036        None if composefs_options.composefs_backend => HashMap::new(),
2037        None => anyhow::bail!(
2038            "Failed to find {} in /usr/lib or /etc",
2039            ostree_prepareroot::CONF_PATH
2040        ),
2041    };
2042
2043    // Create our global (read-only) state which gets wrapped in an Arc
2044    // so we can pass it to worker threads too. Right now this just
2045    // combines our command line options along with some bind mounts from the host.
2046    let state = Arc::new(State {
2047        selinux_state,
2048        source,
2049        config_opts,
2050        target_opts,
2051        target_imgref,
2052        install_config,
2053        ostree_prepareroot_config,
2054        root_ssh_authorized_keys,
2055        container_root: rootfs,
2056        tempdir,
2057        host_is_container,
2058        composefs_required,
2059        composefs_options,
2060        // assume fs-verity is supported as that's the safer option
2061        composefs_fsverity_supported: root_filesystem
2062            .map(|fs| fs.supports_fsverity())
2063            .unwrap_or(true),
2064        allow_missing_verity_explicit,
2065        #[cfg(feature = "install-to-disk")]
2066        aboot_disk_layout,
2067    });
2068
2069    Ok(state)
2070}
2071
2072impl PostFetchState {
2073    pub(crate) fn new(
2074        state: &State,
2075        d: &Dir,
2076        image_bootloader: Option<Bootloader>,
2077    ) -> Result<Self> {
2078        // Determine bootloader type for the target system
2079        // Priority: user-specified > image-required > bootupd availability > systemd-boot fallback
2080        let detected_bootloader =
2081            select_bootloader(state.config_opts.bootloader, image_bootloader, d)?;
2082        println!("Bootloader: {detected_bootloader}");
2083        let r = Self {
2084            detected_bootloader,
2085        };
2086        Ok(r)
2087    }
2088}
2089
2090fn select_bootloader(
2091    configured: Option<Bootloader>,
2092    image: Option<Bootloader>,
2093    root: &Dir,
2094) -> Result<Bootloader> {
2095    if let Some(bootloader) = configured.or(image) {
2096        return Ok(bootloader);
2097    }
2098    if crate::bootloader::supports_bootupd(root)? {
2099        Ok(Bootloader::Grub)
2100    } else {
2101        Ok(Bootloader::Systemd)
2102    }
2103}
2104
2105/// Given a baseline root filesystem with an ostree sysroot initialized:
2106/// - install the container to that root
2107/// - install the bootloader
2108/// - Other post operations, such as pulling bound images
2109async fn install_with_sysroot(
2110    state: &State,
2111    rootfs: &RootSetup,
2112    storage: &Storage,
2113    boot_uuid: &str,
2114    bound_images: BoundImages,
2115    has_ostree: bool,
2116) -> Result<()> {
2117    let ostree = storage.get_ostree()?;
2118    let c_storage = storage.get_ensure_imgstore()?;
2119
2120    // And actually set up the container in that root, returning a deployment and
2121    // the aleph state (see below).
2122    let (deployment, aleph) = install_container(state, rootfs, ostree, storage, has_ostree).await?;
2123    // Write the aleph data that captures the system state at the time of provisioning for aid in future debugging.
2124    aleph.write_to(&rootfs.physical_root)?;
2125
2126    let deployment_path = ostree.deployment_dirpath(&deployment);
2127
2128    let deployment_dir = rootfs
2129        .physical_root
2130        .open_dir(&deployment_path)
2131        .context("Opening deployment dir")?;
2132    let postfetch = PostFetchState::new(state, &deployment_dir, None)?;
2133
2134    if cfg!(target_arch = "s390x") {
2135        // TODO: Integrate s390x support into install_via_bootupd
2136        // zipl only supports single device
2137        crate::bootloader::install_via_zipl(&rootfs.device_info.require_single_root()?, boot_uuid)?;
2138    } else {
2139        match postfetch.detected_bootloader {
2140            Bootloader::Grub => {
2141                let root_path = rootfs
2142                    .target_root_path
2143                    .clone()
2144                    .unwrap_or(rootfs.physical_root_path.clone());
2145                let chroot_target = root_path.join(deployment_path.as_str());
2146                let bind_boot_path = root_path.join(BOOT);
2147                crate::bootloader::install_via_bootupd(
2148                    &rootfs.device_info,
2149                    &root_path,
2150                    &state.config_opts,
2151                    Some(chroot_target.as_path()),
2152                    Some(bind_boot_path.as_path()),
2153                )?;
2154            }
2155            Bootloader::Systemd | Bootloader::GrubCC | Bootloader::Ukiboot => {
2156                anyhow::bail!("bootupd is required for ostree-based installs");
2157            }
2158            Bootloader::None => {
2159                tracing::debug!("Skip bootloader installation due set to None");
2160            }
2161        }
2162    }
2163    tracing::debug!("Installed bootloader");
2164
2165    tracing::debug!("Performing post-deployment operations");
2166
2167    match bound_images {
2168        BoundImages::Skip => {}
2169        BoundImages::Resolved(resolved_bound_images) => {
2170            // Now copy each bound image from the host's container storage into the target.
2171            for image in resolved_bound_images {
2172                let image = image.image.as_str();
2173                c_storage.pull_from_host_storage(image).await?;
2174            }
2175        }
2176        BoundImages::Unresolved(bound_images) => {
2177            crate::boundimage::pull_images_impl(c_storage, bound_images)
2178                .await
2179                .context("pulling bound images")?;
2180        }
2181    }
2182
2183    Ok(())
2184}
2185
2186enum BoundImages {
2187    Skip,
2188    Resolved(Vec<ResolvedBoundImage>),
2189    Unresolved(Vec<BoundImage>),
2190}
2191
2192impl BoundImages {
2193    async fn from_state(state: &State) -> Result<Self> {
2194        let bound_images = match state.config_opts.bound_images {
2195            BoundImagesOpt::Skip => BoundImages::Skip,
2196            others => {
2197                let queried_images = crate::boundimage::query_bound_images(&state.container_root)?;
2198                match others {
2199                    BoundImagesOpt::Stored => {
2200                        // Verify each bound image is present in the container storage
2201                        let mut r = Vec::with_capacity(queried_images.len());
2202                        for image in queried_images {
2203                            let resolved = ResolvedBoundImage::from_image(&image).await?;
2204                            tracing::debug!("Resolved {}: {}", resolved.image, resolved.digest);
2205                            r.push(resolved)
2206                        }
2207                        BoundImages::Resolved(r)
2208                    }
2209                    BoundImagesOpt::Pull => {
2210                        // No need to resolve the images, we will pull them into the target later
2211                        BoundImages::Unresolved(queried_images)
2212                    }
2213                    BoundImagesOpt::Skip => anyhow::bail!("unreachable error"),
2214                }
2215            }
2216        };
2217
2218        Ok(bound_images)
2219    }
2220}
2221
2222async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> {
2223    // We verify this upfront because it's currently required by bootupd
2224    let boot_uuid = rootfs
2225        .get_boot_uuid()?
2226        .or(rootfs.rootfs_uuid.as_deref())
2227        .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
2228    tracing::debug!("boot uuid={boot_uuid}");
2229
2230    let bound_images = BoundImages::from_state(state).await?;
2231
2232    // Initialize the ostree sysroot (repo, stateroot, etc.)
2233
2234    {
2235        let (sysroot, has_ostree) = initialize_ostree_root(state, rootfs).await?;
2236
2237        install_with_sysroot(
2238            state,
2239            rootfs,
2240            &sysroot,
2241            &boot_uuid,
2242            bound_images,
2243            has_ostree,
2244        )
2245        .await?;
2246        let ostree = sysroot.get_ostree()?;
2247
2248        if matches!(cleanup, Cleanup::TriggerOnNextBoot) {
2249            let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
2250            tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}");
2251            sysroot_dir.atomic_write(DESTRUCTIVE_CLEANUP, b"")?;
2252        }
2253
2254        // Ensure the image storage is SELinux-labeled. This must happen
2255        // after all image pulls are complete.
2256        sysroot.ensure_imgstore_labeled()?;
2257
2258        // We must drop the sysroot here in order to close any open file
2259        // descriptors.
2260    };
2261
2262    // Run this on every install as the penultimate step
2263    install_finalize(&rootfs.physical_root_path).await?;
2264
2265    Ok(())
2266}
2267
2268async fn install_to_filesystem_impl(
2269    state: &State,
2270    rootfs: &mut RootSetup,
2271    cleanup: Cleanup,
2272) -> Result<()> {
2273    if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) {
2274        rootfs.kargs.extend(&Cmdline::from("selinux=0"));
2275    }
2276    // Drop exclusive ownership since we're done with mutation
2277    let rootfs = &*rootfs;
2278
2279    match rootfs.device_info.pttype.as_deref() {
2280        Some("dos") => crate::utils::medium_visibility_warning(
2281            "Installing to `dos` format partitions is not recommended",
2282        ),
2283        Some("gpt") => {
2284            // The only thing we should be using in general
2285        }
2286        Some(o) => {
2287            crate::utils::medium_visibility_warning(&format!("Unknown partition table type {o}"))
2288        }
2289        None => {
2290            // No partition table type - may be a filesystem install or loop device
2291        }
2292    }
2293
2294    if state.composefs_options.composefs_backend {
2295        let fetch_ref = state.source.composefs_fetch_reference();
2296        let (manifest, _) = get_container_manifest_and_config(&fetch_ref).await?;
2297        // A capable filesystem gets a strict provisional repository.  The
2298        // imported image is inspected below; only a fresh repository may then
2299        // atomically adopt the image's explicit relaxed policy.
2300        let provisional_relaxed = !state.composefs_fsverity_supported;
2301        let fetch_imgref = Some(fetch_ref.clone().into());
2302        let initialized = initialize_composefs_repository(state, rootfs, provisional_relaxed)?;
2303        crate::deploy::check_disk_space_composefs(
2304            &initialized.repo,
2305            &manifest.manifest,
2306            &ImageReference {
2307                image: fetch_ref.name.clone(),
2308                transport: fetch_ref.transport.to_string(),
2309                signature: None,
2310            },
2311        )?;
2312        let pull_result = pull_install_composefs_repository(
2313            state,
2314            rootfs,
2315            &initialized.repo,
2316            state.target_opts.unified_storage_exp,
2317            fetch_imgref.as_ref(),
2318        )
2319        .await?;
2320        let image_fsverity_policy = crate::bootc_composefs::repo::inspect_image_fsverity_policy(
2321            &initialized.repo,
2322            &pull_result,
2323        )?;
2324
2325        let requested_relaxed = crate::bootc_composefs::repo::final_repository_policy(
2326            image_fsverity_policy,
2327            state.composefs_options.allow_missing_verity,
2328        );
2329        if !initialized.created {
2330            validate_repository_policy(
2331                initialized.repository_insecure,
2332                requested_relaxed,
2333                state.allow_missing_verity_explicit,
2334            )?;
2335        } else if !requested_relaxed && provisional_relaxed {
2336            anyhow::bail!(
2337                "Initial boot artifact requires fs-verity, but the target filesystem does not support it"
2338            );
2339        }
2340        // Repository handles retain LOCK_SH, so no Arc may remain before the
2341        // fresh-policy replacement below takes LOCK_EX.
2342        drop(initialized.repo);
2343        if initialized.created && requested_relaxed && !provisional_relaxed {
2344            finalize_fresh_repository_policy(
2345                &rootfs.physical_root,
2346                crate::bootc_composefs::repo::composefs_repository_config(false),
2347            )?;
2348        }
2349        let allow_missing_verity = requested_relaxed;
2350        tag_pulled_image(&rootfs.physical_root, &pull_result)?;
2351
2352        setup_composefs_boot(rootfs, state, &pull_result, allow_missing_verity).await?;
2353
2354        // Label composefs objects as /usr so they get usr_t rather than
2355        // default_t (which has no policy match).
2356        if let Some(policy) = state.load_policy()? {
2357            tracing::info!("Labeling composefs objects as /usr");
2358            crate::lsm::relabel_recurse(
2359                &rootfs.physical_root,
2360                "composefs",
2361                Some("/usr".into()),
2362                &policy,
2363            )
2364            .context("SELinux labeling of composefs objects")?;
2365        }
2366    } else {
2367        ostree_install(state, rootfs, cleanup).await?;
2368
2369        // For s390x, we set zipl as the bootloader
2370        // this needs to be done after the ostree commit is deployed,
2371        // as we don't want zipl to run during the initial ostree deployment.
2372        if cfg!(target_arch = "s390x") {
2373            Command::new("ostree")
2374                .args([
2375                    "config",
2376                    "--repo",
2377                    "ostree/repo",
2378                    "set",
2379                    "sysroot.bootloader",
2380                    "zipl",
2381                ])
2382                .cwd_dir(rootfs.physical_root.try_clone()?)
2383                .run_capture_stderr()
2384                .context("Setting bootloader config to zipl")?;
2385        }
2386    }
2387
2388    // As the very last step before filesystem finalization, do a full SELinux
2389    // relabel of the physical root filesystem.  Any files that are already
2390    // labeled (e.g. ostree deployment contents, composefs objects) are skipped.
2391    if let Some(policy) = state.load_policy()? {
2392        tracing::info!("Performing final SELinux relabeling of physical root");
2393        let mut path = Utf8PathBuf::from("");
2394        crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None)
2395            .context("Final SELinux relabeling of physical root")?;
2396    } else {
2397        tracing::debug!("Skipping final SELinux relabel (SELinux is disabled)");
2398    }
2399
2400    // Finalize mounted filesystems
2401    if !rootfs.skip_finalize {
2402        let bootfs = rootfs.boot.as_ref().map(|_| ("boot", BOOT));
2403        for (fsname, fs) in std::iter::once(("root", ".")).chain(bootfs) {
2404            finalize_filesystem(fsname, &rootfs.physical_root, fs)?;
2405        }
2406    }
2407
2408    Ok(())
2409}
2410
2411fn installation_complete() {
2412    println!("Installation complete!");
2413}
2414
2415/// Implementation of the `bootc install to-disk` CLI command.
2416#[context("Installing to disk")]
2417#[cfg(feature = "install-to-disk")]
2418pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> {
2419    // Log the disk installation operation to systemd journal
2420    const INSTALL_DISK_JOURNAL_ID: &str = "8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2";
2421    let source_image = opts
2422        .source_opts
2423        .source_imgref
2424        .as_ref()
2425        .map(|s| s.as_str())
2426        .unwrap_or("none");
2427    let target_device = opts.block_opts.device.as_str();
2428
2429    tracing::info!(
2430        message_id = INSTALL_DISK_JOURNAL_ID,
2431        bootc.source_image = source_image,
2432        bootc.target_device = target_device,
2433        bootc.via_loopback = if opts.via_loopback { "true" } else { "false" },
2434        "Starting disk installation from {} to {}",
2435        source_image,
2436        target_device
2437    );
2438
2439    let mut block_opts = opts.block_opts;
2440    let target_blockdev_meta = block_opts
2441        .device
2442        .metadata()
2443        .with_context(|| format!("Querying {}", &block_opts.device))?;
2444    if opts.via_loopback {
2445        if !opts.config_opts.generic_image {
2446            crate::utils::medium_visibility_warning(
2447                "Automatically enabling --generic-image when installing via loopback",
2448            );
2449            opts.config_opts.generic_image = true;
2450        }
2451        if !target_blockdev_meta.file_type().is_file() {
2452            anyhow::bail!(
2453                "Not a regular file (to be used via loopback): {}",
2454                block_opts.device
2455            );
2456        }
2457    } else if !target_blockdev_meta.file_type().is_block_device() {
2458        anyhow::bail!("Not a block device: {}", block_opts.device);
2459    }
2460
2461    let state = prepare_install(
2462        opts.config_opts,
2463        opts.source_opts,
2464        opts.target_opts,
2465        opts.composefs_opts,
2466        block_opts.filesystem,
2467        true,
2468    )
2469    .await?;
2470
2471    // This is all blocking stuff
2472    let (mut rootfs, loopback) = {
2473        let loopback_dev = if opts.via_loopback {
2474            let loopback_dev =
2475                bootc_blockdev::LoopbackDevice::new(block_opts.device.as_std_path())?;
2476            block_opts.device = loopback_dev.path().into();
2477            Some(loopback_dev)
2478        } else {
2479            None
2480        };
2481
2482        let state = state.clone();
2483        let rootfs = tokio::task::spawn_blocking(move || {
2484            baseline::install_create_rootfs(&state, block_opts, opts.run_repart)
2485        })
2486        .await??;
2487        (rootfs, loopback_dev)
2488    };
2489
2490    install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip).await?;
2491
2492    // Drop all data about the root except the bits we need to ensure any file descriptors etc. are closed.
2493    let (root_path, luksdev) = rootfs.into_storage();
2494    Task::new_and_run(
2495        "Unmounting filesystems",
2496        "umount",
2497        ["-R", root_path.as_str()],
2498    )?;
2499    if let Some(luksdev) = luksdev.as_deref() {
2500        Task::new_and_run("Closing root LUKS device", "cryptsetup", ["close", luksdev])?;
2501    }
2502
2503    if let Some(loopback_dev) = loopback {
2504        loopback_dev.close()?;
2505    }
2506
2507    // At this point, all other threads should be gone.
2508    if let Some(state) = Arc::into_inner(state) {
2509        state.consume()?;
2510    } else {
2511        // This shouldn't happen...but we will make it not fatal right now
2512        tracing::warn!("Failed to consume state Arc");
2513    }
2514
2515    installation_complete();
2516
2517    Ok(())
2518}
2519
2520/// Require that a directory contains only mount points recursively.
2521/// Returns Ok(()) if all entries in the directory tree are either:
2522/// - Mount points (on different filesystems)
2523/// - Directories that themselves contain only mount points (recursively)
2524/// - The lost+found directory
2525///
2526/// Returns an error if any non-mount entry is found.
2527///
2528/// This handles cases like /var containing /var/lib (not a mount) which contains
2529/// /var/lib/containers (a mount point).
2530#[context("Requiring directory contains only mount points")]
2531fn require_dir_contains_only_mounts(parent_fd: &Dir, dir_name: &str) -> Result<()> {
2532    tracing::trace!("Checking directory {dir_name} for non-mount entries");
2533    let Some(dir_fd) = parent_fd.open_dir_noxdev(dir_name)? else {
2534        // The directory itself is a mount point
2535        tracing::trace!("{dir_name} is a mount point");
2536        return Ok(());
2537    };
2538
2539    if dir_fd.entries()?.next().is_none() {
2540        anyhow::bail!("Found empty directory: {dir_name}");
2541    }
2542
2543    for entry in dir_fd.entries()? {
2544        tracing::trace!("Checking entry in {dir_name}");
2545        let entry = DirEntryUtf8::from_cap_std(entry?);
2546        let entry_name = entry.file_name()?;
2547
2548        if entry_name == LOST_AND_FOUND {
2549            continue;
2550        }
2551
2552        let etype = entry.file_type()?;
2553        if etype == FileType::dir() {
2554            require_dir_contains_only_mounts(&dir_fd, &entry_name)?;
2555        } else {
2556            anyhow::bail!("Found entry in {dir_name}: {entry_name}");
2557        }
2558    }
2559
2560    Ok(())
2561}
2562
2563#[context("Verifying empty rootfs")]
2564fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> {
2565    for e in rootfs_fd.entries()? {
2566        let e = DirEntryUtf8::from_cap_std(e?);
2567        let name = e.file_name()?;
2568        if name == LOST_AND_FOUND {
2569            continue;
2570        }
2571
2572        // Check if this entry is a directory
2573        let etype = e.file_type()?;
2574        if etype == FileType::dir() {
2575            require_dir_contains_only_mounts(rootfs_fd, &name)?;
2576        } else {
2577            anyhow::bail!("Non-empty root filesystem; found {name:?}");
2578        }
2579    }
2580    Ok(())
2581}
2582
2583/// Remove all entries in a directory, but do not traverse across distinct devices.
2584/// If mount_err is true, then an error is returned if a mount point is found;
2585/// otherwise it is silently ignored.
2586fn remove_all_in_dir_no_xdev(d: &Dir, mount_err: bool) -> Result<()> {
2587    for entry in d.entries()? {
2588        let entry = entry?;
2589        let name = entry.file_name();
2590        let etype = entry.file_type()?;
2591        if etype == FileType::dir() {
2592            remove_dir_no_xdev(d, &name, mount_err)?;
2593        } else {
2594            d.remove_file_optional(&name)?;
2595        }
2596    }
2597    anyhow::Ok(())
2598}
2599
2600/// Recursively remove the directory `name` in `d`, without crossing devices.
2601/// A mount point is left alone, or is an error if `mount_err` is true.
2602fn remove_dir_no_xdev(d: &Dir, name: impl AsRef<Path>, mount_err: bool) -> Result<()> {
2603    let name = name.as_ref();
2604    if let Some(subdir) = d.open_dir_noxdev(name)? {
2605        remove_all_in_dir_no_xdev(&subdir, mount_err)?;
2606        d.remove_dir(name)?;
2607    } else if mount_err {
2608        anyhow::bail!("Found unexpected mount point {name:?}");
2609    }
2610    Ok(())
2611}
2612
2613#[context("Removing boot directory content except loader dir on ostree")]
2614fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> {
2615    let entries = bootdir
2616        .entries()
2617        .context("Reading boot directory entries")?;
2618
2619    for entry in entries {
2620        let entry = entry.context("Reading directory entry")?;
2621        let file_name = entry.file_name();
2622        let file_name = if let Some(n) = file_name.to_str() {
2623            n
2624        } else {
2625            anyhow::bail!("Invalid non-UTF8 filename: {file_name:?} in /boot");
2626        };
2627
2628        // TODO: Preserve basically everything (including the bootloader entries
2629        // on non-ostree) by default until the very end of the install. And ideally
2630        // make the "commit" phase an optional step after.
2631        if is_ostree && file_name.starts_with("loader") {
2632            continue;
2633        }
2634
2635        let etype = entry.file_type()?;
2636        if etype == FileType::dir() {
2637            remove_dir_no_xdev(bootdir, file_name, false)
2638                .with_context(|| format!("Removing directory: {file_name}"))?;
2639        } else {
2640            bootdir
2641                .remove_file_optional(&file_name)
2642                .with_context(|| format!("Removing file: {}", file_name))?;
2643        }
2644    }
2645    Ok(())
2646}
2647
2648/// Remove the bootloader dirs (bootupd/grub, systemd-boot) from the ESP.
2649/// Other content, e.g. Asahi's `m1n1/` and `vendorfw/`, may be firmware
2650/// or earlier boot stages we cannot recreate, so it is preserved.
2651// TODO: be more selective, e.g. keep other OSes' `EFI/<vendor>` and
2652// non-bootc `loader/` entries, and drop old Type #1 kernels (see #2243).
2653#[context("Removing bootloader content from EFI system partition")]
2654fn clean_esp_bootloader_dirs(efidir: &Dir) -> Result<()> {
2655    for name in ["EFI", "loader"] {
2656        if efidir.try_exists(name)? {
2657            remove_dir_no_xdev(efidir, name, false)
2658                .with_context(|| format!("Removing directory: {name}"))?;
2659        }
2660    }
2661    Ok(())
2662}
2663
2664#[context("Removing boot directory content")]
2665fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) -> Result<()> {
2666    let bootdir =
2667        crate::utils::open_dir_remount_rw(rootfs, BOOT.into()).context("Opening /boot")?;
2668
2669    if ARCH_USES_EFI {
2670        // On booted FCOS, esp is not mounted by default
2671        // Mount ESP part at /boot/efi before clean
2672        crate::bootloader::mount_esp_part(&rootfs, &rootfs_path, is_ostree)?;
2673    }
2674
2675    // This should not remove /boot/efi note.
2676    remove_all_except_loader_dirs(&bootdir, is_ostree).context("Emptying /boot")?;
2677
2678    if ARCH_USES_EFI {
2679        if let Some(efidir) = bootdir
2680            .open_dir_optional(crate::bootloader::EFI_DIR)
2681            .context("Opening /boot/efi")?
2682        {
2683            clean_esp_bootloader_dirs(&efidir)?;
2684        }
2685    }
2686
2687    Ok(())
2688}
2689
2690struct RootMountInfo {
2691    mount_spec: String,
2692    kargs: Vec<String>,
2693}
2694
2695/// Discover how to mount the root filesystem, using existing kernel arguments and information
2696/// about the root mount.
2697fn find_root_args_to_inherit(
2698    cmdline: &bytes::Cmdline,
2699    root_info: &Filesystem,
2700) -> Result<RootMountInfo> {
2701    // If we have a root= karg, then use that
2702    let root = cmdline
2703        .find_utf8("root")?
2704        .and_then(|p| p.value().map(|p| p.to_string()));
2705    let (mount_spec, kargs) = if let Some(root) = root {
2706        let rootflags = cmdline.find(ROOTFLAGS_KEY);
2707        let inherit_kargs = cmdline.find_all_starting_with(INITRD_ARG_PREFIX);
2708        (
2709            root,
2710            rootflags
2711                .into_iter()
2712                .chain(inherit_kargs)
2713                .map(|p| utf8::Parameter::try_from(p).map(|p| p.to_string()))
2714                .collect::<Result<Vec<_>, _>>()?,
2715        )
2716    } else {
2717        let uuid = root_info
2718            .uuid
2719            .as_deref()
2720            .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?;
2721        (format!("UUID={uuid}"), Vec::new())
2722    };
2723
2724    Ok(RootMountInfo { mount_spec, kargs })
2725}
2726
2727fn warn_on_host_root(rootfs_fd: &Dir) -> Result<()> {
2728    // Seconds for which we wait while warning
2729    const DELAY_SECONDS: u64 = 20;
2730
2731    let host_root_dfd = &Dir::open_ambient_dir("/proc/1/root", cap_std::ambient_authority())?;
2732    let host_root_devstat = rustix::fs::fstatvfs(host_root_dfd)?;
2733    let target_devstat = rustix::fs::fstatvfs(rootfs_fd)?;
2734    if host_root_devstat.f_fsid != target_devstat.f_fsid {
2735        tracing::debug!("Not the host root");
2736        return Ok(());
2737    }
2738    let dashes = "----------------------------";
2739    let timeout = Duration::from_secs(DELAY_SECONDS);
2740    eprintln!("{dashes}");
2741    crate::utils::medium_visibility_warning(
2742        "WARNING: This operation will OVERWRITE THE BOOTED HOST ROOT FILESYSTEM and is NOT REVERSIBLE.",
2743    );
2744    eprintln!("Waiting {timeout:?} to continue; interrupt (Control-C) to cancel.");
2745    eprintln!("{dashes}");
2746
2747    let bar = indicatif::ProgressBar::new_spinner();
2748    bar.enable_steady_tick(Duration::from_millis(100));
2749    std::thread::sleep(timeout);
2750    bar.finish();
2751
2752    Ok(())
2753}
2754
2755pub enum Cleanup {
2756    Skip,
2757    TriggerOnNextBoot,
2758}
2759
2760/// Implementation of the `bootc install to-filesystem` CLI command.
2761#[context("Installing to filesystem")]
2762pub(crate) async fn install_to_filesystem(
2763    opts: InstallToFilesystemOpts,
2764    targeting_host_root: bool,
2765    cleanup: Cleanup,
2766) -> Result<()> {
2767    // Log the installation operation to systemd journal
2768    const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3";
2769    let source_image = opts
2770        .source_opts
2771        .source_imgref
2772        .as_ref()
2773        .map(|s| s.as_str())
2774        .unwrap_or("none");
2775    let target_path = opts.filesystem_opts.root_path.as_str();
2776
2777    tracing::info!(
2778        message_id = INSTALL_FILESYSTEM_JOURNAL_ID,
2779        bootc.source_image = source_image,
2780        bootc.target_path = target_path,
2781        bootc.targeting_host_root = if targeting_host_root { "true" } else { "false" },
2782        "Starting filesystem installation from {} to {}",
2783        source_image,
2784        target_path
2785    );
2786
2787    // And the last bit of state here is the fsopts, which we also destructure now.
2788    let mut fsopts = opts.filesystem_opts;
2789
2790    // If we're doing an alongside install, automatically set up the host rootfs
2791    // mount if it wasn't done already.
2792    if targeting_host_root
2793        && fsopts.root_path.as_str() == ALONGSIDE_ROOT_MOUNT
2794        && !fsopts.root_path.try_exists()?
2795    {
2796        tracing::debug!("Mounting host / to {ALONGSIDE_ROOT_MOUNT}");
2797        std::fs::create_dir(ALONGSIDE_ROOT_MOUNT)?;
2798        bootc_mount::bind_mount_from_pidns(
2799            bootc_mount::PID1,
2800            "/".into(),
2801            ALONGSIDE_ROOT_MOUNT.into(),
2802            true,
2803        )
2804        .context("Mounting host / to {ALONGSIDE_ROOT_MOUNT}")?;
2805    }
2806
2807    let target_root_path = fsopts.root_path.clone();
2808
2809    // Get a file descriptor for the root path /target
2810    let target_rootfs_fd =
2811        Dir::open_ambient_dir(&target_root_path, cap_std::ambient_authority())
2812            .with_context(|| format!("Opening target root directory {target_root_path}"))?;
2813
2814    tracing::debug!("Target root filesystem: {target_root_path}");
2815
2816    if let Some(false) = target_rootfs_fd.is_mountpoint(".")? {
2817        anyhow::bail!("Not a mountpoint: {target_root_path}");
2818    }
2819
2820    // Check that the target is a directory
2821    {
2822        let root_path = &fsopts.root_path;
2823        let st = root_path
2824            .symlink_metadata()
2825            .with_context(|| format!("Querying target filesystem {root_path}"))?;
2826        if !st.is_dir() {
2827            anyhow::bail!("Not a directory: {root_path}");
2828        }
2829    }
2830
2831    // If we're installing to an ostree root, then find the physical root from
2832    // the deployment root.
2833    let possible_physical_root = fsopts.root_path.join("sysroot");
2834    let possible_ostree_dir = possible_physical_root.join("ostree");
2835    let is_already_ostree = possible_ostree_dir.exists();
2836    if is_already_ostree {
2837        tracing::debug!(
2838            "ostree detected in {possible_ostree_dir}, assuming target is a deployment root and using {possible_physical_root}"
2839        );
2840        fsopts.root_path = possible_physical_root;
2841    };
2842
2843    // Get a file descriptor for the root path
2844    // It will be /target/sysroot on ostree OS, or will be /target
2845    let rootfs_fd = if is_already_ostree {
2846        let root_path = &fsopts.root_path;
2847        let rootfs_fd = Dir::open_ambient_dir(&fsopts.root_path, cap_std::ambient_authority())
2848            .with_context(|| format!("Opening target root directory {root_path}"))?;
2849
2850        tracing::debug!("Root filesystem: {root_path}");
2851
2852        if let Some(false) = rootfs_fd.is_mountpoint(".")? {
2853            anyhow::bail!("Not a mountpoint: {root_path}");
2854        }
2855        rootfs_fd
2856    } else {
2857        target_rootfs_fd.try_clone()?
2858    };
2859
2860    // Gather data about the root filesystem
2861    let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?;
2862
2863    // Gather global state, destructuring the provided options.
2864    // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things)
2865    // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT.
2866    // IMPORTANT: In practice, we should only be gathering information before this point,
2867    // IMPORTANT: and not performing any mutations at all.
2868    let state = prepare_install(
2869        opts.config_opts,
2870        opts.source_opts,
2871        opts.target_opts,
2872        opts.composefs_opts,
2873        Some(inspect.fstype.as_str().try_into()?),
2874        false,
2875    )
2876    .await?;
2877
2878    // Check to see if this happens to be the real host root
2879    if !fsopts.acknowledge_destructive {
2880        warn_on_host_root(&target_rootfs_fd)?;
2881    }
2882
2883    match fsopts.replace {
2884        Some(ReplaceMode::Wipe) => {
2885            let rootfs_fd = rootfs_fd.try_clone()?;
2886            println!("Wiping contents of root");
2887            tokio::task::spawn_blocking(move || remove_all_in_dir_no_xdev(&rootfs_fd, true))
2888                .await??;
2889        }
2890        Some(ReplaceMode::Alongside) => {
2891            clean_boot_directories(&target_rootfs_fd, &target_root_path, is_already_ostree)?
2892        }
2893        None => require_empty_rootdir(&rootfs_fd)?,
2894    }
2895
2896    // We support overriding the mount specification for root (i.e. LABEL vs UUID versus
2897    // raw paths).
2898    // We also support an empty specification as a signal to omit any mountspec kargs.
2899    // CLI takes precedence over config file.
2900    let config_root_mount_spec = state
2901        .install_config
2902        .as_ref()
2903        .and_then(|c| c.root_mount_spec.as_ref());
2904    let root_info = if let Some(s) = fsopts.root_mount_spec.as_ref().or(config_root_mount_spec) {
2905        RootMountInfo {
2906            mount_spec: s.to_string(),
2907            kargs: Vec::new(),
2908        }
2909    } else if targeting_host_root {
2910        // In the to-existing-root case, look at /proc/cmdline
2911        let cmdline = bytes::Cmdline::from_proc()?;
2912        find_root_args_to_inherit(&cmdline, &inspect)?
2913    } else {
2914        // Otherwise, gather metadata from the provided root and use its provided UUID as a
2915        // default root= karg.
2916        let uuid = inspect
2917            .uuid
2918            .as_deref()
2919            .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?;
2920        let kargs = match inspect.fstype.as_str() {
2921            "btrfs" => {
2922                let subvol = crate::utils::find_mount_option(&inspect.options, "subvol");
2923                subvol
2924                    .map(|vol| format!("rootflags=subvol={vol}"))
2925                    .into_iter()
2926                    .collect::<Vec<_>>()
2927            }
2928            _ => Vec::new(),
2929        };
2930        RootMountInfo {
2931            mount_spec: format!("UUID={uuid}"),
2932            kargs,
2933        }
2934    };
2935    tracing::debug!("Root mount: {} {:?}", root_info.mount_spec, root_info.kargs);
2936
2937    let boot_is_mount = {
2938        if let Some(boot_metadata) = target_rootfs_fd.symlink_metadata_optional(BOOT)? {
2939            let root_dev = rootfs_fd.dir_metadata()?.dev();
2940            let boot_dev = boot_metadata.dev();
2941            tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}");
2942            root_dev != boot_dev
2943        } else {
2944            tracing::debug!("No /{BOOT} directory found");
2945            false
2946        }
2947    };
2948    // Find the UUID of /boot because we need it for GRUB.
2949    let boot_uuid = if boot_is_mount {
2950        let boot_path = target_root_path.join(BOOT);
2951        tracing::debug!("boot_path={boot_path}");
2952        let u = bootc_mount::inspect_filesystem(&boot_path)
2953            .with_context(|| format!("Inspecting /{BOOT}"))?
2954            .uuid
2955            .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?;
2956        Some(u)
2957    } else {
2958        None
2959    };
2960    tracing::debug!("boot UUID: {boot_uuid:?}");
2961
2962    // Find the real underlying backing device for the root.  This is currently just required
2963    // for GRUB (BIOS) and in the future zipl (I think).
2964    let device_info = {
2965        let dev = bootc_blockdev::list_dev(Utf8Path::new(&inspect.source))?;
2966        tracing::debug!("Target filesystem backing device: {}", dev.path());
2967        dev
2968    };
2969
2970    let rootarg = format!("root={}", root_info.mount_spec);
2971    // CLI takes precedence over config file.
2972    let config_boot_mount_spec = state
2973        .install_config
2974        .as_ref()
2975        .and_then(|c| c.boot_mount_spec.as_ref());
2976    let mut boot = if let Some(spec) = fsopts.boot_mount_spec.as_ref().or(config_boot_mount_spec) {
2977        // An empty boot mount spec signals to omit the mountspec kargs
2978        // See https://github.com/bootc-dev/bootc/issues/1441
2979        if spec.is_empty() {
2980            None
2981        } else {
2982            Some(MountSpec::new(&spec, &format!("/{BOOT}")))
2983        }
2984    } else {
2985        // Read /etc/fstab to get boot entry, but only use it if it's UUID-based
2986        // Otherwise fall back to boot_uuid
2987        read_boot_fstab_entry(&rootfs_fd)?
2988            .filter(|spec| spec.get_source_uuid().is_some())
2989            .or_else(|| {
2990                boot_uuid
2991                    .as_deref()
2992                    .map(|boot_uuid| MountSpec::new_uuid_src(boot_uuid, &format!("/{BOOT}")))
2993            })
2994    };
2995    // Ensure that we mount /boot readonly because it's really owned by bootc/ostree
2996    // and we don't want e.g. apt/dnf trying to mutate it.
2997    if let Some(boot) = boot.as_mut() {
2998        boot.push_option("ro");
2999    }
3000    // By default, we inject a boot= karg because things like FIPS compliance currently
3001    // require checking in the initramfs.
3002    let bootarg = boot.as_ref().map(|boot| format!("boot={}", &boot.source));
3003
3004    // If the root mount spec is empty, we omit the mounts kargs entirely.
3005    // https://github.com/bootc-dev/bootc/issues/1441
3006    let mut kargs = if root_info.mount_spec.is_empty() {
3007        Vec::new()
3008    } else {
3009        [rootarg]
3010            .into_iter()
3011            .chain(root_info.kargs)
3012            .collect::<Vec<_>>()
3013    };
3014
3015    kargs.push(RW_KARG.to_string());
3016
3017    if let Some(bootarg) = bootarg {
3018        kargs.push(bootarg);
3019    }
3020
3021    let kargs = Cmdline::from(kargs.join(" "));
3022
3023    let skip_finalize =
3024        matches!(fsopts.replace, Some(ReplaceMode::Alongside)) || fsopts.skip_finalize;
3025    let mut rootfs = RootSetup {
3026        #[cfg(feature = "install-to-disk")]
3027        luks_device: None,
3028        device_info,
3029        physical_root_path: fsopts.root_path,
3030        physical_root: rootfs_fd,
3031        target_root_path: Some(target_root_path.clone()),
3032        rootfs_uuid: inspect.uuid.clone(),
3033        aboot_disk_layout: None,
3034        disk_install: false,
3035        boot,
3036        kargs,
3037        skip_finalize,
3038    };
3039
3040    install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?;
3041
3042    // Drop all data about the root except the path to ensure any file descriptors etc. are closed.
3043    drop(rootfs);
3044
3045    installation_complete();
3046
3047    Ok(())
3048}
3049
3050pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> Result<()> {
3051    // Log the existing root installation operation to systemd journal
3052    const INSTALL_EXISTING_ROOT_JOURNAL_ID: &str = "7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1";
3053    let source_image = opts
3054        .source_opts
3055        .source_imgref
3056        .as_ref()
3057        .map(|s| s.as_str())
3058        .unwrap_or("none");
3059    let target_path = opts.root_path.as_str();
3060
3061    tracing::info!(
3062        message_id = INSTALL_EXISTING_ROOT_JOURNAL_ID,
3063        bootc.source_image = source_image,
3064        bootc.target_path = target_path,
3065        bootc.cleanup = if opts.cleanup {
3066            "trigger_on_next_boot"
3067        } else {
3068            "skip"
3069        },
3070        "Starting installation to existing root from {} to {}",
3071        source_image,
3072        target_path
3073    );
3074
3075    let cleanup = match opts.cleanup {
3076        true => Cleanup::TriggerOnNextBoot,
3077        false => Cleanup::Skip,
3078    };
3079
3080    let opts = InstallToFilesystemOpts {
3081        filesystem_opts: InstallTargetFilesystemOpts {
3082            root_path: opts.root_path,
3083            root_mount_spec: None,
3084            boot_mount_spec: None,
3085            replace: opts.replace,
3086            skip_finalize: true,
3087            acknowledge_destructive: opts.acknowledge_destructive,
3088        },
3089        source_opts: opts.source_opts,
3090        target_opts: opts.target_opts,
3091        config_opts: opts.config_opts,
3092        composefs_opts: opts.composefs_opts,
3093    };
3094
3095    install_to_filesystem(opts, true, cleanup).await
3096}
3097
3098/// Read the /boot entry from /etc/fstab, if it exists
3099fn read_boot_fstab_entry(root: &Dir) -> Result<Option<MountSpec>> {
3100    let fstab_path = "etc/fstab";
3101    let fstab = match root.open_optional(fstab_path)? {
3102        Some(f) => f,
3103        None => return Ok(None),
3104    };
3105
3106    let reader = std::io::BufReader::new(fstab);
3107    for line in std::io::BufRead::lines(reader) {
3108        let line = line?;
3109        let line = line.trim();
3110
3111        // Skip empty lines and comments
3112        if line.is_empty() || line.starts_with('#') {
3113            continue;
3114        }
3115
3116        // Parse the mount spec
3117        let spec = MountSpec::from_str(line)?;
3118
3119        // Check if this is a /boot entry
3120        if spec.target == format!("/{BOOT}") {
3121            return Ok(Some(spec));
3122        }
3123    }
3124
3125    Ok(None)
3126}
3127
3128pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> {
3129    let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
3130    if !opts.experimental {
3131        anyhow::bail!("This command requires --experimental");
3132    }
3133
3134    let prog: ProgressWriter = opts.progress.try_into()?;
3135
3136    let sysroot = &crate::cli::get_storage().await?;
3137    let ostree = sysroot.get_ostree()?;
3138    let repo = &ostree.repo();
3139    let (booted_ostree, _deployments, host) = crate::status::get_status_require_booted(ostree)?;
3140
3141    let stateroots = list_stateroots(ostree)?;
3142    let target_stateroot = if let Some(s) = opts.stateroot {
3143        s
3144    } else {
3145        let now = chrono::Utc::now();
3146        let r = allocate_new_stateroot(&ostree, &stateroots, now)?;
3147        r.name
3148    };
3149
3150    let booted_stateroot = booted_ostree.stateroot();
3151    assert!(booted_stateroot.as_str() != target_stateroot);
3152    let (fetched, spec) = if let Some(target) = opts.target_opts.imageref()? {
3153        let mut new_spec = host.spec;
3154        new_spec.image = Some(target.into());
3155        let fetched = crate::deploy::pull(
3156            repo,
3157            &new_spec.image.as_ref().unwrap(),
3158            None,
3159            opts.quiet,
3160            prog.clone(),
3161            None,
3162        )
3163        .await?;
3164        (fetched, new_spec)
3165    } else {
3166        let imgstate = host
3167            .status
3168            .booted
3169            .map(|b| b.query_image(repo))
3170            .transpose()?
3171            .flatten()
3172            .ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
3173        (Box::new((*imgstate).into()), host.spec)
3174    };
3175    let spec = crate::deploy::RequiredHostSpec::from_spec(&spec)?;
3176
3177    // Compute the kernel arguments to inherit. By default, that's only those involved
3178    // in the root filesystem.
3179    let mut kargs = crate::bootc_kargs::get_kargs_in_root(rootfs, std::env::consts::ARCH)?;
3180
3181    // Extend with root kargs
3182    if !opts.no_root_kargs {
3183        let bootcfg = booted_ostree
3184            .deployment
3185            .bootconfig()
3186            .ok_or_else(|| anyhow!("Missing bootcfg for booted deployment"))?;
3187        if let Some(options) = bootcfg.get("options") {
3188            let options_cmdline = Cmdline::from(options.as_str());
3189            let root_kargs = crate::bootc_kargs::root_args_from_cmdline(&options_cmdline);
3190            kargs.extend(&root_kargs);
3191        }
3192    }
3193
3194    // Extend with user-provided kargs
3195    if let Some(user_kargs) = opts.karg.as_ref() {
3196        for karg in user_kargs {
3197            kargs.extend(karg);
3198        }
3199    }
3200
3201    let from = MergeState::Reset {
3202        stateroot: target_stateroot.clone(),
3203        kargs,
3204    };
3205    crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone(), false).await?;
3206
3207    // Copy /boot entry from /etc/fstab to the new stateroot if it exists
3208    if let Some(boot_spec) = read_boot_fstab_entry(rootfs)? {
3209        let staged_deployment = ostree
3210            .staged_deployment()
3211            .ok_or_else(|| anyhow!("No staged deployment found"))?;
3212        let deployment_path = ostree.deployment_dirpath(&staged_deployment);
3213        let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
3214        let deployment_root = sysroot_dir.open_dir(&deployment_path)?;
3215
3216        // Write the /boot entry to /etc/fstab in the new deployment
3217        crate::lsm::atomic_replace_labeled(
3218            &deployment_root,
3219            "etc/fstab",
3220            0o644.into(),
3221            None,
3222            |w| writeln!(w, "{}", boot_spec.to_fstab()).map_err(Into::into),
3223        )?;
3224
3225        tracing::debug!(
3226            "Copied /boot entry to new stateroot: {}",
3227            boot_spec.to_fstab()
3228        );
3229    }
3230
3231    sysroot.update_mtime()?;
3232
3233    if opts.apply {
3234        crate::reboot::reboot()?;
3235    }
3236    Ok(())
3237}
3238
3239/// Implementation of `bootc install finalize`.
3240pub(crate) async fn install_finalize(target: &Utf8Path) -> Result<()> {
3241    // Log the installation finalization operation to systemd journal
3242    const INSTALL_FINALIZE_JOURNAL_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
3243
3244    tracing::info!(
3245        message_id = INSTALL_FINALIZE_JOURNAL_ID,
3246        bootc.target_path = target.as_str(),
3247        "Starting installation finalization for target: {}",
3248        target
3249    );
3250
3251    crate::cli::require_root(false)?;
3252    let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(target)));
3253    sysroot.load(gio::Cancellable::NONE)?;
3254    let deployments = sysroot.deployments();
3255    // Verify we find a deployment
3256    if deployments.is_empty() {
3257        anyhow::bail!("Failed to find deployment in {target}");
3258    }
3259
3260    // Log successful finalization
3261    tracing::info!(
3262        message_id = INSTALL_FINALIZE_JOURNAL_ID,
3263        bootc.target_path = target.as_str(),
3264        "Successfully finalized installation for target: {}",
3265        target
3266    );
3267
3268    // For now that's it! We expect to add more validation/postprocessing
3269    // later, such as munging `etc/fstab` if needed. See
3270
3271    Ok(())
3272}
3273
3274#[cfg(test)]
3275mod tests {
3276    use super::*;
3277
3278    #[test]
3279    fn test_composefs_opts_validate() {
3280        let addon = || Some(vec!["addon".to_string()]);
3281        // (composefs_backend, allow_missing_verity, uki_addon, bootloader, stateroot, valid)
3282        let cases = [
3283            (false, false, None, None, None, true),
3284            (false, false, None, Some(Bootloader::None), None, true),
3285            (false, true, None, None, None, false),
3286            (false, false, addon(), None, None, false),
3287            (false, false, None, None, Some("myroot"), true),
3288            (true, false, None, None, None, true),
3289            (true, true, addon(), Some(Bootloader::Systemd), None, true),
3290            (true, false, None, Some(Bootloader::None), None, true),
3291            (true, false, None, Some(Bootloader::Ukiboot), None, true),
3292            (false, false, None, Some(Bootloader::Ukiboot), None, false),
3293            (true, false, None, None, Some("default"), true),
3294            (true, false, None, None, Some("myroot"), false),
3295        ];
3296        for (composefs_backend, allow_missing_verity, uki_addon, bootloader, stateroot, valid) in
3297            cases
3298        {
3299            let opts = InstallComposefsOpts {
3300                composefs_backend,
3301                allow_missing_verity,
3302                uki_addon_opts: UkiAddonOpts {
3303                    scoped: uki_addon,
3304                    global: None,
3305                },
3306            };
3307            assert_eq!(
3308                opts.validate(bootloader.as_ref(), stateroot, None).is_ok(),
3309                valid,
3310                "{opts:?} {bootloader:?} {stateroot:?}"
3311            );
3312        }
3313
3314        let opts = InstallComposefsOpts {
3315            composefs_backend: true,
3316            ..Default::default()
3317        };
3318        assert_eq!(
3319            opts.validate(None, Some("myroot"), None)
3320                .unwrap_err()
3321                .to_string(),
3322            "--stateroot myroot is not supported with the composefs backend, which only uses default"
3323        );
3324
3325        assert!(opts.validate(None, None, Some("default")).is_ok());
3326        assert!(
3327            InstallComposefsOpts::default()
3328                .validate(None, None, Some("myroot"))
3329                .is_ok()
3330        );
3331        assert_eq!(
3332            opts.validate(None, None, Some("myroot"))
3333                .unwrap_err()
3334                .to_string(),
3335            "stateroot myroot in the install configuration is not supported with the composefs backend, which only uses default"
3336        );
3337    }
3338
3339    #[test]
3340    fn test_select_bootloader() -> Result<()> {
3341        let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
3342        assert_eq!(select_bootloader(None, None, &root)?, Bootloader::Systemd);
3343        assert_eq!(
3344            select_bootloader(None, Some(Bootloader::None), &root)?,
3345            Bootloader::None
3346        );
3347        assert_eq!(
3348            select_bootloader(None, Some(Bootloader::Ukiboot), &root)?,
3349            Bootloader::Ukiboot
3350        );
3351        assert_eq!(
3352            select_bootloader(Some(Bootloader::Grub), Some(Bootloader::None), &root)?,
3353            Bootloader::Grub
3354        );
3355        Ok(())
3356    }
3357
3358    #[test]
3359    #[cfg(feature = "install-to-disk")]
3360    fn install_opts_serializable() {
3361        let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
3362            "device": "/dev/vda"
3363        }))
3364        .unwrap();
3365        assert_eq!(c.block_opts.device, "/dev/vda");
3366    }
3367
3368    #[test]
3369    fn test_composefs_opts_validate_bound_images() -> Result<()> {
3370        use crate::boundimage::BOUND_IMAGE_DIR;
3371        let unbound = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3372        unbound.create_dir_all(BOUND_IMAGE_DIR)?;
3373        let bound = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3374        bound.create_dir_all("usr/share/containers/systemd")?;
3375        bound.write(
3376            "usr/share/containers/systemd/app.image",
3377            "[Image]\nImage=quay.io/example/app:latest\n",
3378        )?;
3379        bound.create_dir_all(BOUND_IMAGE_DIR)?;
3380        bound.symlink_contents(
3381            "/usr/share/containers/systemd/app.image",
3382            format!("{BOUND_IMAGE_DIR}/app.image"),
3383        )?;
3384
3385        // (composefs_backend, bound_images, root binds an image, valid)
3386        let cases = [
3387            (false, BoundImagesOpt::Stored, true, true),
3388            (true, BoundImagesOpt::Stored, false, true),
3389            (true, BoundImagesOpt::Stored, true, false),
3390            (true, BoundImagesOpt::Pull, true, false),
3391            (true, BoundImagesOpt::Skip, true, true),
3392        ];
3393        for (composefs_backend, bound_images, binds, valid) in cases {
3394            let opts = InstallComposefsOpts {
3395                composefs_backend,
3396                ..Default::default()
3397            };
3398            let root = if binds { &bound } else { &unbound };
3399            assert_eq!(
3400                opts.validate_bound_images(bound_images, root).is_ok(),
3401                valid,
3402                "{opts:?} {bound_images:?} {binds}"
3403            );
3404        }
3405
3406        let opts = InstallComposefsOpts {
3407            composefs_backend: true,
3408            ..Default::default()
3409        };
3410        assert_eq!(
3411            opts.validate_bound_images(BoundImagesOpt::Stored, &bound)
3412                .unwrap_err()
3413                .to_string(),
3414            "Logically bound images are not supported with the composefs backend (found quay.io/example/app:latest); use --bound-images skip to install without them"
3415        );
3416        Ok(())
3417    }
3418
3419    #[test]
3420    fn source_fetch_reference_uses_config_id_for_host_images() {
3421        let cases = [
3422            (
3423                "containers-storage:localhost/os:latest",
3424                Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
3425                "containers-storage:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
3426            ),
3427            (
3428                "docker://quay.io/example/os:latest",
3429                None,
3430                "docker://quay.io/example/os:latest",
3431            ),
3432            ("oci:/tmp/image", None, "oci:/tmp/image"),
3433        ];
3434        for (original, config_id, expected) in cases {
3435            let original = original.parse().unwrap();
3436            let fetched = composefs_fetch_reference(&original, config_id);
3437            assert_eq!(fetched.to_string(), expected);
3438        }
3439    }
3440
3441    #[test]
3442    fn test_mountspec() {
3443        let mut ms = MountSpec::new("/dev/vda4", "/boot");
3444        assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto defaults 0 0");
3445        ms.push_option("ro");
3446        assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto ro 0 0");
3447        ms.push_option("relatime");
3448        assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto ro,relatime 0 0");
3449    }
3450
3451    #[test]
3452    fn test_gather_root_args() {
3453        // A basic filesystem using a UUID
3454        let inspect = Filesystem {
3455            source: "/dev/vda4".into(),
3456            target: "/".into(),
3457            fstype: "xfs".into(),
3458            maj_min: "252:4".into(),
3459            options: "rw".into(),
3460            uuid: Some("965eb3c7-5a3f-470d-aaa2-1bcf04334bc6".into()),
3461            children: None,
3462        };
3463        let kargs = bytes::Cmdline::from("");
3464        let r = find_root_args_to_inherit(&kargs, &inspect).unwrap();
3465        assert_eq!(r.mount_spec, "UUID=965eb3c7-5a3f-470d-aaa2-1bcf04334bc6");
3466
3467        let kargs = bytes::Cmdline::from(
3468            "root=/dev/mapper/root rw someother=karg rd.lvm.lv=root systemd.debug=1",
3469        );
3470
3471        // In this case we take the root= from the kernel cmdline
3472        let r = find_root_args_to_inherit(&kargs, &inspect).unwrap();
3473        assert_eq!(r.mount_spec, "/dev/mapper/root");
3474        assert_eq!(r.kargs.len(), 1);
3475        assert_eq!(r.kargs[0], "rd.lvm.lv=root");
3476
3477        // non-UTF8 data in non-essential parts of the cmdline should be ignored
3478        let kargs = bytes::Cmdline::from(
3479            b"root=/dev/mapper/root rw non-utf8=\xff rd.lvm.lv=root systemd.debug=1",
3480        );
3481        let r = find_root_args_to_inherit(&kargs, &inspect).unwrap();
3482        assert_eq!(r.mount_spec, "/dev/mapper/root");
3483        assert_eq!(r.kargs.len(), 1);
3484        assert_eq!(r.kargs[0], "rd.lvm.lv=root");
3485
3486        // non-UTF8 data in `root` should fail
3487        let kargs = bytes::Cmdline::from(
3488            b"root=/dev/mapper/ro\xffot rw non-utf8=\xff rd.lvm.lv=root systemd.debug=1",
3489        );
3490        let r = find_root_args_to_inherit(&kargs, &inspect);
3491        assert!(r.is_err());
3492
3493        // non-UTF8 data in `rd.` should fail
3494        let kargs = bytes::Cmdline::from(
3495            b"root=/dev/mapper/root rw non-utf8=\xff rd.lvm.lv=ro\xffot systemd.debug=1",
3496        );
3497        let r = find_root_args_to_inherit(&kargs, &inspect);
3498        assert!(r.is_err());
3499    }
3500
3501    // As this is a unit test we don't try to test mountpoints, just verify
3502    // that we have the equivalent of rm -rf *
3503    #[test]
3504    fn test_remove_all_noxdev() -> Result<()> {
3505        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3506
3507        td.create_dir_all("foo/bar/baz")?;
3508        td.write("foo/bar/baz/test", b"sometest")?;
3509        td.symlink_contents("/absolute-nonexistent-link", "somelink")?;
3510        td.write("toptestfile", b"othertestcontents")?;
3511
3512        remove_all_in_dir_no_xdev(&td, true).unwrap();
3513
3514        assert_eq!(td.entries()?.count(), 0);
3515
3516        Ok(())
3517    }
3518
3519    #[test]
3520    fn test_clean_esp_bootloader_dirs() -> Result<()> {
3521        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3522
3523        td.create_dir_all("EFI/BOOT")?;
3524        td.write("EFI/BOOT/BOOTAA64.EFI", b"shim")?;
3525        td.create_dir_all("loader/entries")?;
3526        td.write("loader/entries/foo.conf", b"entry")?;
3527        // Asahi content which must survive
3528        td.create_dir_all("m1n1")?;
3529        td.write("m1n1/boot.bin", b"m1n1")?;
3530        td.write("ubootefi.var", b"efivars")?;
3531
3532        clean_esp_bootloader_dirs(&td)?;
3533        assert!(!td.exists("EFI"));
3534        assert!(!td.exists("loader"));
3535        assert_eq!(td.read("m1n1/boot.bin")?, b"m1n1");
3536        assert_eq!(td.read("ubootefi.var")?, b"efivars");
3537
3538        // Idempotent
3539        clean_esp_bootloader_dirs(&td)?;
3540        assert_eq!(td.entries()?.count(), 2);
3541
3542        Ok(())
3543    }
3544
3545    #[test]
3546    fn test_read_boot_fstab_entry() -> Result<()> {
3547        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3548
3549        // Test with no /etc/fstab
3550        assert!(read_boot_fstab_entry(&td)?.is_none());
3551
3552        // Test with /etc/fstab but no /boot entry
3553        td.create_dir("etc")?;
3554        td.write("etc/fstab", "UUID=test-uuid / ext4 defaults 0 0\n")?;
3555        assert!(read_boot_fstab_entry(&td)?.is_none());
3556
3557        // Test with /boot entry
3558        let fstab_content = "\
3559# /etc/fstab
3560UUID=root-uuid / ext4 defaults 0 0
3561UUID=boot-uuid /boot ext4 ro 0 0
3562UUID=home-uuid /home ext4 defaults 0 0
3563";
3564        td.write("etc/fstab", fstab_content)?;
3565        let boot_spec = read_boot_fstab_entry(&td)?.unwrap();
3566        assert_eq!(boot_spec.source, "UUID=boot-uuid");
3567        assert_eq!(boot_spec.target, "/boot");
3568        assert_eq!(boot_spec.fstype, "ext4");
3569        assert_eq!(boot_spec.options, Some("ro".to_string()));
3570
3571        // Test with /boot entry with comments
3572        let fstab_content = "\
3573# /etc/fstab
3574# Created by anaconda
3575UUID=root-uuid / ext4 defaults 0 0
3576# Boot partition
3577UUID=boot-uuid /boot ext4 defaults 0 0
3578";
3579        td.write("etc/fstab", fstab_content)?;
3580        let boot_spec = read_boot_fstab_entry(&td)?.unwrap();
3581        assert_eq!(boot_spec.source, "UUID=boot-uuid");
3582        assert_eq!(boot_spec.target, "/boot");
3583
3584        Ok(())
3585    }
3586
3587    #[test]
3588    fn test_require_dir_contains_only_mounts() -> Result<()> {
3589        // Test 1: Empty directory should fail (not a mount point)
3590        {
3591            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3592            td.create_dir("empty")?;
3593            assert!(require_dir_contains_only_mounts(&td, "empty").is_err());
3594        }
3595
3596        // Test 2: Directory with only lost+found should succeed (lost+found is ignored)
3597        {
3598            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3599            td.create_dir_all("var/lost+found")?;
3600            assert!(require_dir_contains_only_mounts(&td, "var").is_ok());
3601        }
3602
3603        // Test 3: Directory with a regular file should fail
3604        {
3605            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3606            td.create_dir("var")?;
3607            td.write("var/test.txt", b"content")?;
3608            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3609        }
3610
3611        // Test 4: Nested directory structure with a file should fail
3612        {
3613            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3614            td.create_dir_all("var/lib/containers")?;
3615            td.write("var/lib/containers/storage.db", b"data")?;
3616            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3617        }
3618
3619        // Test 5: boot directory with grub should fail (grub2 is not a mount and contains files)
3620        {
3621            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3622            td.create_dir_all("boot/grub2")?;
3623            td.write("boot/grub2/grub.cfg", b"config")?;
3624            assert!(require_dir_contains_only_mounts(&td, "boot").is_err());
3625        }
3626
3627        // Test 6: Nested empty directories should fail (empty directories are not mount points)
3628        {
3629            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3630            td.create_dir_all("var/lib/containers")?;
3631            td.create_dir_all("var/log/journal")?;
3632            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3633        }
3634
3635        // Test 7: Directory with lost+found and a file should fail (lost+found is ignored, but file is not allowed)
3636        {
3637            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3638            td.create_dir_all("var/lost+found")?;
3639            td.write("var/data.txt", b"content")?;
3640            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3641        }
3642
3643        // Test 8: Directory with a symlink should fail
3644        {
3645            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3646            td.create_dir("var")?;
3647            td.symlink_contents("../usr/lib", "var/lib")?;
3648            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3649        }
3650
3651        // Test 9: Deeply nested directory with a file should fail
3652        {
3653            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3654            td.create_dir_all("var/lib/containers/storage/overlay")?;
3655            td.write("var/lib/containers/storage/overlay/file.txt", b"data")?;
3656            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3657        }
3658
3659        Ok(())
3660    }
3661
3662    #[test]
3663    fn test_delete_kargs() -> Result<()> {
3664        let mut cmdline = Cmdline::from("console=tty0 quiet debug nosmt foo=bar foo=baz bar=baz");
3665
3666        let deletions = vec!["foo=bar", "bar", "debug"];
3667
3668        delete_kargs(&mut cmdline, &deletions);
3669
3670        let result = cmdline.to_string();
3671        assert!(!result.contains("foo=bar"));
3672        assert!(!result.contains("bar"));
3673        assert!(!result.contains("debug"));
3674        assert!(result.contains("foo=baz"));
3675
3676        Ok(())
3677    }
3678}