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