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