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