bootc_lib/
install.rs

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