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