bootc_lib/
install.rs

1//! # Writing a container to a block device in a bootable way
2//!
3//! This module implements the core installation logic for bootc, enabling a container
4//! image to be written to storage in a bootable form. It bridges the gap between
5//! OCI container images and traditional bootable Linux systems.
6//!
7//! ## Overview
8//!
9//! The installation process transforms a container image into a bootable system by:
10//!
11//! 1. **Preparing the environment**: Validating we're running in a privileged container,
12//!    handling SELinux re-execution if needed, and loading configuration.
13//!
14//! 2. **Setting up storage**: Either creating partitions (`to-disk`) or using
15//!    externally-prepared filesystems (`to-filesystem`).
16//!
17//! 3. **Deploying the image**: Pulling the container image into an ostree repository
18//!    and creating a deployment, or setting up a composefs-based root.
19//!
20//! 4. **Installing the bootloader**: Using bootupd, systemd-boot, or zipl depending
21//!    on architecture and configuration.
22//!
23//! 5. **Finalizing**: Trimming the filesystem, flushing writes, and freezing/thawing
24//!    the journal.
25//!
26//! ## Installation Modes
27//!
28//! ### `bootc install to-disk`
29//!
30//! Creates a complete bootable system on a block device. This is the simplest path
31//! and handles partitioning automatically using the Discoverable Partitions
32//! Specification (DPS). The partition layout includes:
33//!
34//! - **ESP** (EFI System Partition): Required for UEFI boot
35//! - **BIOS boot partition**: For legacy boot on x86_64
36//! - **Boot partition**: Optional, used when LUKS encryption is enabled
37//! - **Root partition**: Uses architecture-specific DPS type GUIDs for auto-discovery
38//!
39//! ### `bootc install to-filesystem`
40//!
41//! Installs to a pre-mounted filesystem, allowing external tools to handle complex
42//! storage layouts (RAID, LVM, custom LUKS configurations). The caller is responsible
43//! for creating and mounting the filesystem, then providing appropriate `--karg`
44//! options or mount specifications.
45//!
46//! ### `bootc install to-existing-root`
47//!
48//! "Alongside" installation mode that converts an existing Linux system. The boot
49//! partition is wiped and replaced, but the root filesystem content is preserved
50//! until reboot. Post-reboot, the old system is accessible at `/sysroot` for
51//! data migration.
52//!
53//! ### `bootc install reset`
54//!
55//! Creates a new stateroot within an existing bootc system, effectively providing
56//! a factory-reset capability without touching other stateroots.
57//!
58//! ## Storage Backends
59//!
60//! ### OSTree Backend (Default)
61//!
62//! Uses ostree-ext to convert container layers into an ostree repository. The
63//! deployment is created via `ostree admin deploy`, and bootloader entries are
64//! managed via BLS (Boot Loader Specification) files.
65//!
66//! ### Composefs Backend (Experimental)
67//!
68//! Alternative backend using composefs overlayfs for the root filesystem. Provides
69//! stronger integrity guarantees via fs-verity and supports UKI (Unified Kernel
70//! Images) for measured boot scenarios.
71//!
72//! ## Discoverable Partitions Specification (DPS)
73//!
74//! As of bootc 1.11, partitions are created with DPS type GUIDs from the
75//! [UAPI Group specification](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/).
76//! This enables:
77//!
78//! - **Auto-discovery**: systemd-gpt-auto-generator can mount partitions without
79//!   explicit configuration
80//! - **Architecture awareness**: Root partition types are architecture-specific,
81//!   preventing cross-architecture boot issues
82//! - **Future extensibility**: Enables systemd-repart for declarative partition
83//!   management
84//!
85//! See [`crate::discoverable_partition_specification`] for the partition type GUIDs.
86//!
87//! ## Installation Flow
88//!
89//! The high-level flow is:
90//!
91//! 1. **CLI entry** → [`install_to_disk`], [`install_to_filesystem`], or [`install_to_existing_root`]
92//! 2. **Preparation** → [`prepare_install`] validates environment, handles SELinux, loads config
93//! 3. **Storage setup** → (to-disk only) [`baseline::install_create_rootfs`] partitions and formats
94//! 4. **Deployment** → [`install_to_filesystem_impl`] branches to OSTree or Composefs backend
95//! 5. **Bootloader** → [`crate::bootloader::install_via_bootupd`] or architecture-specific installer
96//! 6. **Finalization** → [`finalize_filesystem`] trims, flushes, and freezes the filesystem
97//!
98//! For a visual diagram of this flow, see the bootc documentation.
99//!
100//! ## Key Types
101//!
102//! - [`State`]: Immutable global state for the installation, including source image
103//!   info, SELinux state, configuration, and composefs options.
104//!
105//! - [`RootSetup`]: Represents the prepared root filesystem, including mount paths,
106//!   device information, boot partition specs, and kernel arguments.
107//!
108//! - [`SourceInfo`]: Information about the source container image, including the
109//!   ostree-container reference and whether SELinux labels are present.
110//!
111//! - [`SELinuxFinalState`]: Tracks SELinux handling during installation (enabled,
112//!   disabled, host-disabled, or force-disabled).
113//!
114//! ## Configuration
115//!
116//! Installation is configured via TOML files loaded from multiple paths in
117//! systemd-style priority order:
118//!
119//! - `/usr/lib/bootc/install/*.toml` - Distribution/image defaults
120//! - `/etc/bootc/install/*.toml` - Local overrides
121//!
122//! Files are merged alphanumerically, with higher-numbered files taking precedence.
123//! See [`config::InstallConfiguration`] for the schema.
124//!
125//! Key configurable options include:
126//! - Root filesystem type (xfs, ext4, btrfs)
127//! - Allowed block setups (direct, tpm2-luks)
128//! - Default kernel arguments
129//! - Architecture-specific overrides
130//!
131//! ## Submodules
132//!
133//! - [`baseline`]: The "baseline" installer for simple partitioning (to-disk)
134//! - [`config`]: TOML configuration parsing and merging
135//! - [`completion`]: Post-installation hooks for external installers (Anaconda)
136//! - [`osconfig`]: SSH key injection and OS configuration
137//! - [`aleph`]: Installation provenance tracking (.bootc-aleph.json)
138//! - `osbuild`: Helper APIs for bootc-image-builder integration
139
140// This sub-module is the "basic" installer that handles creating basic block device
141// and filesystem setup.
142mod aleph;
143#[cfg(feature = "install-to-disk")]
144pub(crate) mod baseline;
145pub(crate) mod completion;
146pub(crate) mod config;
147mod osbuild;
148pub(crate) mod osconfig;
149
150use std::collections::HashMap;
151use std::io::Write;
152use std::os::fd::{AsFd, AsRawFd};
153use std::os::unix::process::CommandExt;
154use std::path::Path;
155use std::process;
156use std::process::Command;
157use std::str::FromStr;
158use std::sync::Arc;
159use std::time::Duration;
160
161use aleph::InstallAleph;
162use anyhow::{Context, Result, anyhow, ensure};
163use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
164use bootc_utils::CommandRunExt;
165use camino::Utf8Path;
166use camino::Utf8PathBuf;
167use canon_json::CanonJsonSerialize;
168use cap_std::fs::{Dir, MetadataExt};
169use cap_std_ext::cap_std;
170use cap_std_ext::cap_std::fs::FileType;
171use cap_std_ext::cap_std::fs_utf8::DirEntry as DirEntryUtf8;
172use cap_std_ext::cap_tempfile::TempDir;
173use cap_std_ext::cmdext::CapStdExtCommandExt;
174use cap_std_ext::prelude::CapStdExtDirExt;
175use clap::ValueEnum;
176use fn_error_context::context;
177use ostree::gio;
178use ostree_ext::ostree;
179use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate};
180use ostree_ext::prelude::Cast;
181use ostree_ext::sysroot::{SysrootLock, allocate_new_stateroot, list_stateroots};
182use ostree_ext::{container as ostree_container, ostree_prepareroot};
183#[cfg(feature = "install-to-disk")]
184use rustix::fs::FileTypeExt;
185use rustix::fs::MetadataExt as _;
186use serde::{Deserialize, Serialize};
187
188#[cfg(feature = "install-to-disk")]
189use self::baseline::InstallBlockDeviceOpts;
190use crate::bootc_composefs::status::ComposefsCmdline;
191use crate::bootc_composefs::{
192    boot::setup_composefs_boot,
193    repo::{get_imgref, initialize_composefs_repository, open_composefs_repo},
194    status::get_container_manifest_and_config,
195};
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_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8};
207use bootc_mount::Filesystem;
208use cfsctl::composefs;
209use composefs::fsverity::FsVerityHashValue;
210
211/// The toplevel boot directory
212pub(crate) const BOOT: &str = "boot";
213/// Directory for transient runtime state
214#[cfg(feature = "install-to-disk")]
215const RUN_BOOTC: &str = "/run/bootc";
216/// The default path for the host rootfs
217const ALONGSIDE_ROOT_MOUNT: &str = "/target";
218/// Global flag to signal the booted system was provisioned via an alongside bootc install
219pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup";
220/// This is an ext4 special directory we need to ignore.
221const LOST_AND_FOUND: &str = "lost+found";
222/// The filename of the composefs EROFS superblock; TODO move this into ostree
223const OSTREE_COMPOSEFS_SUPER: &str = ".ostree.cfs";
224/// The mount path for selinux
225const SELINUXFS: &str = "/sys/fs/selinux";
226/// The mount path for uefi
227pub(crate) const EFIVARFS: &str = "/sys/firmware/efi/efivars";
228pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64"));
229
230pub(crate) const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
231
232const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[
233    // Default to avoiding grub2-mkconfig etc.
234    ("sysroot.bootloader", "none"),
235    // Always flip this one on because we need to support alongside installs
236    // to systems without a separate boot partition.
237    ("sysroot.bootprefix", "true"),
238    ("sysroot.readonly", "true"),
239];
240
241/// Kernel argument used to specify we want the rootfs mounted read-write by default
242pub(crate) const RW_KARG: &str = "rw";
243
244#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub(crate) struct InstallTargetOpts {
246    // TODO: A size specifier which allocates free space for the root in *addition* to the base container image size
247    // pub(crate) root_additional_size: Option<String>
248    /// The transport; e.g. oci, oci-archive, containers-storage.  Defaults to `registry`.
249    #[clap(long, default_value = "registry")]
250    #[serde(default)]
251    pub(crate) target_transport: String,
252
253    /// Specify the image to fetch for subsequent updates
254    #[clap(long)]
255    pub(crate) target_imgref: Option<String>,
256
257    /// This command line argument does nothing; it exists for compatibility.
258    ///
259    /// As of newer versions of bootc, this value is enabled by default,
260    /// i.e. it is not enforced that a signature
261    /// verification policy is enabled.  Hence to enable it, one can specify
262    /// `--target-no-signature-verification=false`.
263    ///
264    /// It is likely that the functionality here will be replaced with a different signature
265    /// enforcement scheme in the future that integrates with `podman`.
266    #[clap(long, hide = true)]
267    #[serde(default)]
268    pub(crate) target_no_signature_verification: bool,
269
270    /// This is the inverse of the previous `--target-no-signature-verification` (which is now
271    /// a no-op).  Enabling this option enforces that `/etc/containers/policy.json` includes a
272    /// default policy which 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
1297pub(crate) struct RootSetup {
1298    #[cfg(feature = "install-to-disk")]
1299    luks_device: Option<String>,
1300    pub(crate) device_info: bootc_blockdev::Device,
1301    /// Absolute path to the location where we've mounted the physical
1302    /// root filesystem for the system we're installing.
1303    pub(crate) physical_root_path: Utf8PathBuf,
1304    /// Directory file descriptor for the above physical root.
1305    pub(crate) physical_root: Dir,
1306    /// Target root path /target.
1307    pub(crate) target_root_path: Option<Utf8PathBuf>,
1308    pub(crate) rootfs_uuid: Option<String>,
1309    /// True if we should skip finalizing
1310    skip_finalize: bool,
1311    boot: Option<MountSpec>,
1312    pub(crate) kargs: CmdlineOwned,
1313}
1314
1315fn require_boot_uuid(spec: &MountSpec) -> Result<&str> {
1316    spec.get_source_uuid()
1317        .ok_or_else(|| anyhow!("/boot is not specified via UUID= (this is currently required)"))
1318}
1319
1320impl RootSetup {
1321    /// Get the UUID= mount specifier for the /boot filesystem; if there isn't one, the root UUID will
1322    /// be returned.
1323    pub(crate) fn get_boot_uuid(&self) -> Result<Option<&str>> {
1324        self.boot.as_ref().map(require_boot_uuid).transpose()
1325    }
1326
1327    /// Get the boot mount spec, if a separate /boot partition exists.
1328    pub(crate) fn boot_mount_spec(&self) -> Option<&MountSpec> {
1329        self.boot.as_ref()
1330    }
1331
1332    // Drop any open file descriptors and return just the mount path and backing luks device, if any
1333    #[cfg(feature = "install-to-disk")]
1334    fn into_storage(self) -> (Utf8PathBuf, Option<String>) {
1335        (self.physical_root_path, self.luks_device)
1336    }
1337}
1338
1339#[derive(Debug)]
1340#[allow(dead_code)]
1341pub(crate) enum SELinuxFinalState {
1342    /// Host and target both have SELinux, but user forced it off for target
1343    ForceTargetDisabled,
1344    /// Host and target both have SELinux
1345    Enabled(Option<crate::lsm::SetEnforceGuard>),
1346    /// Host has SELinux disabled, target is enabled.
1347    HostDisabled,
1348    /// Neither host or target have SELinux
1349    Disabled,
1350}
1351
1352impl SELinuxFinalState {
1353    /// Returns true if the target system will have SELinux enabled.
1354    pub(crate) fn enabled(&self) -> bool {
1355        match self {
1356            SELinuxFinalState::ForceTargetDisabled | SELinuxFinalState::Disabled => false,
1357            SELinuxFinalState::Enabled(_) | SELinuxFinalState::HostDisabled => true,
1358        }
1359    }
1360
1361    /// Returns the canonical stringified version of self.  This is only used
1362    /// for debugging purposes.
1363    pub(crate) fn to_aleph(&self) -> &'static str {
1364        match self {
1365            SELinuxFinalState::ForceTargetDisabled => "force-target-disabled",
1366            SELinuxFinalState::Enabled(_) => "enabled",
1367            SELinuxFinalState::HostDisabled => "host-disabled",
1368            SELinuxFinalState::Disabled => "disabled",
1369        }
1370    }
1371}
1372
1373/// If we detect that the target ostree commit has SELinux labels,
1374/// and we aren't passed an override to disable it, then ensure
1375/// the running process is labeled with install_t so it can
1376/// write arbitrary labels.
1377pub(crate) fn reexecute_self_for_selinux_if_needed(
1378    srcdata: &SourceInfo,
1379    override_disable_selinux: bool,
1380) -> Result<SELinuxFinalState> {
1381    // If the target state has SELinux enabled, we need to check the host state.
1382    if srcdata.selinux {
1383        let host_selinux = crate::lsm::selinux_enabled()?;
1384        tracing::debug!("Target has SELinux, host={host_selinux}");
1385        let r = if override_disable_selinux {
1386            println!("notice: Target has SELinux enabled, overriding to disable");
1387            SELinuxFinalState::ForceTargetDisabled
1388        } else if host_selinux {
1389            // /sys/fs/selinuxfs is not normally mounted, so we do that now.
1390            // Because SELinux enablement status is cached process-wide and was very likely
1391            // already queried by something else (e.g. glib's constructor), we would also need
1392            // to re-exec.  But, selinux_ensure_install does that unconditionally right now too,
1393            // so let's just fall through to that.
1394            setup_sys_mount("selinuxfs", SELINUXFS)?;
1395            // This will re-execute the current process (once).
1396            let g = crate::lsm::selinux_ensure_install_or_setenforce()?;
1397            SELinuxFinalState::Enabled(g)
1398        } else {
1399            SELinuxFinalState::HostDisabled
1400        };
1401        Ok(r)
1402    } else {
1403        Ok(SELinuxFinalState::Disabled)
1404    }
1405}
1406
1407/// Trim, flush outstanding writes, and freeze/thaw the target mounted filesystem;
1408/// these steps prepare the filesystem for its first booted use.
1409pub(crate) fn finalize_filesystem(
1410    fsname: &str,
1411    root: &Dir,
1412    path: impl AsRef<Utf8Path>,
1413) -> Result<()> {
1414    let path = path.as_ref();
1415    // fstrim ensures the underlying block device knows about unused space
1416    Task::new(format!("Trimming {fsname}"), "fstrim")
1417        .args(["--quiet-unsupported", "-v", path.as_str()])
1418        .cwd(root)?
1419        .run()?;
1420    // Remounting readonly will flush outstanding writes and ensure we error out if there were background
1421    // writeback problems.
1422    Task::new(format!("Finalizing filesystem {fsname}"), "mount")
1423        .cwd(root)?
1424        .args(["-o", "remount,ro", path.as_str()])
1425        .run()?;
1426    // Finally, freezing (and thawing) the filesystem will flush the journal, which means the next boot is clean.
1427    for a in ["-f", "-u"] {
1428        Command::new("fsfreeze")
1429            .cwd_dir(root.try_clone()?)
1430            .args([a, path.as_str()])
1431            .run_capture_stderr()?;
1432    }
1433    Ok(())
1434}
1435
1436/// A heuristic check that we were invoked with --pid=host
1437fn require_host_pidns() -> Result<()> {
1438    if rustix::process::getpid().is_init() {
1439        anyhow::bail!("This command must be run with the podman --pid=host flag")
1440    }
1441    tracing::trace!("OK: we're not pid 1");
1442    Ok(())
1443}
1444
1445/// Verify that we can access /proc/1, which will catch rootless podman (with --pid=host)
1446/// for example.
1447fn require_host_userns() -> Result<()> {
1448    let proc1 = "/proc/1";
1449    let pid1_uid = Path::new(proc1)
1450        .metadata()
1451        .with_context(|| format!("Querying {proc1}"))?
1452        .uid();
1453    // We must really be in a rootless container, or in some way
1454    // we're not part of the host user namespace.
1455    ensure!(
1456        pid1_uid == 0,
1457        "{proc1} is owned by {pid1_uid}, not zero; this command must be run in the root user namespace (e.g. not rootless podman)"
1458    );
1459    tracing::trace!("OK: we're in a matching user namespace with pid1");
1460    Ok(())
1461}
1462
1463/// Ensure that /tmp is a tmpfs because in some cases we might perform
1464/// operations which expect it (as it is on a proper host system).
1465/// Ideally we have people run this container via podman run --read-only-tmpfs
1466/// actually.
1467pub(crate) fn setup_tmp_mount() -> Result<()> {
1468    let st = rustix::fs::statfs("/tmp")?;
1469    if st.f_type == libc::TMPFS_MAGIC {
1470        tracing::trace!("Already have tmpfs /tmp")
1471    } else {
1472        // Note we explicitly also don't want a "nosuid" tmp, because that
1473        // suppresses our install_t transition
1474        Command::new("mount")
1475            .args(["tmpfs", "-t", "tmpfs", "/tmp"])
1476            .run_capture_stderr()?;
1477    }
1478    Ok(())
1479}
1480
1481/// By default, podman/docker etc. when passed `--privileged` mount `/sys` as read-only,
1482/// but non-recursively.  We selectively grab sub-filesystems that we need.
1483#[context("Ensuring sys mount {fspath} {fstype}")]
1484pub(crate) fn setup_sys_mount(fstype: &str, fspath: &str) -> Result<()> {
1485    tracing::debug!("Setting up sys mounts");
1486    let rootfs = format!("/proc/1/root/{fspath}");
1487    // Does mount point even exist in the host?
1488    if !Path::new(rootfs.as_str()).try_exists()? {
1489        return Ok(());
1490    }
1491
1492    // Now, let's find out if it's populated
1493    if std::fs::read_dir(rootfs)?.next().is_none() {
1494        return Ok(());
1495    }
1496
1497    // Check that the path that should be mounted is even populated.
1498    // Since we are dealing with /sys mounts here, if it's populated,
1499    // we can be at least a little certain that it's mounted.
1500    if Path::new(fspath).try_exists()? && std::fs::read_dir(fspath)?.next().is_some() {
1501        return Ok(());
1502    }
1503
1504    // This means the host has this mounted, so we should mount it too
1505    Command::new("mount")
1506        .args(["-t", fstype, fstype, fspath])
1507        .run_capture_stderr()?;
1508
1509    Ok(())
1510}
1511
1512/// Verify that we can load the manifest of the target image
1513#[context("Verifying fetch")]
1514async fn verify_target_fetch(
1515    tmpdir: &Dir,
1516    imgref: &ostree_container::OstreeImageReference,
1517) -> Result<()> {
1518    let tmpdir = &TempDir::new_in(&tmpdir)?;
1519    let tmprepo = &ostree::Repo::create_at_dir(tmpdir.as_fd(), ".", ostree::RepoMode::Bare, None)
1520        .context("Init tmp repo")?;
1521
1522    tracing::trace!("Verifying fetch for {imgref}");
1523    let mut imp =
1524        ostree_container::store::ImageImporter::new(tmprepo, imgref, Default::default()).await?;
1525    use ostree_container::store::PrepareResult;
1526    let prep = match imp.prepare().await? {
1527        // SAFETY: It's impossible that the image was already fetched into this newly created temporary repository
1528        PrepareResult::AlreadyPresent(_) => unreachable!(),
1529        PrepareResult::Ready(r) => r,
1530    };
1531    tracing::debug!("Fetched manifest with digest {}", prep.manifest_digest);
1532    Ok(())
1533}
1534
1535/// Preparation for an install; validates and prepares some (thereafter immutable) global state.
1536async fn prepare_install(
1537    mut config_opts: InstallConfigOpts,
1538    source_opts: InstallSourceOpts,
1539    target_opts: InstallTargetOpts,
1540    mut composefs_options: InstallComposefsOpts,
1541    target_fs: Option<FilesystemEnum>,
1542) -> Result<Arc<State>> {
1543    tracing::trace!("Preparing install");
1544    let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())
1545        .context("Opening /")?;
1546
1547    let host_is_container = crate::containerenv::is_container(&rootfs);
1548    let external_source = source_opts.source_imgref.is_some();
1549    let (source, target_rootfs) = match source_opts.source_imgref {
1550        None => {
1551            ensure!(
1552                host_is_container,
1553                "Either --source-imgref must be defined or this command must be executed inside a podman container."
1554            );
1555
1556            crate::cli::require_root(true)?;
1557
1558            require_host_pidns()?;
1559            // Out of conservatism we only verify the host userns path when we're expecting
1560            // to do a self-install (e.g. not bootc-image-builder or equivalent).
1561            require_host_userns()?;
1562            let container_info = crate::containerenv::get_container_execution_info(&rootfs)?;
1563            // This command currently *must* be run inside a privileged container.
1564            match container_info.rootless.as_deref() {
1565                Some("1") => anyhow::bail!(
1566                    "Cannot install from rootless podman; this command must be run as root"
1567                ),
1568                Some(o) => tracing::debug!("rootless={o}"),
1569                // This one shouldn't happen except on old podman
1570                None => tracing::debug!(
1571                    "notice: Did not find rootless= entry in {}",
1572                    crate::containerenv::PATH,
1573                ),
1574            };
1575            tracing::trace!("Read container engine info {:?}", container_info);
1576
1577            let source = SourceInfo::from_container(&rootfs, &container_info)?;
1578            (source, Some(rootfs.try_clone()?))
1579        }
1580        Some(source) => {
1581            crate::cli::require_root(false)?;
1582            let source = SourceInfo::from_imageref(&source, &rootfs)?;
1583            (source, None)
1584        }
1585    };
1586
1587    // Parse the target CLI image reference options and create the *target* image
1588    // reference, which defaults to pulling from a registry.
1589    if target_opts.target_no_signature_verification {
1590        // Perhaps log this in the future more prominently, but no reason to annoy people.
1591        tracing::debug!(
1592            "Use of --target-no-signature-verification flag which is enabled by default"
1593        );
1594    }
1595    let target_sigverify = sigpolicy_from_opt(target_opts.enforce_container_sigpolicy);
1596    let target_imgname = target_opts
1597        .target_imgref
1598        .as_deref()
1599        .unwrap_or(source.imageref.name.as_str());
1600    let target_transport =
1601        ostree_container::Transport::try_from(target_opts.target_transport.as_str())?;
1602    let target_imgref = ostree_container::OstreeImageReference {
1603        sigverify: target_sigverify,
1604        imgref: ostree_container::ImageReference {
1605            transport: target_transport,
1606            name: target_imgname.to_string(),
1607        },
1608    };
1609    tracing::debug!("Target image reference: {target_imgref}");
1610
1611    let (composefs_required, kernel) = if let Some(root) = target_rootfs.as_ref() {
1612        let kernel = crate::kernel::find_kernel(root)?;
1613
1614        (
1615            kernel.as_ref().map(|k| k.kernel.unified).unwrap_or(false),
1616            kernel,
1617        )
1618    } else {
1619        (false, None)
1620    };
1621
1622    tracing::debug!("Composefs required: {composefs_required}");
1623
1624    if composefs_required {
1625        composefs_options.composefs_backend = true;
1626    }
1627
1628    if composefs_options.composefs_backend
1629        && matches!(config_opts.bootloader, Some(Bootloader::None))
1630    {
1631        anyhow::bail!("Bootloader set to none is not supported with the composefs backend");
1632    }
1633
1634    // We need to access devices that are set up by the host udev
1635    bootc_mount::ensure_mirrored_host_mount("/dev")?;
1636    // We need to read our own container image (and any logically bound images)
1637    // from the host container store.
1638    bootc_mount::ensure_mirrored_host_mount("/var/lib/containers")?;
1639    // In some cases we may create large files, and it's better not to have those
1640    // in our overlayfs.
1641    bootc_mount::ensure_mirrored_host_mount("/var/tmp")?;
1642    // udev state is required for running lsblk during install to-disk
1643    // see https://github.com/bootc-dev/bootc/pull/688
1644    bootc_mount::ensure_mirrored_host_mount("/run/udev")?;
1645    // We also always want /tmp to be a proper tmpfs on general principle.
1646    setup_tmp_mount()?;
1647    // Allocate a temporary directory we can use in various places to avoid
1648    // creating multiple.
1649    let tempdir = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1650    // And continue to init global state
1651    osbuild::adjust_for_bootc_image_builder(&rootfs, &tempdir)?;
1652
1653    if target_opts.run_fetch_check {
1654        verify_target_fetch(&tempdir, &target_imgref).await?;
1655    }
1656
1657    // Even though we require running in a container, the mounts we create should be specific
1658    // to this process, so let's enter a private mountns to avoid leaking them.
1659    if !external_source && std::env::var_os("BOOTC_SKIP_UNSHARE").is_none() {
1660        super::cli::ensure_self_unshared_mount_namespace()?;
1661    }
1662
1663    setup_sys_mount("efivarfs", EFIVARFS)?;
1664
1665    // Now, deal with SELinux state.
1666    let selinux_state = reexecute_self_for_selinux_if_needed(&source, config_opts.disable_selinux)?;
1667    tracing::debug!("SELinux state: {selinux_state:?}");
1668
1669    println!("Installing image: {:#}", &target_imgref);
1670    if let Some(digest) = source.digest.as_deref() {
1671        println!("Digest: {digest}");
1672    }
1673
1674    let install_config = config::load_config()?;
1675    if let Some(ref config) = install_config {
1676        tracing::debug!("Loaded install configuration");
1677        // Merge config file values into config_opts (CLI takes precedence)
1678        // Only apply config file value if CLI didn't explicitly set it
1679        if !config_opts.bootupd_skip_boot_uuid {
1680            config_opts.bootupd_skip_boot_uuid = config
1681                .bootupd
1682                .as_ref()
1683                .and_then(|b| b.skip_boot_uuid)
1684                .unwrap_or(false);
1685        }
1686
1687        if config_opts.bootloader.is_none() {
1688            config_opts.bootloader = config.bootloader.clone();
1689        }
1690    } else {
1691        tracing::debug!("No install configuration found");
1692    }
1693
1694    let root_filesystem = target_fs
1695        .or(install_config
1696            .as_ref()
1697            .and_then(|c| c.filesystem_root())
1698            .and_then(|r| r.fstype))
1699        .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?;
1700
1701    let mut is_uki = false;
1702
1703    // For composefs backend, automatically disable fs-verity hard requirement if the
1704    // filesystem doesn't support it
1705    //
1706    // If we have a sealed UKI on our hands, then we can assume that user wanted fs-verity so
1707    // we hard require it in that particular case
1708    //
1709    // NOTE: This isn't really 100% accurate 100% of the time as the cmdline can be in an addon
1710    match kernel {
1711        Some(k) => match k.k_type {
1712            crate::kernel::KernelType::Uki { cmdline, .. } => {
1713                let allow_missing_fsverity = cmdline.is_some_and(|cmd| {
1714                    ComposefsCmdline::find_in_cmdline(&cmd)
1715                        .is_some_and(|cfs_cmdline| cfs_cmdline.allow_missing_fsverity)
1716                });
1717
1718                if !allow_missing_fsverity {
1719                    anyhow::ensure!(
1720                        root_filesystem.supports_fsverity(),
1721                        "Specified filesystem {root_filesystem} does not support fs-verity"
1722                    );
1723                }
1724
1725                composefs_options.allow_missing_verity = allow_missing_fsverity;
1726                is_uki = true;
1727            }
1728
1729            crate::kernel::KernelType::Vmlinuz { .. } => {}
1730        },
1731
1732        None => {}
1733    }
1734
1735    // If `--allow-missing-verity` is already passed via CLI, don't modify
1736    if composefs_options.composefs_backend && !composefs_options.allow_missing_verity && !is_uki {
1737        composefs_options.allow_missing_verity = !root_filesystem.supports_fsverity();
1738    }
1739
1740    tracing::info!(
1741        allow_missing_fsverity = composefs_options.allow_missing_verity,
1742        uki = is_uki,
1743        "ComposeFS install prep",
1744    );
1745
1746    if let Some(crate::spec::Bootloader::None) = config_opts.bootloader {
1747        if cfg!(target_arch = "s390x") {
1748            anyhow::bail!("Bootloader set to none is not supported for the s390x architecture");
1749        }
1750    }
1751
1752    // Convert the keyfile to a hashmap because GKeyFile isnt Send for probably bad reasons.
1753    let prepareroot_config = {
1754        let kf = ostree_prepareroot::require_config_from_root(&rootfs)?;
1755        let mut r = HashMap::new();
1756        for grp in kf.groups() {
1757            for key in kf.keys(&grp)? {
1758                let key = key.as_str();
1759                let value = kf.value(&grp, key)?;
1760                r.insert(format!("{grp}.{key}"), value.to_string());
1761            }
1762        }
1763        r
1764    };
1765
1766    // Eagerly read the file now to ensure we error out early if e.g. it doesn't exist,
1767    // instead of much later after we're 80% of the way through an install.
1768    let root_ssh_authorized_keys = config_opts
1769        .root_ssh_authorized_keys
1770        .as_ref()
1771        .map(|p| std::fs::read_to_string(p).with_context(|| format!("Reading {p}")))
1772        .transpose()?;
1773
1774    // Create our global (read-only) state which gets wrapped in an Arc
1775    // so we can pass it to worker threads too. Right now this just
1776    // combines our command line options along with some bind mounts from the host.
1777    let state = Arc::new(State {
1778        selinux_state,
1779        source,
1780        config_opts,
1781        target_opts,
1782        target_imgref,
1783        install_config,
1784        prepareroot_config,
1785        root_ssh_authorized_keys,
1786        container_root: rootfs,
1787        tempdir,
1788        host_is_container,
1789        composefs_required,
1790        composefs_options,
1791    });
1792
1793    Ok(state)
1794}
1795
1796impl PostFetchState {
1797    pub(crate) fn new(state: &State, d: &Dir) -> Result<Self> {
1798        // Determine bootloader type for the target system
1799        // Priority: user-specified > bootupd availability > systemd-boot fallback
1800        let detected_bootloader = {
1801            if let Some(bootloader) = state.config_opts.bootloader.clone() {
1802                bootloader
1803            } else {
1804                if crate::bootloader::supports_bootupd(d)? {
1805                    crate::spec::Bootloader::Grub
1806                } else {
1807                    crate::spec::Bootloader::Systemd
1808                }
1809            }
1810        };
1811        println!("Bootloader: {detected_bootloader}");
1812        let r = Self {
1813            detected_bootloader,
1814        };
1815        Ok(r)
1816    }
1817}
1818
1819/// Given a baseline root filesystem with an ostree sysroot initialized:
1820/// - install the container to that root
1821/// - install the bootloader
1822/// - Other post operations, such as pulling bound images
1823async fn install_with_sysroot(
1824    state: &State,
1825    rootfs: &RootSetup,
1826    storage: &Storage,
1827    boot_uuid: &str,
1828    bound_images: BoundImages,
1829    has_ostree: bool,
1830) -> Result<()> {
1831    let ostree = storage.get_ostree()?;
1832    let c_storage = storage.get_ensure_imgstore()?;
1833
1834    // And actually set up the container in that root, returning a deployment and
1835    // the aleph state (see below).
1836    let (deployment, aleph) = install_container(state, rootfs, ostree, storage, has_ostree).await?;
1837    // Write the aleph data that captures the system state at the time of provisioning for aid in future debugging.
1838    aleph.write_to(&rootfs.physical_root)?;
1839
1840    let deployment_path = ostree.deployment_dirpath(&deployment);
1841
1842    let deployment_dir = rootfs
1843        .physical_root
1844        .open_dir(&deployment_path)
1845        .context("Opening deployment dir")?;
1846    let postfetch = PostFetchState::new(state, &deployment_dir)?;
1847
1848    if cfg!(target_arch = "s390x") {
1849        // TODO: Integrate s390x support into install_via_bootupd
1850        // zipl only supports single device
1851        crate::bootloader::install_via_zipl(&rootfs.device_info.require_single_root()?, boot_uuid)?;
1852    } else {
1853        match postfetch.detected_bootloader {
1854            Bootloader::Grub => {
1855                crate::bootloader::install_via_bootupd(
1856                    &rootfs.device_info,
1857                    &rootfs
1858                        .target_root_path
1859                        .clone()
1860                        .unwrap_or(rootfs.physical_root_path.clone()),
1861                    &state.config_opts,
1862                    Some(&deployment_path.as_str()),
1863                )?;
1864            }
1865            Bootloader::Systemd => {
1866                anyhow::bail!("bootupd is required for ostree-based installs");
1867            }
1868            Bootloader::None => {
1869                tracing::debug!("Skip bootloader installation due set to None");
1870            }
1871        }
1872    }
1873    tracing::debug!("Installed bootloader");
1874
1875    tracing::debug!("Performing post-deployment operations");
1876
1877    match bound_images {
1878        BoundImages::Skip => {}
1879        BoundImages::Resolved(resolved_bound_images) => {
1880            // Now copy each bound image from the host's container storage into the target.
1881            for image in resolved_bound_images {
1882                let image = image.image.as_str();
1883                c_storage.pull_from_host_storage(image).await?;
1884            }
1885        }
1886        BoundImages::Unresolved(bound_images) => {
1887            crate::boundimage::pull_images_impl(c_storage, bound_images)
1888                .await
1889                .context("pulling bound images")?;
1890        }
1891    }
1892
1893    Ok(())
1894}
1895
1896enum BoundImages {
1897    Skip,
1898    Resolved(Vec<ResolvedBoundImage>),
1899    Unresolved(Vec<BoundImage>),
1900}
1901
1902impl BoundImages {
1903    async fn from_state(state: &State) -> Result<Self> {
1904        let bound_images = match state.config_opts.bound_images {
1905            BoundImagesOpt::Skip => BoundImages::Skip,
1906            others => {
1907                let queried_images = crate::boundimage::query_bound_images(&state.container_root)?;
1908                match others {
1909                    BoundImagesOpt::Stored => {
1910                        // Verify each bound image is present in the container storage
1911                        let mut r = Vec::with_capacity(queried_images.len());
1912                        for image in queried_images {
1913                            let resolved = ResolvedBoundImage::from_image(&image).await?;
1914                            tracing::debug!("Resolved {}: {}", resolved.image, resolved.digest);
1915                            r.push(resolved)
1916                        }
1917                        BoundImages::Resolved(r)
1918                    }
1919                    BoundImagesOpt::Pull => {
1920                        // No need to resolve the images, we will pull them into the target later
1921                        BoundImages::Unresolved(queried_images)
1922                    }
1923                    BoundImagesOpt::Skip => anyhow::bail!("unreachable error"),
1924                }
1925            }
1926        };
1927
1928        Ok(bound_images)
1929    }
1930}
1931
1932async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> {
1933    // We verify this upfront because it's currently required by bootupd
1934    let boot_uuid = rootfs
1935        .get_boot_uuid()?
1936        .or(rootfs.rootfs_uuid.as_deref())
1937        .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
1938    tracing::debug!("boot uuid={boot_uuid}");
1939
1940    let bound_images = BoundImages::from_state(state).await?;
1941
1942    // Initialize the ostree sysroot (repo, stateroot, etc.)
1943
1944    {
1945        let (sysroot, has_ostree) = initialize_ostree_root(state, rootfs).await?;
1946
1947        install_with_sysroot(
1948            state,
1949            rootfs,
1950            &sysroot,
1951            &boot_uuid,
1952            bound_images,
1953            has_ostree,
1954        )
1955        .await?;
1956        let ostree = sysroot.get_ostree()?;
1957
1958        if matches!(cleanup, Cleanup::TriggerOnNextBoot) {
1959            let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
1960            tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}");
1961            sysroot_dir.atomic_write(DESTRUCTIVE_CLEANUP, b"")?;
1962        }
1963
1964        // Ensure the image storage is SELinux-labeled. This must happen
1965        // after all image pulls are complete.
1966        sysroot.ensure_imgstore_labeled()?;
1967
1968        // We must drop the sysroot here in order to close any open file
1969        // descriptors.
1970    };
1971
1972    // Run this on every install as the penultimate step
1973    install_finalize(&rootfs.physical_root_path).await?;
1974
1975    Ok(())
1976}
1977
1978async fn install_to_filesystem_impl(
1979    state: &State,
1980    rootfs: &mut RootSetup,
1981    cleanup: Cleanup,
1982) -> Result<()> {
1983    if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) {
1984        rootfs.kargs.extend(&Cmdline::from("selinux=0"));
1985    }
1986    // Drop exclusive ownership since we're done with mutation
1987    let rootfs = &*rootfs;
1988
1989    match rootfs.device_info.pttype.as_deref() {
1990        Some("dos") => crate::utils::medium_visibility_warning(
1991            "Installing to `dos` format partitions is not recommended",
1992        ),
1993        Some("gpt") => {
1994            // The only thing we should be using in general
1995        }
1996        Some(o) => {
1997            crate::utils::medium_visibility_warning(&format!("Unknown partition table type {o}"))
1998        }
1999        None => {
2000            // No partition table type - may be a filesystem install or loop device
2001        }
2002    }
2003
2004    if state.composefs_options.composefs_backend {
2005        // Pre-flight disk space check for native composefs install path.
2006        {
2007            let imgref = &state.source.imageref;
2008            let imgref_repr = get_imgref(&imgref.transport.to_string(), &imgref.name);
2009            let img_manifest_config = get_container_manifest_and_config(&imgref_repr).await?;
2010            crate::store::ensure_composefs_dir(&rootfs.physical_root)?;
2011            let cfs_repo = open_composefs_repo(&rootfs.physical_root)?;
2012            crate::deploy::check_disk_space_composefs(
2013                &cfs_repo,
2014                &img_manifest_config.manifest,
2015                &crate::spec::ImageReference {
2016                    image: imgref.name.clone(),
2017                    transport: imgref.transport.to_string(),
2018                    signature: None,
2019                },
2020            )?;
2021        }
2022        let pull_result = initialize_composefs_repository(
2023            state,
2024            rootfs,
2025            state.composefs_options.allow_missing_verity,
2026        )
2027        .await?;
2028        tracing::info!(
2029            "id: {}, verity: {}",
2030            pull_result.config_digest,
2031            pull_result.config_verity.to_hex()
2032        );
2033
2034        setup_composefs_boot(
2035            rootfs,
2036            state,
2037            &pull_result.config_digest,
2038            state.composefs_options.allow_missing_verity,
2039        )
2040        .await?;
2041
2042        // Label composefs objects as /usr so they get usr_t rather than
2043        // default_t (which has no policy match).
2044        if let Some(policy) = state.load_policy()? {
2045            tracing::info!("Labeling composefs objects as /usr");
2046            crate::lsm::relabel_recurse(
2047                &rootfs.physical_root,
2048                "composefs",
2049                Some("/usr".into()),
2050                &policy,
2051            )
2052            .context("SELinux labeling of composefs objects")?;
2053        }
2054    } else {
2055        ostree_install(state, rootfs, cleanup).await?;
2056    }
2057
2058    // As the very last step before filesystem finalization, do a full SELinux
2059    // relabel of the physical root filesystem.  Any files that are already
2060    // labeled (e.g. ostree deployment contents, composefs objects) are skipped.
2061    if let Some(policy) = state.load_policy()? {
2062        tracing::info!("Performing final SELinux relabeling of physical root");
2063        let mut path = Utf8PathBuf::from("");
2064        crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None)
2065            .context("Final SELinux relabeling of physical root")?;
2066    } else {
2067        tracing::debug!("Skipping final SELinux relabel (SELinux is disabled)");
2068    }
2069
2070    // Finalize mounted filesystems
2071    if !rootfs.skip_finalize {
2072        let bootfs = rootfs.boot.as_ref().map(|_| ("boot", "boot"));
2073        for (fsname, fs) in std::iter::once(("root", ".")).chain(bootfs) {
2074            finalize_filesystem(fsname, &rootfs.physical_root, fs)?;
2075        }
2076    }
2077
2078    Ok(())
2079}
2080
2081fn installation_complete() {
2082    println!("Installation complete!");
2083}
2084
2085/// Implementation of the `bootc install to-disk` CLI command.
2086#[context("Installing to disk")]
2087#[cfg(feature = "install-to-disk")]
2088pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> {
2089    // Log the disk installation operation to systemd journal
2090    const INSTALL_DISK_JOURNAL_ID: &str = "8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2";
2091    let source_image = opts
2092        .source_opts
2093        .source_imgref
2094        .as_ref()
2095        .map(|s| s.as_str())
2096        .unwrap_or("none");
2097    let target_device = opts.block_opts.device.as_str();
2098
2099    tracing::info!(
2100        message_id = INSTALL_DISK_JOURNAL_ID,
2101        bootc.source_image = source_image,
2102        bootc.target_device = target_device,
2103        bootc.via_loopback = if opts.via_loopback { "true" } else { "false" },
2104        "Starting disk installation from {} to {}",
2105        source_image,
2106        target_device
2107    );
2108
2109    let mut block_opts = opts.block_opts;
2110    let target_blockdev_meta = block_opts
2111        .device
2112        .metadata()
2113        .with_context(|| format!("Querying {}", &block_opts.device))?;
2114    if opts.via_loopback {
2115        if !opts.config_opts.generic_image {
2116            crate::utils::medium_visibility_warning(
2117                "Automatically enabling --generic-image when installing via loopback",
2118            );
2119            opts.config_opts.generic_image = true;
2120        }
2121        if !target_blockdev_meta.file_type().is_file() {
2122            anyhow::bail!(
2123                "Not a regular file (to be used via loopback): {}",
2124                block_opts.device
2125            );
2126        }
2127    } else if !target_blockdev_meta.file_type().is_block_device() {
2128        anyhow::bail!("Not a block device: {}", block_opts.device);
2129    }
2130
2131    let state = prepare_install(
2132        opts.config_opts,
2133        opts.source_opts,
2134        opts.target_opts,
2135        opts.composefs_opts,
2136        block_opts.filesystem,
2137    )
2138    .await?;
2139
2140    // This is all blocking stuff
2141    let (mut rootfs, loopback) = {
2142        let loopback_dev = if opts.via_loopback {
2143            let loopback_dev =
2144                bootc_blockdev::LoopbackDevice::new(block_opts.device.as_std_path())?;
2145            block_opts.device = loopback_dev.path().into();
2146            Some(loopback_dev)
2147        } else {
2148            None
2149        };
2150
2151        let state = state.clone();
2152        let rootfs = tokio::task::spawn_blocking(move || {
2153            baseline::install_create_rootfs(&state, block_opts)
2154        })
2155        .await??;
2156        (rootfs, loopback_dev)
2157    };
2158
2159    install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip).await?;
2160
2161    // Drop all data about the root except the bits we need to ensure any file descriptors etc. are closed.
2162    let (root_path, luksdev) = rootfs.into_storage();
2163    Task::new_and_run(
2164        "Unmounting filesystems",
2165        "umount",
2166        ["-R", root_path.as_str()],
2167    )?;
2168    if let Some(luksdev) = luksdev.as_deref() {
2169        Task::new_and_run("Closing root LUKS device", "cryptsetup", ["close", luksdev])?;
2170    }
2171
2172    if let Some(loopback_dev) = loopback {
2173        loopback_dev.close()?;
2174    }
2175
2176    // At this point, all other threads should be gone.
2177    if let Some(state) = Arc::into_inner(state) {
2178        state.consume()?;
2179    } else {
2180        // This shouldn't happen...but we will make it not fatal right now
2181        tracing::warn!("Failed to consume state Arc");
2182    }
2183
2184    installation_complete();
2185
2186    Ok(())
2187}
2188
2189/// Require that a directory contains only mount points recursively.
2190/// Returns Ok(()) if all entries in the directory tree are either:
2191/// - Mount points (on different filesystems)
2192/// - Directories that themselves contain only mount points (recursively)
2193/// - The lost+found directory
2194///
2195/// Returns an error if any non-mount entry is found.
2196///
2197/// This handles cases like /var containing /var/lib (not a mount) which contains
2198/// /var/lib/containers (a mount point).
2199#[context("Requiring directory contains only mount points")]
2200fn require_dir_contains_only_mounts(parent_fd: &Dir, dir_name: &str) -> Result<()> {
2201    tracing::trace!("Checking directory {dir_name} for non-mount entries");
2202    let Some(dir_fd) = parent_fd.open_dir_noxdev(dir_name)? else {
2203        // The directory itself is a mount point
2204        tracing::trace!("{dir_name} is a mount point");
2205        return Ok(());
2206    };
2207
2208    if dir_fd.entries()?.next().is_none() {
2209        anyhow::bail!("Found empty directory: {dir_name}");
2210    }
2211
2212    for entry in dir_fd.entries()? {
2213        tracing::trace!("Checking entry in {dir_name}");
2214        let entry = DirEntryUtf8::from_cap_std(entry?);
2215        let entry_name = entry.file_name()?;
2216
2217        if entry_name == LOST_AND_FOUND {
2218            continue;
2219        }
2220
2221        let etype = entry.file_type()?;
2222        if etype == FileType::dir() {
2223            require_dir_contains_only_mounts(&dir_fd, &entry_name)?;
2224        } else {
2225            anyhow::bail!("Found entry in {dir_name}: {entry_name}");
2226        }
2227    }
2228
2229    Ok(())
2230}
2231
2232#[context("Verifying empty rootfs")]
2233fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> {
2234    for e in rootfs_fd.entries()? {
2235        let e = DirEntryUtf8::from_cap_std(e?);
2236        let name = e.file_name()?;
2237        if name == LOST_AND_FOUND {
2238            continue;
2239        }
2240
2241        // Check if this entry is a directory
2242        let etype = e.file_type()?;
2243        if etype == FileType::dir() {
2244            require_dir_contains_only_mounts(rootfs_fd, &name)?;
2245        } else {
2246            anyhow::bail!("Non-empty root filesystem; found {name:?}");
2247        }
2248    }
2249    Ok(())
2250}
2251
2252/// Remove all entries in a directory, but do not traverse across distinct devices.
2253/// If mount_err is true, then an error is returned if a mount point is found;
2254/// otherwise it is silently ignored.
2255fn remove_all_in_dir_no_xdev(d: &Dir, mount_err: bool) -> Result<()> {
2256    for entry in d.entries()? {
2257        let entry = entry?;
2258        let name = entry.file_name();
2259        let etype = entry.file_type()?;
2260        if etype == FileType::dir() {
2261            if let Some(subdir) = d.open_dir_noxdev(&name)? {
2262                remove_all_in_dir_no_xdev(&subdir, mount_err)?;
2263                d.remove_dir(&name)?;
2264            } else if mount_err {
2265                anyhow::bail!("Found unexpected mount point {name:?}");
2266            }
2267        } else {
2268            d.remove_file_optional(&name)?;
2269        }
2270    }
2271    anyhow::Ok(())
2272}
2273
2274#[context("Removing boot directory content except loader dir on ostree")]
2275fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> {
2276    let entries = bootdir
2277        .entries()
2278        .context("Reading boot directory entries")?;
2279
2280    for entry in entries {
2281        let entry = entry.context("Reading directory entry")?;
2282        let file_name = entry.file_name();
2283        let file_name = if let Some(n) = file_name.to_str() {
2284            n
2285        } else {
2286            anyhow::bail!("Invalid non-UTF8 filename: {file_name:?} in /boot");
2287        };
2288
2289        // TODO: Preserve basically everything (including the bootloader entries
2290        // on non-ostree) by default until the very end of the install. And ideally
2291        // make the "commit" phase an optional step after.
2292        if is_ostree && file_name.starts_with("loader") {
2293            continue;
2294        }
2295
2296        let etype = entry.file_type()?;
2297        if etype == FileType::dir() {
2298            // Open the directory and remove its contents
2299            if let Some(subdir) = bootdir.open_dir_noxdev(&file_name)? {
2300                remove_all_in_dir_no_xdev(&subdir, false)
2301                    .with_context(|| format!("Removing directory contents: {}", file_name))?;
2302                bootdir.remove_dir(&file_name)?;
2303            }
2304        } else {
2305            bootdir
2306                .remove_file_optional(&file_name)
2307                .with_context(|| format!("Removing file: {}", file_name))?;
2308        }
2309    }
2310    Ok(())
2311}
2312
2313#[context("Removing boot directory content")]
2314fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) -> Result<()> {
2315    let bootdir =
2316        crate::utils::open_dir_remount_rw(rootfs, BOOT.into()).context("Opening /boot")?;
2317
2318    if ARCH_USES_EFI {
2319        // On booted FCOS, esp is not mounted by default
2320        // Mount ESP part at /boot/efi before clean
2321        crate::bootloader::mount_esp_part(&rootfs, &rootfs_path, is_ostree)?;
2322    }
2323
2324    // This should not remove /boot/efi note.
2325    remove_all_except_loader_dirs(&bootdir, is_ostree).context("Emptying /boot")?;
2326
2327    // TODO: we should also support not wiping the ESP.
2328    if ARCH_USES_EFI {
2329        if let Some(efidir) = bootdir
2330            .open_dir_optional(crate::bootloader::EFI_DIR)
2331            .context("Opening /boot/efi")?
2332        {
2333            remove_all_in_dir_no_xdev(&efidir, false).context("Emptying EFI system partition")?;
2334        }
2335    }
2336
2337    Ok(())
2338}
2339
2340struct RootMountInfo {
2341    mount_spec: String,
2342    kargs: Vec<String>,
2343}
2344
2345/// Discover how to mount the root filesystem, using existing kernel arguments and information
2346/// about the root mount.
2347fn find_root_args_to_inherit(
2348    cmdline: &bytes::Cmdline,
2349    root_info: &Filesystem,
2350) -> Result<RootMountInfo> {
2351    // If we have a root= karg, then use that
2352    let root = cmdline
2353        .find_utf8("root")?
2354        .and_then(|p| p.value().map(|p| p.to_string()));
2355    let (mount_spec, kargs) = if let Some(root) = root {
2356        let rootflags = cmdline.find(ROOTFLAGS);
2357        let inherit_kargs = cmdline.find_all_starting_with(INITRD_ARG_PREFIX);
2358        (
2359            root,
2360            rootflags
2361                .into_iter()
2362                .chain(inherit_kargs)
2363                .map(|p| utf8::Parameter::try_from(p).map(|p| p.to_string()))
2364                .collect::<Result<Vec<_>, _>>()?,
2365        )
2366    } else {
2367        let uuid = root_info
2368            .uuid
2369            .as_deref()
2370            .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?;
2371        (format!("UUID={uuid}"), Vec::new())
2372    };
2373
2374    Ok(RootMountInfo { mount_spec, kargs })
2375}
2376
2377fn warn_on_host_root(rootfs_fd: &Dir) -> Result<()> {
2378    // Seconds for which we wait while warning
2379    const DELAY_SECONDS: u64 = 20;
2380
2381    let host_root_dfd = &Dir::open_ambient_dir("/proc/1/root", cap_std::ambient_authority())?;
2382    let host_root_devstat = rustix::fs::fstatvfs(host_root_dfd)?;
2383    let target_devstat = rustix::fs::fstatvfs(rootfs_fd)?;
2384    if host_root_devstat.f_fsid != target_devstat.f_fsid {
2385        tracing::debug!("Not the host root");
2386        return Ok(());
2387    }
2388    let dashes = "----------------------------";
2389    let timeout = Duration::from_secs(DELAY_SECONDS);
2390    eprintln!("{dashes}");
2391    crate::utils::medium_visibility_warning(
2392        "WARNING: This operation will OVERWRITE THE BOOTED HOST ROOT FILESYSTEM and is NOT REVERSIBLE.",
2393    );
2394    eprintln!("Waiting {timeout:?} to continue; interrupt (Control-C) to cancel.");
2395    eprintln!("{dashes}");
2396
2397    let bar = indicatif::ProgressBar::new_spinner();
2398    bar.enable_steady_tick(Duration::from_millis(100));
2399    std::thread::sleep(timeout);
2400    bar.finish();
2401
2402    Ok(())
2403}
2404
2405pub enum Cleanup {
2406    Skip,
2407    TriggerOnNextBoot,
2408}
2409
2410/// Implementation of the `bootc install to-filesystem` CLI command.
2411#[context("Installing to filesystem")]
2412pub(crate) async fn install_to_filesystem(
2413    opts: InstallToFilesystemOpts,
2414    targeting_host_root: bool,
2415    cleanup: Cleanup,
2416) -> Result<()> {
2417    // Log the installation operation to systemd journal
2418    const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3";
2419    let source_image = opts
2420        .source_opts
2421        .source_imgref
2422        .as_ref()
2423        .map(|s| s.as_str())
2424        .unwrap_or("none");
2425    let target_path = opts.filesystem_opts.root_path.as_str();
2426
2427    tracing::info!(
2428        message_id = INSTALL_FILESYSTEM_JOURNAL_ID,
2429        bootc.source_image = source_image,
2430        bootc.target_path = target_path,
2431        bootc.targeting_host_root = if targeting_host_root { "true" } else { "false" },
2432        "Starting filesystem installation from {} to {}",
2433        source_image,
2434        target_path
2435    );
2436
2437    // And the last bit of state here is the fsopts, which we also destructure now.
2438    let mut fsopts = opts.filesystem_opts;
2439
2440    // If we're doing an alongside install, automatically set up the host rootfs
2441    // mount if it wasn't done already.
2442    if targeting_host_root
2443        && fsopts.root_path.as_str() == ALONGSIDE_ROOT_MOUNT
2444        && !fsopts.root_path.try_exists()?
2445    {
2446        tracing::debug!("Mounting host / to {ALONGSIDE_ROOT_MOUNT}");
2447        std::fs::create_dir(ALONGSIDE_ROOT_MOUNT)?;
2448        bootc_mount::bind_mount_from_pidns(
2449            bootc_mount::PID1,
2450            "/".into(),
2451            ALONGSIDE_ROOT_MOUNT.into(),
2452            true,
2453        )
2454        .context("Mounting host / to {ALONGSIDE_ROOT_MOUNT}")?;
2455    }
2456
2457    let target_root_path = fsopts.root_path.clone();
2458
2459    // Get a file descriptor for the root path /target
2460    let target_rootfs_fd =
2461        Dir::open_ambient_dir(&target_root_path, cap_std::ambient_authority())
2462            .with_context(|| format!("Opening target root directory {target_root_path}"))?;
2463
2464    tracing::debug!("Target root filesystem: {target_root_path}");
2465
2466    if let Some(false) = target_rootfs_fd.is_mountpoint(".")? {
2467        anyhow::bail!("Not a mountpoint: {target_root_path}");
2468    }
2469
2470    // Check that the target is a directory
2471    {
2472        let root_path = &fsopts.root_path;
2473        let st = root_path
2474            .symlink_metadata()
2475            .with_context(|| format!("Querying target filesystem {root_path}"))?;
2476        if !st.is_dir() {
2477            anyhow::bail!("Not a directory: {root_path}");
2478        }
2479    }
2480
2481    // If we're installing to an ostree root, then find the physical root from
2482    // the deployment root.
2483    let possible_physical_root = fsopts.root_path.join("sysroot");
2484    let possible_ostree_dir = possible_physical_root.join("ostree");
2485    let is_already_ostree = possible_ostree_dir.exists();
2486    if is_already_ostree {
2487        tracing::debug!(
2488            "ostree detected in {possible_ostree_dir}, assuming target is a deployment root and using {possible_physical_root}"
2489        );
2490        fsopts.root_path = possible_physical_root;
2491    };
2492
2493    // Get a file descriptor for the root path
2494    // It will be /target/sysroot on ostree OS, or will be /target
2495    let rootfs_fd = if is_already_ostree {
2496        let root_path = &fsopts.root_path;
2497        let rootfs_fd = Dir::open_ambient_dir(&fsopts.root_path, cap_std::ambient_authority())
2498            .with_context(|| format!("Opening target root directory {root_path}"))?;
2499
2500        tracing::debug!("Root filesystem: {root_path}");
2501
2502        if let Some(false) = rootfs_fd.is_mountpoint(".")? {
2503            anyhow::bail!("Not a mountpoint: {root_path}");
2504        }
2505        rootfs_fd
2506    } else {
2507        target_rootfs_fd.try_clone()?
2508    };
2509
2510    // Gather data about the root filesystem
2511    let inspect = bootc_mount::inspect_filesystem(&fsopts.root_path)?;
2512
2513    // Gather global state, destructuring the provided options.
2514    // IMPORTANT: We might re-execute the current process in this function (for SELinux among other things)
2515    // IMPORTANT: and hence anything that is done before MUST BE IDEMPOTENT.
2516    // IMPORTANT: In practice, we should only be gathering information before this point,
2517    // IMPORTANT: and not performing any mutations at all.
2518    let state = prepare_install(
2519        opts.config_opts,
2520        opts.source_opts,
2521        opts.target_opts,
2522        opts.composefs_opts,
2523        Some(inspect.fstype.as_str().try_into()?),
2524    )
2525    .await?;
2526
2527    // Check to see if this happens to be the real host root
2528    if !fsopts.acknowledge_destructive {
2529        warn_on_host_root(&target_rootfs_fd)?;
2530    }
2531
2532    match fsopts.replace {
2533        Some(ReplaceMode::Wipe) => {
2534            let rootfs_fd = rootfs_fd.try_clone()?;
2535            println!("Wiping contents of root");
2536            tokio::task::spawn_blocking(move || remove_all_in_dir_no_xdev(&rootfs_fd, true))
2537                .await??;
2538        }
2539        Some(ReplaceMode::Alongside) => {
2540            clean_boot_directories(&target_rootfs_fd, &target_root_path, is_already_ostree)?
2541        }
2542        None => require_empty_rootdir(&rootfs_fd)?,
2543    }
2544
2545    // We support overriding the mount specification for root (i.e. LABEL vs UUID versus
2546    // raw paths).
2547    // We also support an empty specification as a signal to omit any mountspec kargs.
2548    // CLI takes precedence over config file.
2549    let config_root_mount_spec = state
2550        .install_config
2551        .as_ref()
2552        .and_then(|c| c.root_mount_spec.as_ref());
2553    let root_info = if let Some(s) = fsopts.root_mount_spec.as_ref().or(config_root_mount_spec) {
2554        RootMountInfo {
2555            mount_spec: s.to_string(),
2556            kargs: Vec::new(),
2557        }
2558    } else if targeting_host_root {
2559        // In the to-existing-root case, look at /proc/cmdline
2560        let cmdline = bytes::Cmdline::from_proc()?;
2561        find_root_args_to_inherit(&cmdline, &inspect)?
2562    } else {
2563        // Otherwise, gather metadata from the provided root and use its provided UUID as a
2564        // default root= karg.
2565        let uuid = inspect
2566            .uuid
2567            .as_deref()
2568            .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?;
2569        let kargs = match inspect.fstype.as_str() {
2570            "btrfs" => {
2571                let subvol = crate::utils::find_mount_option(&inspect.options, "subvol");
2572                subvol
2573                    .map(|vol| format!("rootflags=subvol={vol}"))
2574                    .into_iter()
2575                    .collect::<Vec<_>>()
2576            }
2577            _ => Vec::new(),
2578        };
2579        RootMountInfo {
2580            mount_spec: format!("UUID={uuid}"),
2581            kargs,
2582        }
2583    };
2584    tracing::debug!("Root mount: {} {:?}", root_info.mount_spec, root_info.kargs);
2585
2586    let boot_is_mount = {
2587        if let Some(boot_metadata) = target_rootfs_fd.symlink_metadata_optional(BOOT)? {
2588            let root_dev = rootfs_fd.dir_metadata()?.dev();
2589            let boot_dev = boot_metadata.dev();
2590            tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}");
2591            root_dev != boot_dev
2592        } else {
2593            tracing::debug!("No /{BOOT} directory found");
2594            false
2595        }
2596    };
2597    // Find the UUID of /boot because we need it for GRUB.
2598    let boot_uuid = if boot_is_mount {
2599        let boot_path = target_root_path.join(BOOT);
2600        tracing::debug!("boot_path={boot_path}");
2601        let u = bootc_mount::inspect_filesystem(&boot_path)
2602            .with_context(|| format!("Inspecting /{BOOT}"))?
2603            .uuid
2604            .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?;
2605        Some(u)
2606    } else {
2607        None
2608    };
2609    tracing::debug!("boot UUID: {boot_uuid:?}");
2610
2611    // Find the real underlying backing device for the root.  This is currently just required
2612    // for GRUB (BIOS) and in the future zipl (I think).
2613    let device_info = {
2614        let dev =
2615            bootc_blockdev::list_dev(Utf8Path::new(&inspect.source))?.require_single_root()?;
2616        tracing::debug!("Backing device: {}", dev.path());
2617        dev
2618    };
2619
2620    let rootarg = format!("root={}", root_info.mount_spec);
2621    // CLI takes precedence over config file.
2622    let config_boot_mount_spec = state
2623        .install_config
2624        .as_ref()
2625        .and_then(|c| c.boot_mount_spec.as_ref());
2626    let mut boot = if let Some(spec) = fsopts.boot_mount_spec.as_ref().or(config_boot_mount_spec) {
2627        // An empty boot mount spec signals to omit the mountspec kargs
2628        // See https://github.com/bootc-dev/bootc/issues/1441
2629        if spec.is_empty() {
2630            None
2631        } else {
2632            Some(MountSpec::new(&spec, "/boot"))
2633        }
2634    } else {
2635        // Read /etc/fstab to get boot entry, but only use it if it's UUID-based
2636        // Otherwise fall back to boot_uuid
2637        read_boot_fstab_entry(&rootfs_fd)?
2638            .filter(|spec| spec.get_source_uuid().is_some())
2639            .or_else(|| {
2640                boot_uuid
2641                    .as_deref()
2642                    .map(|boot_uuid| MountSpec::new_uuid_src(boot_uuid, "/boot"))
2643            })
2644    };
2645    // Ensure that we mount /boot readonly because it's really owned by bootc/ostree
2646    // and we don't want e.g. apt/dnf trying to mutate it.
2647    if let Some(boot) = boot.as_mut() {
2648        boot.push_option("ro");
2649    }
2650    // By default, we inject a boot= karg because things like FIPS compliance currently
2651    // require checking in the initramfs.
2652    let bootarg = boot.as_ref().map(|boot| format!("boot={}", &boot.source));
2653
2654    // If the root mount spec is empty, we omit the mounts kargs entirely.
2655    // https://github.com/bootc-dev/bootc/issues/1441
2656    let mut kargs = if root_info.mount_spec.is_empty() {
2657        Vec::new()
2658    } else {
2659        [rootarg]
2660            .into_iter()
2661            .chain(root_info.kargs)
2662            .collect::<Vec<_>>()
2663    };
2664
2665    kargs.push(RW_KARG.to_string());
2666
2667    if let Some(bootarg) = bootarg {
2668        kargs.push(bootarg);
2669    }
2670
2671    let kargs = Cmdline::from(kargs.join(" "));
2672
2673    let skip_finalize =
2674        matches!(fsopts.replace, Some(ReplaceMode::Alongside)) || fsopts.skip_finalize;
2675    let mut rootfs = RootSetup {
2676        #[cfg(feature = "install-to-disk")]
2677        luks_device: None,
2678        device_info,
2679        physical_root_path: fsopts.root_path,
2680        physical_root: rootfs_fd,
2681        target_root_path: Some(target_root_path.clone()),
2682        rootfs_uuid: inspect.uuid.clone(),
2683        boot,
2684        kargs,
2685        skip_finalize,
2686    };
2687
2688    install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?;
2689
2690    // Drop all data about the root except the path to ensure any file descriptors etc. are closed.
2691    drop(rootfs);
2692
2693    installation_complete();
2694
2695    Ok(())
2696}
2697
2698pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> Result<()> {
2699    // Log the existing root installation operation to systemd journal
2700    const INSTALL_EXISTING_ROOT_JOURNAL_ID: &str = "7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1";
2701    let source_image = opts
2702        .source_opts
2703        .source_imgref
2704        .as_ref()
2705        .map(|s| s.as_str())
2706        .unwrap_or("none");
2707    let target_path = opts.root_path.as_str();
2708
2709    tracing::info!(
2710        message_id = INSTALL_EXISTING_ROOT_JOURNAL_ID,
2711        bootc.source_image = source_image,
2712        bootc.target_path = target_path,
2713        bootc.cleanup = if opts.cleanup {
2714            "trigger_on_next_boot"
2715        } else {
2716            "skip"
2717        },
2718        "Starting installation to existing root from {} to {}",
2719        source_image,
2720        target_path
2721    );
2722
2723    let cleanup = match opts.cleanup {
2724        true => Cleanup::TriggerOnNextBoot,
2725        false => Cleanup::Skip,
2726    };
2727
2728    let opts = InstallToFilesystemOpts {
2729        filesystem_opts: InstallTargetFilesystemOpts {
2730            root_path: opts.root_path,
2731            root_mount_spec: None,
2732            boot_mount_spec: None,
2733            replace: opts.replace,
2734            skip_finalize: true,
2735            acknowledge_destructive: opts.acknowledge_destructive,
2736        },
2737        source_opts: opts.source_opts,
2738        target_opts: opts.target_opts,
2739        config_opts: opts.config_opts,
2740        composefs_opts: opts.composefs_opts,
2741    };
2742
2743    install_to_filesystem(opts, true, cleanup).await
2744}
2745
2746/// Read the /boot entry from /etc/fstab, if it exists
2747fn read_boot_fstab_entry(root: &Dir) -> Result<Option<MountSpec>> {
2748    let fstab_path = "etc/fstab";
2749    let fstab = match root.open_optional(fstab_path)? {
2750        Some(f) => f,
2751        None => return Ok(None),
2752    };
2753
2754    let reader = std::io::BufReader::new(fstab);
2755    for line in std::io::BufRead::lines(reader) {
2756        let line = line?;
2757        let line = line.trim();
2758
2759        // Skip empty lines and comments
2760        if line.is_empty() || line.starts_with('#') {
2761            continue;
2762        }
2763
2764        // Parse the mount spec
2765        let spec = MountSpec::from_str(line)?;
2766
2767        // Check if this is a /boot entry
2768        if spec.target == "/boot" {
2769            return Ok(Some(spec));
2770        }
2771    }
2772
2773    Ok(None)
2774}
2775
2776pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> {
2777    let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2778    if !opts.experimental {
2779        anyhow::bail!("This command requires --experimental");
2780    }
2781
2782    let prog: ProgressWriter = opts.progress.try_into()?;
2783
2784    let sysroot = &crate::cli::get_storage().await?;
2785    let ostree = sysroot.get_ostree()?;
2786    let repo = &ostree.repo();
2787    let (booted_ostree, _deployments, host) = crate::status::get_status_require_booted(ostree)?;
2788
2789    let stateroots = list_stateroots(ostree)?;
2790    let target_stateroot = if let Some(s) = opts.stateroot {
2791        s
2792    } else {
2793        let now = chrono::Utc::now();
2794        let r = allocate_new_stateroot(&ostree, &stateroots, now)?;
2795        r.name
2796    };
2797
2798    let booted_stateroot = booted_ostree.stateroot();
2799    assert!(booted_stateroot.as_str() != target_stateroot);
2800    let (fetched, spec) = if let Some(target) = opts.target_opts.imageref()? {
2801        let mut new_spec = host.spec;
2802        new_spec.image = Some(target.into());
2803        let fetched = crate::deploy::pull(
2804            repo,
2805            &new_spec.image.as_ref().unwrap(),
2806            None,
2807            opts.quiet,
2808            prog.clone(),
2809            None,
2810        )
2811        .await?;
2812        (fetched, new_spec)
2813    } else {
2814        let imgstate = host
2815            .status
2816            .booted
2817            .map(|b| b.query_image(repo))
2818            .transpose()?
2819            .flatten()
2820            .ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
2821        (Box::new((*imgstate).into()), host.spec)
2822    };
2823    let spec = crate::deploy::RequiredHostSpec::from_spec(&spec)?;
2824
2825    // Compute the kernel arguments to inherit. By default, that's only those involved
2826    // in the root filesystem.
2827    let mut kargs = crate::bootc_kargs::get_kargs_in_root(rootfs, std::env::consts::ARCH)?;
2828
2829    // Extend with root kargs
2830    if !opts.no_root_kargs {
2831        let bootcfg = booted_ostree
2832            .deployment
2833            .bootconfig()
2834            .ok_or_else(|| anyhow!("Missing bootcfg for booted deployment"))?;
2835        if let Some(options) = bootcfg.get("options") {
2836            let options_cmdline = Cmdline::from(options.as_str());
2837            let root_kargs = crate::bootc_kargs::root_args_from_cmdline(&options_cmdline);
2838            kargs.extend(&root_kargs);
2839        }
2840    }
2841
2842    // Extend with user-provided kargs
2843    if let Some(user_kargs) = opts.karg.as_ref() {
2844        for karg in user_kargs {
2845            kargs.extend(karg);
2846        }
2847    }
2848
2849    let from = MergeState::Reset {
2850        stateroot: target_stateroot.clone(),
2851        kargs,
2852    };
2853    crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone(), false).await?;
2854
2855    // Copy /boot entry from /etc/fstab to the new stateroot if it exists
2856    if let Some(boot_spec) = read_boot_fstab_entry(rootfs)? {
2857        let staged_deployment = ostree
2858            .staged_deployment()
2859            .ok_or_else(|| anyhow!("No staged deployment found"))?;
2860        let deployment_path = ostree.deployment_dirpath(&staged_deployment);
2861        let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
2862        let deployment_root = sysroot_dir.open_dir(&deployment_path)?;
2863
2864        // Write the /boot entry to /etc/fstab in the new deployment
2865        crate::lsm::atomic_replace_labeled(
2866            &deployment_root,
2867            "etc/fstab",
2868            0o644.into(),
2869            None,
2870            |w| writeln!(w, "{}", boot_spec.to_fstab()).map_err(Into::into),
2871        )?;
2872
2873        tracing::debug!(
2874            "Copied /boot entry to new stateroot: {}",
2875            boot_spec.to_fstab()
2876        );
2877    }
2878
2879    sysroot.update_mtime()?;
2880
2881    if opts.apply {
2882        crate::reboot::reboot()?;
2883    }
2884    Ok(())
2885}
2886
2887/// Implementation of `bootc install finalize`.
2888pub(crate) async fn install_finalize(target: &Utf8Path) -> Result<()> {
2889    // Log the installation finalization operation to systemd journal
2890    const INSTALL_FINALIZE_JOURNAL_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
2891
2892    tracing::info!(
2893        message_id = INSTALL_FINALIZE_JOURNAL_ID,
2894        bootc.target_path = target.as_str(),
2895        "Starting installation finalization for target: {}",
2896        target
2897    );
2898
2899    crate::cli::require_root(false)?;
2900    let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(target)));
2901    sysroot.load(gio::Cancellable::NONE)?;
2902    let deployments = sysroot.deployments();
2903    // Verify we find a deployment
2904    if deployments.is_empty() {
2905        anyhow::bail!("Failed to find deployment in {target}");
2906    }
2907
2908    // Log successful finalization
2909    tracing::info!(
2910        message_id = INSTALL_FINALIZE_JOURNAL_ID,
2911        bootc.target_path = target.as_str(),
2912        "Successfully finalized installation for target: {}",
2913        target
2914    );
2915
2916    // For now that's it! We expect to add more validation/postprocessing
2917    // later, such as munging `etc/fstab` if needed. See
2918
2919    Ok(())
2920}
2921
2922#[cfg(test)]
2923mod tests {
2924    use super::*;
2925
2926    #[test]
2927    fn install_opts_serializable() {
2928        let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
2929            "device": "/dev/vda"
2930        }))
2931        .unwrap();
2932        assert_eq!(c.block_opts.device, "/dev/vda");
2933    }
2934
2935    #[test]
2936    fn test_mountspec() {
2937        let mut ms = MountSpec::new("/dev/vda4", "/boot");
2938        assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto defaults 0 0");
2939        ms.push_option("ro");
2940        assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto ro 0 0");
2941        ms.push_option("relatime");
2942        assert_eq!(ms.to_fstab(), "/dev/vda4 /boot auto ro,relatime 0 0");
2943    }
2944
2945    #[test]
2946    fn test_gather_root_args() {
2947        // A basic filesystem using a UUID
2948        let inspect = Filesystem {
2949            source: "/dev/vda4".into(),
2950            target: "/".into(),
2951            fstype: "xfs".into(),
2952            maj_min: "252:4".into(),
2953            options: "rw".into(),
2954            uuid: Some("965eb3c7-5a3f-470d-aaa2-1bcf04334bc6".into()),
2955            children: None,
2956        };
2957        let kargs = bytes::Cmdline::from("");
2958        let r = find_root_args_to_inherit(&kargs, &inspect).unwrap();
2959        assert_eq!(r.mount_spec, "UUID=965eb3c7-5a3f-470d-aaa2-1bcf04334bc6");
2960
2961        let kargs = bytes::Cmdline::from(
2962            "root=/dev/mapper/root rw someother=karg rd.lvm.lv=root systemd.debug=1",
2963        );
2964
2965        // In this case we take the root= from the kernel cmdline
2966        let r = find_root_args_to_inherit(&kargs, &inspect).unwrap();
2967        assert_eq!(r.mount_spec, "/dev/mapper/root");
2968        assert_eq!(r.kargs.len(), 1);
2969        assert_eq!(r.kargs[0], "rd.lvm.lv=root");
2970
2971        // non-UTF8 data in non-essential parts of the cmdline should be ignored
2972        let kargs = bytes::Cmdline::from(
2973            b"root=/dev/mapper/root rw non-utf8=\xff rd.lvm.lv=root systemd.debug=1",
2974        );
2975        let r = find_root_args_to_inherit(&kargs, &inspect).unwrap();
2976        assert_eq!(r.mount_spec, "/dev/mapper/root");
2977        assert_eq!(r.kargs.len(), 1);
2978        assert_eq!(r.kargs[0], "rd.lvm.lv=root");
2979
2980        // non-UTF8 data in `root` should fail
2981        let kargs = bytes::Cmdline::from(
2982            b"root=/dev/mapper/ro\xffot rw non-utf8=\xff rd.lvm.lv=root systemd.debug=1",
2983        );
2984        let r = find_root_args_to_inherit(&kargs, &inspect);
2985        assert!(r.is_err());
2986
2987        // non-UTF8 data in `rd.` should fail
2988        let kargs = bytes::Cmdline::from(
2989            b"root=/dev/mapper/root rw non-utf8=\xff rd.lvm.lv=ro\xffot systemd.debug=1",
2990        );
2991        let r = find_root_args_to_inherit(&kargs, &inspect);
2992        assert!(r.is_err());
2993    }
2994
2995    // As this is a unit test we don't try to test mountpoints, just verify
2996    // that we have the equivalent of rm -rf *
2997    #[test]
2998    fn test_remove_all_noxdev() -> Result<()> {
2999        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3000
3001        td.create_dir_all("foo/bar/baz")?;
3002        td.write("foo/bar/baz/test", b"sometest")?;
3003        td.symlink_contents("/absolute-nonexistent-link", "somelink")?;
3004        td.write("toptestfile", b"othertestcontents")?;
3005
3006        remove_all_in_dir_no_xdev(&td, true).unwrap();
3007
3008        assert_eq!(td.entries()?.count(), 0);
3009
3010        Ok(())
3011    }
3012
3013    #[test]
3014    fn test_read_boot_fstab_entry() -> Result<()> {
3015        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3016
3017        // Test with no /etc/fstab
3018        assert!(read_boot_fstab_entry(&td)?.is_none());
3019
3020        // Test with /etc/fstab but no /boot entry
3021        td.create_dir("etc")?;
3022        td.write("etc/fstab", "UUID=test-uuid / ext4 defaults 0 0\n")?;
3023        assert!(read_boot_fstab_entry(&td)?.is_none());
3024
3025        // Test with /boot entry
3026        let fstab_content = "\
3027# /etc/fstab
3028UUID=root-uuid / ext4 defaults 0 0
3029UUID=boot-uuid /boot ext4 ro 0 0
3030UUID=home-uuid /home ext4 defaults 0 0
3031";
3032        td.write("etc/fstab", fstab_content)?;
3033        let boot_spec = read_boot_fstab_entry(&td)?.unwrap();
3034        assert_eq!(boot_spec.source, "UUID=boot-uuid");
3035        assert_eq!(boot_spec.target, "/boot");
3036        assert_eq!(boot_spec.fstype, "ext4");
3037        assert_eq!(boot_spec.options, Some("ro".to_string()));
3038
3039        // Test with /boot entry with comments
3040        let fstab_content = "\
3041# /etc/fstab
3042# Created by anaconda
3043UUID=root-uuid / ext4 defaults 0 0
3044# Boot partition
3045UUID=boot-uuid /boot ext4 defaults 0 0
3046";
3047        td.write("etc/fstab", fstab_content)?;
3048        let boot_spec = read_boot_fstab_entry(&td)?.unwrap();
3049        assert_eq!(boot_spec.source, "UUID=boot-uuid");
3050        assert_eq!(boot_spec.target, "/boot");
3051
3052        Ok(())
3053    }
3054
3055    #[test]
3056    fn test_require_dir_contains_only_mounts() -> Result<()> {
3057        // Test 1: Empty directory should fail (not a mount point)
3058        {
3059            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3060            td.create_dir("empty")?;
3061            assert!(require_dir_contains_only_mounts(&td, "empty").is_err());
3062        }
3063
3064        // Test 2: Directory with only lost+found should succeed (lost+found is ignored)
3065        {
3066            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3067            td.create_dir_all("var/lost+found")?;
3068            assert!(require_dir_contains_only_mounts(&td, "var").is_ok());
3069        }
3070
3071        // Test 3: Directory with a regular file should fail
3072        {
3073            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3074            td.create_dir("var")?;
3075            td.write("var/test.txt", b"content")?;
3076            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3077        }
3078
3079        // Test 4: Nested directory structure with a file should fail
3080        {
3081            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3082            td.create_dir_all("var/lib/containers")?;
3083            td.write("var/lib/containers/storage.db", b"data")?;
3084            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3085        }
3086
3087        // Test 5: boot directory with grub should fail (grub2 is not a mount and contains files)
3088        {
3089            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3090            td.create_dir_all("boot/grub2")?;
3091            td.write("boot/grub2/grub.cfg", b"config")?;
3092            assert!(require_dir_contains_only_mounts(&td, "boot").is_err());
3093        }
3094
3095        // Test 6: Nested empty directories should fail (empty directories are not mount points)
3096        {
3097            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3098            td.create_dir_all("var/lib/containers")?;
3099            td.create_dir_all("var/log/journal")?;
3100            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3101        }
3102
3103        // Test 7: Directory with lost+found and a file should fail (lost+found is ignored, but file is not allowed)
3104        {
3105            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3106            td.create_dir_all("var/lost+found")?;
3107            td.write("var/data.txt", b"content")?;
3108            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3109        }
3110
3111        // Test 8: Directory with a symlink should fail
3112        {
3113            let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3114            td.create_dir("var")?;
3115            td.symlink_contents("../usr/lib", "var/lib")?;
3116            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3117        }
3118
3119        // Test 9: Deeply nested directory 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/storage/overlay")?;
3123            td.write("var/lib/containers/storage/overlay/file.txt", b"data")?;
3124            assert!(require_dir_contains_only_mounts(&td, "var").is_err());
3125        }
3126
3127        Ok(())
3128    }
3129
3130    #[test]
3131    fn test_delete_kargs() -> Result<()> {
3132        let mut cmdline = Cmdline::from("console=tty0 quiet debug nosmt foo=bar foo=baz bar=baz");
3133
3134        let deletions = vec!["foo=bar", "bar", "debug"];
3135
3136        delete_kargs(&mut cmdline, &deletions);
3137
3138        let result = cmdline.to_string();
3139        assert!(!result.contains("foo=bar"));
3140        assert!(!result.contains("bar"));
3141        assert!(!result.contains("debug"));
3142        assert!(result.contains("foo=baz"));
3143
3144        Ok(())
3145    }
3146}