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