Skip to main content

bootc_lib/bootc_composefs/
boot.rs

1//! Composefs boot setup and configuration.
2//!
3//! This module handles setting up boot entries for composefs-based deployments,
4//! including generating BLS (Boot Loader Specification) entries, copying kernel/initrd
5//! files, managing UKI (Unified Kernel Images), and configuring the ESP (EFI System
6//! Partition).
7//!
8//! ## Boot Ordering
9//!
10//! A critical aspect of this module is boot entry ordering, which must work correctly
11//! across both Grub and systemd-boot bootloaders despite their fundamentally different
12//! sorting behaviors.
13//!
14//! ## Critical Context: Grub's Filename Parsing
15//!
16//! **Grub does NOT read BLS fields** - it parses the filename as an RPM package name!
17//! See: <https://github.com/ostreedev/ostree/issues/2961>
18//!
19//! Grub's `split_package_string()` parsing algorithm:
20//! 1. Strip `.conf` suffix
21//! 2. Find LAST `-` → extract **release** field
22//! 3. Find SECOND-TO-LAST `-` → extract **version** field
23//! 4. Remainder → **name** field
24//!
25//! Example: `kernel-5.14.0-362.fc38.conf`
26//! - name: `kernel`
27//! - version: `5.14.0`
28//! - release: `362.fc38`
29//!
30//! **Critical:** Grub sorts by (name, version, release) in DESCENDING order.
31//!
32//! ## Bootloader Differences
33//!
34//! ### Grub
35//! - Ignores BLS sort-key field completely
36//! - Parses filename to extract name-version-release
37//! - Sorts by (name, version, release) DESCENDING
38//! - Any `-` in name/version gets incorrectly split
39//!
40//! ### Systemd-boot
41//! - Reads BLS sort-key field
42//! - Sorts by sort-key ASCENDING (A→Z, 0→9)
43//! - Filename is mostly irrelevant
44//!
45//! ## Implementation Strategy
46//!
47//! **Filenames** (for Grub's RPM-style parsing and descending sort):
48//! - Format: `bootc_{os_id}-{version}-{priority}.conf`
49//! - Replace `-` with `_` in os_id to prevent mis-parsing
50//! - Primary: `bootc_fedora-41.20251125.0-1.conf` → (name=bootc_fedora, version=41.20251125.0, release=1)
51//! - Secondary: `bootc_fedora-41.20251124.0-0.conf` → (name=bootc_fedora, version=41.20251124.0, release=0)
52//! - Grub sorts: Primary (release=1) > Secondary (release=0) when versions equal
53//!
54//! **Sort-keys** (for systemd-boot's ascending sort):
55//! - Primary: `bootc-{os_id}-0` (lower value, sorts first)
56//! - Secondary: `bootc-{os_id}-1` (higher value, sorts second)
57//!
58//! ## Boot Entry Ordering
59//!
60//! After an upgrade, both bootloaders show:
61//! 1. **Primary**: New/upgraded deployment (default boot target)
62//! 2. **Secondary**: Currently booted deployment (rollback option)
63
64use std::cell::Cell;
65use std::fs::create_dir_all;
66use std::io::{Read, Seek, SeekFrom, Write};
67use std::path::Path;
68use std::sync::Arc;
69
70use anyhow::{Context, Result, anyhow, bail};
71use bootc_mount::tempmount::TempMount;
72use camino::{Utf8Path, Utf8PathBuf};
73use cap_std_ext::{
74    cap_std::{ambient_authority, fs::Dir},
75    dirext::CapStdExtDirExt,
76};
77use clap::ValueEnum;
78use composefs::fs::read_file;
79use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
80use composefs::tree::RegularFile;
81use composefs_boot::bootloader::{
82    BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType,
83    UsrLibModulesVmlinuz, get_boot_resources,
84};
85use composefs_boot::{
86    cmdline::ComposefsCmdline as ComposefsBootCmdline, os_release::OsReleaseInfo, uki,
87};
88use composefs_ctl::composefs;
89use composefs_ctl::composefs_boot;
90use composefs_ctl::composefs_oci;
91use fn_error_context::context;
92use linux_kernel_cmdline::utf8::{Cmdline, Parameter};
93use rustix::{mount::MountFlags, path::Arg};
94use schemars::JsonSchema;
95use serde::{Deserialize, Serialize};
96
97use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state};
98use crate::bootc_composefs::status::ComposefsCmdline;
99use crate::bootc_kargs::compute_new_kargs;
100use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED};
101use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey};
102use crate::spec::BootloaderKind;
103use crate::task::Task;
104use crate::{bootc_composefs::repo::open_composefs_repo, store::Storage};
105use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState};
106use crate::{
107    composefs_consts::{
108        BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED,
109    },
110    spec::{Bootloader, Host},
111};
112use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs};
113
114use crate::install::{RootSetup, State};
115
116/// Contains the EFP's filesystem UUID. Used by grub
117pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg";
118/// The EFI Linux directory
119pub(crate) const EFI_LINUX: &str = "EFI/Linux";
120
121/// Timeout for systemd-boot bootloader menu
122const SYSTEMD_TIMEOUT: &str = "timeout 5";
123const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf";
124
125pub(crate) const INITRD: &str = "initrd";
126pub(crate) const VMLINUZ: &str = "vmlinuz";
127
128const BOOTC_AUTOENROLL_PATH: &str = "usr/lib/bootc/install/secureboot-keys";
129
130const AUTH_EXT: &str = "auth";
131
132/// We want to be able to control the ordering of UKIs so we put them in a directory that's not the
133/// directory specified by the BLS spec. We do this because we want systemd-boot to only look at
134/// our config files and not show the actual UKIs in the bootloader menu
135/// This is relative to the ESP
136pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc";
137
138pub(crate) enum BootSetupType<'a> {
139    /// For initial setup, i.e. install to-disk
140    Setup((&'a RootSetup, &'a State, &'a PostFetchState)),
141    /// For `bootc upgrade`
142    Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)),
143}
144
145#[derive(
146    ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema,
147)]
148pub enum BootType {
149    #[default]
150    Bls,
151    Uki,
152}
153
154impl ::std::fmt::Display for BootType {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        let s = match self {
157            BootType::Bls => "bls",
158            BootType::Uki => "uki",
159        };
160
161        write!(f, "{}", s)
162    }
163}
164
165impl TryFrom<&str> for BootType {
166    type Error = anyhow::Error;
167
168    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
169        match value {
170            "bls" => Ok(Self::Bls),
171            "uki" => Ok(Self::Uki),
172            unrecognized => Err(anyhow::anyhow!(
173                "Unrecognized boot option: '{unrecognized}'"
174            )),
175        }
176    }
177}
178
179impl From<&ComposefsBootEntry<Sha512HashValue>> for BootType {
180    fn from(entry: &ComposefsBootEntry<Sha512HashValue>) -> Self {
181        match entry {
182            ComposefsBootEntry::Type1(..) => Self::Bls,
183            ComposefsBootEntry::Type2(..) => Self::Uki,
184            ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls,
185        }
186    }
187}
188
189/// Returns the beginning of the grub2/user.cfg file
190/// where we source a file containing the ESPs filesystem UUID
191pub(crate) fn get_efi_uuid_source() -> String {
192    format!(
193        r#"
194if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then
195        source ${{config_directory}}/{EFI_UUID_FILE}
196fi
197"#
198    )
199}
200
201/// Mount flags shared by all ESP mounts: non-executable, no setuid.
202const ESP_MOUNT_FLAGS: MountFlags =
203    MountFlags::from_bits_retain(MountFlags::NOEXEC.bits() | MountFlags::NOSUID.bits());
204
205/// FAT mount options: owner-only permissions on files (0600) and dirs (0700).
206const ESP_MOUNT_DATA: &std::ffi::CStr = c"fmask=0177,dmask=0077";
207
208/// Mount the ESP from the provided device into a temporary directory.
209pub fn mount_esp(device: &str) -> Result<TempMount> {
210    TempMount::mount_dev(device, "vfat", ESP_MOUNT_FLAGS, Some(ESP_MOUNT_DATA))
211}
212
213/// Mount the ESP from `device` at the given path and return a guard that
214/// synchronously unmounts (and flushes) it on drop.
215pub(crate) fn mount_esp_at(
216    device: &str,
217    path: std::path::PathBuf,
218) -> Result<bootc_mount::tempmount::MountGuard> {
219    bootc_mount::tempmount::MountGuard::mount(
220        device,
221        path,
222        "vfat",
223        ESP_MOUNT_FLAGS,
224        Some(ESP_MOUNT_DATA),
225    )
226}
227
228/// Filename release field for primary (new/upgraded) entry.
229/// Grub parses this as the "release" field and sorts descending, so "1" > "0".
230pub(crate) const FILENAME_PRIORITY_PRIMARY: &str = "1";
231
232/// Filename release field for secondary (currently booted) entry.
233pub(crate) const FILENAME_PRIORITY_SECONDARY: &str = "0";
234
235/// Sort-key priority for primary (new/upgraded) entry.
236/// Systemd-boot sorts by sort-key in ascending order, so "0" appears before "1".
237pub(crate) const SORTKEY_PRIORITY_PRIMARY: &str = "0";
238
239/// Sort-key priority for secondary (currently booted) entry.
240pub(crate) const SORTKEY_PRIORITY_SECONDARY: &str = "1";
241
242/// Generate BLS Type 1 entry filename compatible with Grub's RPM-style parsing.
243///
244/// Format: `bootc_{os_id}-{version}-{priority}.conf`
245///
246/// Grub parses this as:
247/// - name: `bootc_{os_id}` (hyphens in os_id replaced with underscores)
248/// - version: `{version}`
249/// - release: `{priority}`
250///
251/// The underscore replacement prevents Grub from mis-parsing os_id values
252/// containing hyphens (e.g., "fedora-coreos" → "fedora_coreos").
253pub fn type1_entry_conf_file_name(
254    os_id: &str,
255    version: impl std::fmt::Display,
256    priority: &str,
257) -> String {
258    let os_id_safe = os_id.replace('-', "_");
259    format!("bootc_{os_id_safe}-{version}-{priority}.conf")
260}
261
262/// Generate sort key for the primary (new/upgraded) boot entry.
263/// Format: bootc-{id}-0
264/// Systemd-boot sorts ascending by sort-key, so "0" comes first.
265/// Grub ignores sort-key and uses filename/version ordering.
266pub(crate) fn primary_sort_key(os_id: &str) -> String {
267    format!("bootc-{os_id}-{SORTKEY_PRIORITY_PRIMARY}")
268}
269
270/// Generate sort key for the secondary (currently booted) boot entry.
271/// Format: bootc-{id}-1
272pub(crate) fn secondary_sort_key(os_id: &str) -> String {
273    format!("bootc-{os_id}-{SORTKEY_PRIORITY_SECONDARY}")
274}
275
276/// Returns the name of the directory where we store Type1 boot entries
277pub(crate) fn get_type1_dir_name(depl_verity: &str) -> String {
278    format!("{TYPE1_BOOT_DIR_PREFIX}{depl_verity}")
279}
280
281/// Returns the name of a UKI given verity digest
282pub(crate) fn get_uki_name(depl_verity: &str) -> String {
283    format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_EXT}")
284}
285
286/// Returns the name of a UKI Addon directory given verity digest
287pub(crate) fn get_uki_addon_dir_name(depl_verity: &str) -> String {
288    format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_DIR_EXT}")
289}
290
291#[allow(dead_code)]
292/// Returns the name of a UKI Addon given verity digest
293pub(crate) fn get_uki_addon_file_name(depl_verity: &str) -> String {
294    format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_FILE_EXT}")
295}
296
297/// Compute SHA256Sum of VMlinuz + Initrd
298///
299/// # Arguments
300/// * entry - BootEntry containing VMlinuz and Initrd
301/// * repo - The composefs repository
302#[context("Computing boot digest")]
303fn compute_boot_digest(
304    entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
305    repo: &crate::store::ComposefsRepository,
306) -> Result<String> {
307    let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?;
308
309    let Some(initramfs) = &entry.initramfs else {
310        anyhow::bail!("initramfs not found");
311    };
312
313    let initramfs = read_file(initramfs, &repo).context("Reading intird")?;
314
315    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
316        .context("Creating hasher")?;
317
318    hasher.update(&vmlinuz).context("hashing vmlinuz")?;
319    hasher.update(&initramfs).context("hashing initrd")?;
320
321    let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
322
323    Ok(hex::encode(digest))
324}
325
326#[context("Computing boot digest for Type1 entries")]
327fn compute_boot_digest_type1(dir: &Dir) -> Result<String> {
328    let mut vmlinuz = dir
329        .open(VMLINUZ)
330        .with_context(|| format!("Opening {VMLINUZ}"))?;
331
332    let mut initrd = dir
333        .open(INITRD)
334        .with_context(|| format!("Opening {INITRD}"))?;
335
336    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
337        .context("Creating hasher")?;
338
339    std::io::copy(&mut vmlinuz, &mut hasher)?;
340    std::io::copy(&mut initrd, &mut hasher)?;
341
342    let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
343
344    Ok(hex::encode(digest))
345}
346
347/// Compute SHA256Sum of .linux + .initrd section of the UKI
348///
349/// # Arguments
350/// * entry - BootEntry containing VMlinuz and Initrd
351/// * repo - The composefs repository
352#[context("Computing boot digest")]
353pub(crate) fn compute_boot_digest_uki<R: Read + Seek>(uki_reader: &mut R) -> Result<String> {
354    let vmlinuz = uki::get_section_buffered(uki_reader, ".linux").context(".linux not present")?;
355    uki_reader
356        .seek(SeekFrom::Start(0))
357        .context("Moving seek to 0")?;
358    let initramfs =
359        uki::get_section_buffered(uki_reader, ".initrd").context(".initrd not present")?;
360
361    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
362        .context("Creating hasher")?;
363
364    hasher.update(&vmlinuz).context("hashing vmlinuz")?;
365    hasher.update(&initramfs).context("hashing initrd")?;
366
367    let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
368
369    Ok(hex::encode(digest))
370}
371
372/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum
373///
374/// # Returns
375/// Returns the directory name that has the same sha256 digest for vmlinuz + initrd as the one
376/// that's passed in
377#[context("Checking boot entry duplicates")]
378pub(crate) fn find_vmlinuz_initrd_duplicate(
379    storage: &Storage,
380    digest: &str,
381) -> Result<Option<String>> {
382    let boot_dir = storage.bls_boot_binaries_dir()?;
383
384    for entry in boot_dir.entries_utf8()? {
385        let entry = entry?;
386        let dir_name = entry.file_name()?;
387
388        if !entry.file_type()?.is_dir() {
389            continue;
390        }
391
392        let Some(..) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
393            continue;
394        };
395
396        let entry_digest = compute_boot_digest_type1(&boot_dir.open_dir(&dir_name)?)?;
397
398        if entry_digest == digest {
399            return Ok(Some(dir_name));
400        }
401    }
402
403    Ok(None)
404}
405
406#[context("Writing BLS entries to disk")]
407fn write_bls_boot_entries_to_disk(
408    boot_dir: &Utf8PathBuf,
409    deployment_id: &Sha512HashValue,
410    entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
411    repo: &crate::store::ComposefsRepository,
412) -> Result<()> {
413    let dir_name = get_type1_dir_name(&deployment_id.to_hex());
414
415    // Write the initrd and vmlinuz at /boot/composefs-<id>/
416    let path = boot_dir.join(&dir_name);
417    create_dir_all(&path)?;
418
419    let entries_dir = Dir::open_ambient_dir(&path, ambient_authority())
420        .with_context(|| format!("Opening {path}"))?;
421
422    entries_dir
423        .atomic_write(
424            VMLINUZ,
425            read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?,
426        )
427        .context("Writing vmlinuz to path")?;
428
429    let Some(initramfs) = &entry.initramfs else {
430        anyhow::bail!("initramfs not found");
431    };
432
433    entries_dir
434        .atomic_write(
435            INITRD,
436            read_file(initramfs, &repo).context("Reading initrd")?,
437        )
438        .context("Writing initrd to path")?;
439
440    // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd
441    let owned_fd = entries_dir
442        .reopen_as_ownedfd()
443        .context("Reopen as owned fd")?;
444
445    rustix::fs::fsync(owned_fd).context("fsync")?;
446
447    Ok(())
448}
449
450/// Parses /usr/lib/os-release and returns (id, title, version)
451/// Expects a reference to the root of the filesystem, or the root
452/// of a mounted EROFS
453pub fn parse_os_release(root: &Dir) -> Result<Option<(String, Option<String>, Option<String>)>> {
454    // Every update should have its own /usr/lib/os-release
455    let file = root
456        .open_optional("usr/lib/os-release")
457        .context("Opening usr/lib/os-release")?;
458
459    let Some(mut os_rel_file) = file else {
460        return Ok(None);
461    };
462
463    let mut file_contents = String::new();
464    os_rel_file.read_to_string(&mut file_contents)?;
465
466    let parsed = OsReleaseInfo::parse(&file_contents);
467
468    let os_id = parsed
469        .get_value(&["ID"])
470        .unwrap_or_else(|| "bootc".to_string());
471
472    Ok(Some((
473        os_id,
474        parsed.get_pretty_name(),
475        parsed.get_version(),
476    )))
477}
478
479struct BLSEntryPath {
480    /// Where to write vmlinuz/initrd
481    entries_path: Utf8PathBuf,
482    /// The absolute path, with reference to the partition's root, where the vmlinuz/initrd are written to
483    abs_entries_path: Utf8PathBuf,
484    /// Where to write the .conf files
485    config_path: Utf8PathBuf,
486}
487
488/// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk
489///
490/// # Returns
491/// Returns the SHA256Sum of VMLinuz + Initrd combo. Error if any
492#[context("Setting up BLS boot")]
493pub(crate) fn setup_composefs_bls_boot(
494    setup_type: BootSetupType,
495    repo: crate::store::ComposefsRepository,
496    id: &Sha512HashValue,
497    entry: &ComposefsBootEntry<Sha512HashValue>,
498    mounted_erofs: &Dir,
499) -> Result<String> {
500    let id_hex = id.to_hex();
501
502    let (root_path, esp_device, mut cmdline_refs, bootloader) = match setup_type {
503        BootSetupType::Setup((root_setup, state, postfetch)) => {
504            // root_setup.kargs has [root=UUID=<UUID>, "rw"]
505            let mut cmdline_options = Cmdline::new();
506
507            cmdline_options.extend(&root_setup.kargs);
508
509            if let Some(user_kargs) = &state.config_opts.karg {
510                for karg in user_kargs {
511                    cmdline_options.extend(karg);
512                }
513            }
514
515            let composefs_cmdline =
516                ComposefsCmdline::build(&id_hex, state.composefs_options.allow_missing_verity);
517            cmdline_options.extend(&Cmdline::from(&composefs_cmdline.to_string()));
518
519            // If there's a separate /boot partition, add a systemd.mount-extra
520            // karg so systemd mounts it after reboot. This avoids writing to
521            // /etc/fstab which conflicts with transient etc (see #1388).
522            if let Some(boot) = root_setup.boot_mount_spec() {
523                if !boot.source.is_empty() {
524                    let mount_extra = format!(
525                        "systemd.mount-extra={}:/boot:{}:{}",
526                        boot.source,
527                        boot.fstype,
528                        boot.options.as_deref().unwrap_or("defaults"),
529                    );
530                    cmdline_options.extend(&Cmdline::from(mount_extra.as_str()));
531                    tracing::debug!("Added /boot mount karg: {mount_extra}");
532                }
533            }
534
535            // Locate ESP partition device by walking up to the root disk(s)
536            let esp_part = root_setup.device_info.find_first_colocated_esp()?;
537
538            (
539                root_setup.physical_root_path.clone(),
540                esp_part.path(),
541                cmdline_options,
542                postfetch.detected_bootloader.clone(),
543            )
544        }
545
546        BootSetupType::Upgrade((storage, booted_cfs, host)) => {
547            let bootloader = host.require_composefs_booted()?.bootloader.clone();
548
549            let boot_dir = storage.require_boot_dir()?;
550            let current_cfg = get_booted_bls(&boot_dir, booted_cfs)?;
551
552            let mut cmdline = match current_cfg.cfg_type {
553                BLSConfigType::NonEFI { options, .. } => {
554                    let options = options
555                        .ok_or_else(|| anyhow::anyhow!("No 'options' found in BLS Config"))?;
556
557                    Cmdline::from(options)
558                }
559
560                _ => anyhow::bail!("Found NonEFI config"),
561            };
562
563            // Copy all cmdline args, replacing only `composefs=`
564            let cfs_cmdline =
565                ComposefsCmdline::build(&id_hex, booted_cfs.cmdline.allow_missing_fsverity)
566                    .to_string();
567
568            let param = Parameter::parse(&cfs_cmdline)
569                .context("Failed to create 'composefs=' parameter")?;
570            cmdline.add_or_modify(&param);
571
572            // Locate ESP partition device by walking up to the root disk(s)
573            let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?;
574            let esp_dev = root_dev.find_first_colocated_esp()?;
575
576            (
577                Utf8PathBuf::from("/sysroot"),
578                esp_dev.path(),
579                cmdline,
580                bootloader,
581            )
582        }
583    };
584
585    let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
586
587    let current_root = if is_upgrade {
588        Some(&Dir::open_ambient_dir("/", ambient_authority()).context("Opening root")? as &Dir)
589    } else {
590        None
591    };
592
593    compute_new_kargs(mounted_erofs, current_root, &mut cmdline_refs)?;
594
595    let (entry_paths, _tmpdir_guard) = match bootloader.kind()? {
596        BootloaderKind::GRUBClassic => {
597            let root = Dir::open_ambient_dir(&root_path, ambient_authority())
598                .context("Opening root path")?;
599
600            // Grub wants the paths to be absolute against the mounted drive that the kernel +
601            // initrd live in
602            //
603            // If "boot" is a partition, we want the paths to be absolute to "/"
604            let entries_path = match root.is_mountpoint("boot")? {
605                Some(true) => "/",
606                // We can be fairly sure that the kernels we target support `statx`
607                Some(false) | None => "/boot",
608            };
609
610            (
611                BLSEntryPath {
612                    entries_path: root_path.join("boot"),
613                    config_path: root_path.join("boot"),
614                    abs_entries_path: entries_path.into(),
615                },
616                None,
617            )
618        }
619
620        BootloaderKind::BLSCompatible => {
621            let efi_mount = mount_esp(&esp_device).context("Mounting ESP")?;
622
623            let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?);
624            let efi_linux_dir = mounted_efi.join(EFI_LINUX);
625
626            (
627                BLSEntryPath {
628                    entries_path: efi_linux_dir,
629                    config_path: mounted_efi.clone(),
630                    abs_entries_path: Utf8PathBuf::from("/").join(EFI_LINUX),
631                },
632                Some(efi_mount),
633            )
634        }
635    };
636
637    let (bls_config, boot_digest, os_id) = match &entry {
638        ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"),
639        ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"),
640
641        ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => {
642            let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo)
643                .context("Computing boot digest")?;
644
645            let osrel = parse_os_release(mounted_erofs)?;
646
647            let (os_id, title, version, sort_key) = match osrel {
648                Some((id_str, title_opt, version_opt)) => (
649                    id_str.clone(),
650                    title_opt.unwrap_or_else(|| id.to_hex()),
651                    version_opt.unwrap_or_else(|| id.to_hex()),
652                    primary_sort_key(&id_str),
653                ),
654                None => {
655                    let default_id = "bootc".to_string();
656                    (
657                        default_id.clone(),
658                        id.to_hex(),
659                        id.to_hex(),
660                        primary_sort_key(&default_id),
661                    )
662                }
663            };
664
665            let mut bls_config = BLSConfig::default();
666
667            let entries_dir = get_type1_dir_name(&id_hex);
668
669            bls_config
670                .with_title(title)
671                .with_version(version)
672                .with_sort_key(sort_key)
673                .with_cfg(BLSConfigType::NonEFI {
674                    linux: entry_paths
675                        .abs_entries_path
676                        .join(&entries_dir)
677                        .join(VMLINUZ),
678                    initrd: vec![entry_paths.abs_entries_path.join(&entries_dir).join(INITRD)],
679                    options: Some(cmdline_refs),
680                });
681
682            let shared_entry = match setup_type {
683                BootSetupType::Setup(_) => None,
684                BootSetupType::Upgrade((storage, ..)) => {
685                    find_vmlinuz_initrd_duplicate(storage, &boot_digest)?
686                }
687            };
688
689            match shared_entry {
690                Some(shared_entry) => {
691                    // Multiple deployments could be using the same kernel + initrd, but there
692                    // would be only one available
693                    //
694                    // Symlinking directories themselves would be better, but vfat does not support
695                    // symlinks
696                    match bls_config.cfg_type {
697                        BLSConfigType::NonEFI {
698                            ref mut linux,
699                            ref mut initrd,
700                            ..
701                        } => {
702                            *linux = entry_paths
703                                .abs_entries_path
704                                .join(&shared_entry)
705                                .join(VMLINUZ);
706
707                            *initrd = vec![
708                                entry_paths
709                                    .abs_entries_path
710                                    .join(&shared_entry)
711                                    .join(INITRD),
712                            ];
713                        }
714
715                        _ => unreachable!(),
716                    };
717                }
718
719                None => {
720                    write_bls_boot_entries_to_disk(
721                        &entry_paths.entries_path,
722                        id,
723                        usr_lib_modules_vmlinuz,
724                        &repo,
725                    )?;
726                }
727            };
728
729            (bls_config, boot_digest, os_id)
730        }
731    };
732
733    let loader_path = entry_paths.config_path.join("loader");
734
735    let (config_path, booted_bls) = if is_upgrade {
736        let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())?;
737
738        let BootSetupType::Upgrade((_, booted_cfs, ..)) = setup_type else {
739            // This is just for sanity
740            unreachable!("enum mismatch");
741        };
742
743        let mut booted_bls = get_booted_bls(&boot_dir, booted_cfs)?;
744        booted_bls.sort_key = Some(secondary_sort_key(&os_id));
745
746        let staged_path = loader_path.join(STAGED_BOOT_LOADER_ENTRIES);
747
748        // Delete the staged entries directory if it exists as we want to overwrite the entries
749        // anyway
750        if boot_dir
751            .remove_all_optional(TYPE1_ENT_PATH_STAGED)
752            .context("Failed to remove staged directory")?
753        {
754            tracing::debug!("Removed existing staged entries directory");
755        }
756
757        // This will be atomically renamed to 'loader/entries' on shutdown/reboot
758        (staged_path, Some(booted_bls))
759    } else {
760        (loader_path.join(BOOT_LOADER_ENTRIES), None)
761    };
762
763    create_dir_all(&config_path).with_context(|| format!("Creating {:?}", config_path))?;
764
765    let loader_entries_dir = Dir::open_ambient_dir(&config_path, ambient_authority())
766        .with_context(|| format!("Opening {config_path:?}"))?;
767
768    loader_entries_dir.atomic_write(
769        type1_entry_conf_file_name(&os_id, &bls_config.version(), FILENAME_PRIORITY_PRIMARY),
770        bls_config.to_string().as_bytes(),
771    )?;
772
773    if let Some(booted_bls) = booted_bls {
774        loader_entries_dir.atomic_write(
775            type1_entry_conf_file_name(&os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
776            booted_bls.to_string().as_bytes(),
777        )?;
778    }
779
780    let owned_loader_entries_fd = loader_entries_dir
781        .reopen_as_ownedfd()
782        .context("Reopening as owned fd")?;
783
784    rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?;
785
786    Ok(boot_digest)
787}
788
789struct UKIInfo {
790    boot_label: String,
791    version: Option<String>,
792    os_id: Option<String>,
793    boot_digest: String,
794}
795
796/// Writes a PortableExecutable to ESP along with any PE specific or Global addons
797#[context("Writing {file_path} to ESP")]
798fn write_pe_to_esp(
799    repo: &crate::store::ComposefsRepository,
800    file: &RegularFile<Sha512HashValue>,
801    file_path: &Utf8Path,
802    pe_type: PEType,
803    uki_id: &Sha512HashValue,
804    missing_fsverity_allowed: bool,
805    mounted_efi: impl AsRef<Path>,
806) -> Result<Option<UKIInfo>> {
807    let mut uki_reader = match file {
808        RegularFile::Inline(..) => {
809            // UKI/Addons would always be large enough to be an external object
810            anyhow::bail!("File too small to be UKI/Addon")
811        }
812        RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
813            std::fs::File::from(repo.open_object(id)?)
814        }
815        RegularFile::Sparse(..) => {
816            anyhow::bail!("Sparse file cannot be a UKI/Addon")
817        }
818    };
819
820    let mut boot_label: Option<UKIInfo> = None;
821
822    // UKI Extension might not even have a cmdline
823    // TODO: UKI Addon might also have a composefs= cmdline?
824    if matches!(pe_type, PEType::Uki) {
825        let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?;
826
827        let composefs_info = ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(&cmdline)
828            .context("Parsing composefs=")?
829            .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?;
830        let composefs_cmdline = composefs_info.digest();
831        let missing_verity_allowed_cmdline = composefs_info.is_insecure();
832
833        // If the UKI cmdline does not match what the user has passed as cmdline option
834        // NOTE: This will only be checked for new installs and now upgrades/switches
835        match missing_fsverity_allowed {
836            true if !missing_verity_allowed_cmdline => {
837                tracing::warn!(
838                    "--allow-missing-fsverity passed as option but UKI cmdline does not support it"
839                );
840            }
841
842            false if missing_verity_allowed_cmdline => {
843                tracing::warn!("UKI cmdline has composefs set as insecure");
844            }
845
846            _ => { /* no-op */ }
847        }
848
849        if *composefs_cmdline != *uki_id {
850            anyhow::bail!(
851                "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})"
852            );
853        }
854
855        uki_reader.seek(SeekFrom::Start(0))?;
856        let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?;
857
858        let parsed_osrel = OsReleaseInfo::parse(&osrel);
859
860        uki_reader.seek(SeekFrom::Start(0))?;
861        let boot_digest = compute_boot_digest_uki(&mut uki_reader)?;
862
863        uki_reader.seek(SeekFrom::Start(0))?;
864        boot_label = Some(UKIInfo {
865            boot_label: uki::get_boot_label_buffered(&mut uki_reader)
866                .context("Getting UKI boot label")?,
867            version: parsed_osrel.get_version(),
868            os_id: parsed_osrel.get_value(&["ID"]),
869            boot_digest,
870        });
871    }
872
873    let efi_linux_path = mounted_efi.as_ref().join(BOOTC_UKI_DIR);
874    create_dir_all(&efi_linux_path).context("Creating bootc UKI directory")?;
875
876    let final_pe_path = match file_path.parent() {
877        Some(parent) => {
878            let renamed_path = match parent.as_str().ends_with(EFI_ADDON_DIR_EXT) {
879                true => {
880                    let dir_name = get_uki_addon_dir_name(&uki_id.to_hex());
881
882                    parent
883                        .parent()
884                        .map(|p| p.join(&dir_name))
885                        .unwrap_or(dir_name.into())
886                }
887
888                false => parent.to_path_buf(),
889            };
890
891            let full_path = efi_linux_path.join(renamed_path);
892            create_dir_all(&full_path)?;
893
894            full_path
895        }
896
897        None => efi_linux_path,
898    };
899
900    let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority())
901        .with_context(|| format!("Opening {final_pe_path:?}"))?;
902
903    let pe_name = match pe_type {
904        PEType::Uki => &get_uki_name(&uki_id.to_hex()),
905        PEType::UkiAddon => file_path
906            .components()
907            .last()
908            .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))?
909            .as_str(),
910    };
911
912    uki_reader.seek(SeekFrom::Start(0))?;
913    pe_dir
914        .atomic_replace_with(pe_name, |writer| std::io::copy(&mut uki_reader, writer))
915        .context("Writing UKI")?;
916
917    rustix::fs::fsync(
918        pe_dir
919            .reopen_as_ownedfd()
920            .context("Reopening as owned fd")?,
921    )
922    .context("fsync")?;
923
924    Ok(boot_label)
925}
926
927#[context("Writing Grub menuentry")]
928fn write_grub_uki_menuentry(
929    root_path: Utf8PathBuf,
930    setup_type: &BootSetupType,
931    boot_label: String,
932    id: &Sha512HashValue,
933    esp_device: &String,
934) -> Result<()> {
935    let boot_dir = root_path.join("boot");
936    create_dir_all(&boot_dir).context("Failed to create boot dir")?;
937
938    let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
939
940    let efi_uuid_source = get_efi_uuid_source();
941
942    let user_cfg_name = if is_upgrade {
943        USER_CFG_STAGED
944    } else {
945        USER_CFG
946    };
947
948    let grub_dir = Dir::open_ambient_dir(boot_dir.join("grub2"), ambient_authority())
949        .context("opening boot/grub2")?;
950
951    // Iterate over all available deployments, and generate a menuentry for each
952    if is_upgrade {
953        let mut str_buf = String::new();
954        let boot_dir =
955            Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?;
956        let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?;
957
958        grub_dir
959            .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
960                f.write_all(efi_uuid_source.as_bytes())?;
961                f.write_all(
962                    MenuEntry::new(&boot_label, &id.to_hex())
963                        .to_string()
964                        .as_bytes(),
965                )?;
966
967                // Write out only the currently booted entry, which should be the very first one
968                // Even if we have booted into the second menuentry "boot entry", the default will be the
969                // first one
970                f.write_all(entries[0].to_string().as_bytes())?;
971
972                Ok(())
973            })
974            .with_context(|| format!("Writing to {user_cfg_name}"))?;
975
976        rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
977
978        return Ok(());
979    }
980
981    // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there
982    // This will be sourced by grub2/user.cfg to be used for `--fs-uuid`
983    let esp_uuid = Task::new("blkid for ESP UUID", "blkid")
984        .args(["-s", "UUID", "-o", "value", &esp_device])
985        .read()?;
986
987    grub_dir.atomic_write(
988        EFI_UUID_FILE,
989        format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(),
990    )?;
991
992    // Write to grub2/user.cfg
993    grub_dir
994        .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
995            f.write_all(efi_uuid_source.as_bytes())?;
996            f.write_all(
997                MenuEntry::new(&boot_label, &id.to_hex())
998                    .to_string()
999                    .as_bytes(),
1000            )?;
1001
1002            Ok(())
1003        })
1004        .with_context(|| format!("Writing to {user_cfg_name}"))?;
1005
1006    rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
1007
1008    Ok(())
1009}
1010
1011#[context("Writing systemd UKI config")]
1012fn write_systemd_uki_config(
1013    esp_dir: &Dir,
1014    setup_type: &BootSetupType,
1015    boot_label: UKIInfo,
1016    id: &Sha512HashValue,
1017    bootloader: &Bootloader,
1018) -> Result<()> {
1019    let os_id = boot_label.os_id.as_deref().unwrap_or("bootc");
1020    let primary_sort_key = primary_sort_key(os_id);
1021
1022    let mut bls_conf = BLSConfig::default();
1023    bls_conf
1024        .with_title(boot_label.boot_label)
1025        .with_cfg(BLSConfigType::EFI {
1026            key: EFIKey::for_bootloader(
1027                format!("/{BOOTC_UKI_DIR}/{}", get_uki_name(&id.to_hex())).into(),
1028                bootloader,
1029            ),
1030        })
1031        .with_sort_key(primary_sort_key.clone())
1032        .with_version(boot_label.version.unwrap_or_else(|| id.to_hex()));
1033
1034    let (entries_dir, booted_bls) = match setup_type {
1035        BootSetupType::Setup(..) => {
1036            esp_dir
1037                .create_dir_all(TYPE1_ENT_PATH)
1038                .with_context(|| format!("Creating {TYPE1_ENT_PATH}"))?;
1039
1040            (esp_dir.open_dir(TYPE1_ENT_PATH)?, None)
1041        }
1042
1043        BootSetupType::Upgrade((_, booted_cfs, ..)) => {
1044            esp_dir
1045                .create_dir_all(TYPE1_ENT_PATH_STAGED)
1046                .with_context(|| format!("Creating {TYPE1_ENT_PATH_STAGED}"))?;
1047
1048            let mut booted_bls = get_booted_bls(&esp_dir, booted_cfs)?;
1049            booted_bls.sort_key = Some(secondary_sort_key(os_id));
1050
1051            (esp_dir.open_dir(TYPE1_ENT_PATH_STAGED)?, Some(booted_bls))
1052        }
1053    };
1054
1055    entries_dir
1056        .atomic_write(
1057            type1_entry_conf_file_name(os_id, &bls_conf.version(), FILENAME_PRIORITY_PRIMARY),
1058            bls_conf.to_string().as_bytes(),
1059        )
1060        .context("Writing conf file")?;
1061
1062    if let Some(booted_bls) = booted_bls {
1063        entries_dir.atomic_write(
1064            type1_entry_conf_file_name(os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
1065            booted_bls.to_string().as_bytes(),
1066        )?;
1067    }
1068
1069    // Write the timeout for bootloader menu if not exists
1070    if !esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) {
1071        esp_dir
1072            .atomic_write(SYSTEMD_LOADER_CONF_PATH, SYSTEMD_TIMEOUT)
1073            .with_context(|| format!("Writing to {SYSTEMD_LOADER_CONF_PATH}"))?;
1074    }
1075
1076    let esp_dir = esp_dir
1077        .reopen_as_ownedfd()
1078        .context("Reopening as owned fd")?;
1079    rustix::fs::fsync(esp_dir).context("fsync")?;
1080
1081    Ok(())
1082}
1083
1084#[context("Setting up UKI boot")]
1085pub(crate) fn setup_composefs_uki_boot(
1086    setup_type: BootSetupType,
1087    repo: crate::store::ComposefsRepository,
1088    id: &Sha512HashValue,
1089    entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
1090) -> Result<String> {
1091    let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type
1092    {
1093        BootSetupType::Setup((root_setup, state, postfetch)) => {
1094            state.require_no_kargs_for_uki()?;
1095
1096            // Locate ESP partition device by walking up to the root disk(s)
1097            let esp_part = root_setup.device_info.find_first_colocated_esp()?;
1098
1099            (
1100                root_setup.physical_root_path.clone(),
1101                esp_part.path(),
1102                postfetch.detected_bootloader.clone(),
1103                state.composefs_options.allow_missing_verity,
1104                state.composefs_options.uki_addon.as_ref(),
1105            )
1106        }
1107
1108        BootSetupType::Upgrade((storage, booted_cfs, host)) => {
1109            let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path
1110            let bootloader = host.require_composefs_booted()?.bootloader.clone();
1111
1112            // Locate ESP partition device by walking up to the root disk(s)
1113            let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?;
1114            let esp_dev = root_dev.find_first_colocated_esp()?;
1115
1116            (
1117                sysroot,
1118                esp_dev.path(),
1119                bootloader,
1120                booted_cfs.cmdline.allow_missing_fsverity,
1121                None,
1122            )
1123        }
1124    };
1125
1126    let esp_mount = mount_esp(&esp_device).context("Mounting ESP")?;
1127
1128    let mut uki_info: Option<UKIInfo> = None;
1129
1130    for entry in entries {
1131        match entry {
1132            ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"),
1133            ComposefsBootEntry::UsrLibModulesVmLinuz(..) => {
1134                tracing::debug!("Skipping vmlinuz in /usr/lib/modules")
1135            }
1136
1137            ComposefsBootEntry::Type2(entry) => {
1138                // If --uki-addon is not passed, we don't install any addon
1139                if matches!(entry.pe_type, PEType::UkiAddon) {
1140                    let Some(addons) = uki_addons else {
1141                        continue;
1142                    };
1143
1144                    let addon_name = entry
1145                        .file_path
1146                        .components()
1147                        .last()
1148                        .ok_or_else(|| anyhow::anyhow!("Could not get UKI addon name"))?;
1149
1150                    let addon_name = addon_name.as_str()?;
1151
1152                    let addon_name =
1153                        addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| {
1154                            anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_DIR_EXT}")
1155                        })?;
1156
1157                    if !addons.iter().any(|passed_addon| passed_addon == addon_name) {
1158                        continue;
1159                    }
1160                }
1161
1162                let utf8_file_path = Utf8Path::from_path(&entry.file_path)
1163                    .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
1164
1165                let ret = write_pe_to_esp(
1166                    &repo,
1167                    &entry.file,
1168                    utf8_file_path,
1169                    entry.pe_type,
1170                    &id,
1171                    missing_fsverity_allowed,
1172                    esp_mount.dir.path(),
1173                )?;
1174
1175                if let Some(label) = ret {
1176                    uki_info = Some(label);
1177                }
1178            }
1179        };
1180    }
1181
1182    let uki_info =
1183        uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?;
1184
1185    let boot_digest = uki_info.boot_digest.clone();
1186
1187    match bootloader.kind()? {
1188        BootloaderKind::GRUBClassic => {
1189            write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)?
1190        }
1191
1192        BootloaderKind::BLSCompatible => {
1193            write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, id, &bootloader)?
1194        }
1195    };
1196
1197    Ok(boot_digest)
1198}
1199
1200/// A composefs image attached to a temporary directory with the ESP and a
1201/// tmpfs mounted inside it, ready for bootloader installation.
1202///
1203/// The composefs image (a detached `fsmount(2)` fd with no VFS path) is
1204/// attached to a tmpdir via `move_mount(2)`, giving us a real filesystem path
1205/// that `mount(2)` and bootctl can use.  The ESP is mounted at
1206/// `<tmpdir>/efi` (if that directory exists in the image) or `<tmpdir>/boot`,
1207/// per the Boot Loader Specification.  A tmpfs is also mounted at
1208/// `<tmpdir>/tmp` to provide a writable scratch area for tools invoked with
1209/// `--root`.
1210///
1211/// Drop order matters: the tmpfs guard is declared before `composefs` so it
1212/// is unmounted (and flushed) before the composefs root is detached.
1213pub(crate) struct MountedImageRoot {
1214    // The ESP's device path. The ESP is intentionally *not* mounted here for
1215    // our whole lifetime; it's mounted on demand via `with_esp()` instead.
1216    // That's because `install_via_bootupd` runs bootupd's install tooling
1217    // inside a `ChrootCmd`, which unshares the mount namespace and
1218    // recursively self-binds the chroot directory (our `root_path()`) onto
1219    // itself. If the ESP were already mounted at `<root_path>/boot` when
1220    // that happens, the self-bind would duplicate it into an
1221    // orphaned/shadowed mountinfo entry that's still visible to naive
1222    // `findmnt`-based scans (like bootupd's), causing it to pick the wrong
1223    // filesystem UUID.
1224    esp_device: Utf8PathBuf,
1225    // Tracks whether the ESP is currently mounted, i.e. whether we're
1226    // inside a call to `with_esp()`. Guards `open_esp_dir()` against
1227    // returning the wrong thing: without this, calling it while the ESP
1228    // isn't mounted would silently open the empty `esp_subdir` directory
1229    // that already exists directly in the composefs image, rather than
1230    // failing.
1231    esp_mounted: Cell<bool>,
1232    // Unmounted before `composefs` on drop.
1233    _tmp: bootc_mount::tempmount::MountGuard,
1234    composefs: TempMount,
1235    pub(crate) esp_subdir: &'static str,
1236}
1237
1238impl MountedImageRoot {
1239    /// Find the ESP on `device`, attach the composefs image to a tmpdir, and
1240    /// mount the ESP and a scratch tmpfs inside it.
1241    // TODO: install to all ESPs on multi-device setups
1242    #[context("Preparing image root for bootloader installation")]
1243    pub(crate) fn new(
1244        composefs_mnt_fd: std::os::fd::OwnedFd,
1245        device: &bootc_blockdev::Device,
1246    ) -> Result<Self> {
1247        let roots = device.find_all_roots()?;
1248        let mut esp_part = None;
1249        for root in &roots {
1250            if let Some(esp) = root.find_partition_of_esp_optional()? {
1251                esp_part = Some(esp);
1252                break;
1253            }
1254        }
1255        let esp_part = esp_part.ok_or_else(|| anyhow!("ESP partition not found"))?;
1256
1257        // Attach the detached composefs fsmount fd to a real tmpdir path so
1258        // that mount(2) and bootctl --root can work with it.
1259        let composefs = TempMount::mount_fd(composefs_mnt_fd)
1260            .context("Attaching composefs image to temporary directory")?;
1261
1262        // TODO: support XBOOTLDR.  Per BLS, the ESP should be mounted at /efi
1263        // when a separate XBOOTLDR partition is present at /boot.  bootc does
1264        // not yet detect or use XBOOTLDR in the composefs install path, so
1265        // unconditionally mount the ESP at /boot for now.
1266        let esp_subdir = "boot";
1267
1268        // Mount a tmpfs over /tmp so that tools invoked with --root have a
1269        // writable scratch area without touching the read-only EROFS root.
1270        let tmp_path = composefs.dir.path().join("tmp");
1271        let tmp = bootc_mount::tempmount::MountGuard::mount(
1272            "tmpfs",
1273            tmp_path,
1274            "tmpfs",
1275            MountFlags::NOEXEC | MountFlags::NOSUID | MountFlags::NODEV,
1276            None::<&std::ffi::CStr>,
1277        )
1278        .context("Mounting tmpfs into composefs root")?;
1279
1280        Ok(Self {
1281            esp_device: esp_part.path().into(),
1282            esp_mounted: Cell::new(false),
1283            _tmp: tmp,
1284            composefs,
1285            esp_subdir,
1286        })
1287    }
1288
1289    /// The composefs image as a capability-safe directory (for file reads).
1290    pub(crate) fn dir(&self) -> &Dir {
1291        &self.composefs.fd
1292    }
1293
1294    /// Real filesystem path of the composefs tmpdir root.
1295    pub(crate) fn root_path(&self) -> &std::path::Path {
1296        self.composefs.dir.path()
1297    }
1298
1299    /// Open the mounted ESP as a capability-safe directory.
1300    ///
1301    /// Fails unless called from within a call to [`Self::with_esp`]: the ESP
1302    /// is only actually mounted for the duration of that call, and
1303    /// `esp_subdir` otherwise refers to an ordinary (empty) directory that
1304    /// already exists directly in the composefs image.
1305    pub(crate) fn open_esp_dir(&self) -> Result<Dir> {
1306        if !self.esp_mounted.get() {
1307            bail!("BUG: attempted to open the ESP directory while it is not mounted");
1308        }
1309        self.composefs
1310            .fd
1311            .open_dir(self.esp_subdir)
1312            .with_context(|| format!("Opening ESP at /{}", self.esp_subdir))
1313    }
1314
1315    /// Mount the ESP at `<tmpdir>/<esp_subdir>` for the duration of `f`, then
1316    /// unmount it again. Nothing is mounted there outside of calls to this
1317    /// method, so callers that need to invoke bootloader-install tooling
1318    /// that itself creates a private mount namespace (e.g. via `ChrootCmd`)
1319    /// can safely do so without risking this mount being shadowed/duplicated
1320    /// by that tooling's own mount-namespace setup.
1321    pub(crate) fn with_esp<T>(&self, f: impl FnOnce(&Dir) -> Result<T>) -> Result<T> {
1322        let esp_path = self.root_path().join(self.esp_subdir);
1323        let _guard = mount_esp_at(self.esp_device.as_str(), esp_path)
1324            .context("Mounting ESP into composefs root")?;
1325
1326        self.esp_mounted.set(true);
1327        // Reset `esp_mounted` when we return, including via `?` or panic,
1328        // so a later `with_esp()` call (or a stray `open_esp_dir()` call
1329        // after this one returns) doesn't see a stale "mounted" state.
1330        struct ResetOnDrop<'a>(&'a Cell<bool>);
1331        impl Drop for ResetOnDrop<'_> {
1332            fn drop(&mut self) {
1333                self.0.set(false);
1334            }
1335        }
1336        let _reset = ResetOnDrop(&self.esp_mounted);
1337
1338        let dir = self.open_esp_dir()?;
1339        f(&dir)
1340    }
1341}
1342
1343pub struct SecurebootKeys {
1344    pub dir: Dir,
1345    pub keys: Vec<Utf8PathBuf>,
1346}
1347
1348fn get_secureboot_keys(fs: &Dir, p: &str) -> Result<Option<SecurebootKeys>> {
1349    let mut entries = vec![];
1350
1351    // if the dir doesn't exist, return None
1352    let keys_dir = match fs.open_dir_optional(p)? {
1353        Some(d) => d,
1354        _ => return Ok(None),
1355    };
1356
1357    // https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392
1358
1359    for entry in keys_dir.entries()? {
1360        let dir_e = entry?;
1361        let dirname = dir_e.file_name();
1362        if !dir_e.file_type()?.is_dir() {
1363            bail!("/{p}/{dirname:?} is not a directory");
1364        }
1365
1366        let dir_path: Utf8PathBuf = dirname.try_into()?;
1367        let dir = dir_e.open_dir()?;
1368        for entry in dir.entries()? {
1369            let e = entry?;
1370            let local: Utf8PathBuf = e.file_name().try_into()?;
1371            let path = dir_path.join(local);
1372
1373            if path.extension() != Some(AUTH_EXT) {
1374                continue;
1375            }
1376
1377            if !e.file_type()?.is_file() {
1378                bail!("/{p}/{path:?} is not a file");
1379            }
1380            entries.push(path);
1381        }
1382    }
1383    return Ok(Some(SecurebootKeys {
1384        dir: keys_dir,
1385        keys: entries,
1386    }));
1387}
1388
1389#[context("Setting up composefs boot")]
1390pub(crate) async fn setup_composefs_boot(
1391    root_setup: &RootSetup,
1392    state: &State,
1393    pull_result: &composefs_oci::PullResult<Sha512HashValue>,
1394    allow_missing_fsverity: bool,
1395) -> Result<()> {
1396    const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5";
1397
1398    tracing::info!(
1399        message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID,
1400        bootc.operation = "boot_setup",
1401        bootc.config_digest = %pull_result.config_digest,
1402        bootc.allow_missing_fsverity = allow_missing_fsverity,
1403        "Setting up composefs boot",
1404    );
1405
1406    let mut repo = open_composefs_repo(&root_setup.physical_root)?;
1407    if allow_missing_fsverity {
1408        repo.set_insecure();
1409    }
1410
1411    let repo = Arc::new(repo);
1412
1413    // Generate the bootable EROFS image (idempotent).
1414    let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest)
1415        .context("Generating bootable EROFS image")?;
1416
1417    // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.).
1418    let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None)
1419        .context("Creating composefs filesystem for boot entry discovery")?;
1420    let entries =
1421        get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?;
1422
1423    let composefs_mnt_fd = repo
1424        .mount(&id.to_hex())
1425        .context("Failed to mount composefs image")?;
1426    let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?;
1427
1428    let postfetch = PostFetchState::new(state, mounted_root.dir())?;
1429
1430    let boot_uuid = root_setup
1431        .get_boot_uuid()?
1432        .or(root_setup.rootfs_uuid.as_deref())
1433        .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
1434
1435    if cfg!(target_arch = "s390x") {
1436        // TODO: Integrate s390x support into install_via_bootupd
1437        crate::bootloader::install_via_zipl(
1438            &root_setup.device_info.require_single_root()?,
1439            boot_uuid,
1440        )?;
1441    } else if matches!(
1442        postfetch.detected_bootloader,
1443        Bootloader::Grub | Bootloader::GrubCC
1444    ) {
1445        let chroot_target = Utf8Path::from_path(mounted_root.root_path())
1446            .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
1447        // Like the ostree backend, bind the physical root's real /boot (an
1448        // ordinary ext4/xfs/... directory, not yet populated with kernels at
1449        // this point) into the chroot. This gives bootupd both a correct
1450        // filesystem to inspect for `--write-uuid` (rather than the ESP,
1451        // which is otherwise mounted at the composefs root's own /boot) and
1452        // an empty `boot/efi` directory for its EFI component to discover
1453        // and mount the real ESP into, exactly as it would on ostree.
1454        let bind_boot_path = root_setup.physical_root_path.join("boot");
1455        crate::bootloader::install_via_bootupd(
1456            &root_setup.device_info,
1457            &root_setup.physical_root_path,
1458            &state.config_opts,
1459            Some(chroot_target),
1460            Some(bind_boot_path.as_path()),
1461        )?;
1462
1463        // FIXME: Remove this hack once we have support in bootupd
1464        if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) {
1465            // bootupctl wrote this under the physical root's real /boot (via
1466            // the bind mount above), not under the composefs root.
1467            root_setup
1468                .physical_root
1469                .remove_all_optional("boot/grub2")
1470                .context("removing grub2")?;
1471
1472            // install_via_bootupd above has already returned, so it's safe
1473            // to mount the ESP here for the duration of this cleanup.
1474            mounted_root.with_esp(|esp_dir| {
1475                let (os_id, ..) = parse_os_release(mounted_root.dir())?
1476                    .ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
1477
1478                let dir = format!("EFI/{os_id}");
1479
1480                // Files are in EFI/<os-name>/
1481                let efis_dir = esp_dir
1482                    .open_dir(&dir)
1483                    .with_context(|| format!("Opening {dir}"))?;
1484
1485                efis_dir
1486                    .remove_file_optional("bootuuid.cfg")
1487                    .context("Removing bootuuid.cfg")?;
1488                efis_dir
1489                    .remove_file_optional("grub.cfg")
1490                    .context("Removing grub.cfg")?;
1491
1492                let final_name = match std::env::consts::ARCH {
1493                    "x86_64" => "grubx64.efi",
1494                    "aarch64" => "grubaa64-cc.efi",
1495                    arch => anyhow::bail!("GrubCC not supported for: {arch}"),
1496                };
1497
1498                mounted_root
1499                    .dir()
1500                    .copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1501                    .context("Copying grub-cc binary")?;
1502
1503                Ok(())
1504            })?;
1505        }
1506    } else {
1507        mounted_root.with_esp(|_esp_dir| {
1508            crate::bootloader::install_systemd_boot(
1509                &mounted_root,
1510                &state.config_opts,
1511                get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1512            )
1513        })?;
1514    }
1515
1516    let Some(entry) = entries.iter().next() else {
1517        anyhow::bail!("No boot entries!");
1518    };
1519
1520    let boot_type = BootType::from(entry);
1521
1522    // Unwrap Arc to pass owned repo to boot setup functions.
1523    let repo = Arc::try_unwrap(repo).map_err(|_| {
1524        anyhow::anyhow!(
1525            "BUG: Arc<Repository> still has other references after boot image generation"
1526        )
1527    })?;
1528
1529    let boot_digest = match boot_type {
1530        BootType::Bls => setup_composefs_bls_boot(
1531            BootSetupType::Setup((&root_setup, &state, &postfetch)),
1532            repo,
1533            &id,
1534            entry,
1535            mounted_root.dir(),
1536        )?,
1537        BootType::Uki => setup_composefs_uki_boot(
1538            BootSetupType::Setup((&root_setup, &state, &postfetch)),
1539            repo,
1540            &id,
1541            entries,
1542        )?,
1543    };
1544
1545    write_composefs_state(
1546        &root_setup.physical_root_path,
1547        &id,
1548        &crate::spec::ImageReference::from(state.target_imgref.clone()),
1549        None,
1550        boot_type,
1551        boot_digest,
1552        &pull_result.manifest_digest.to_string(),
1553        allow_missing_fsverity,
1554    )
1555    .await?;
1556
1557    Ok(())
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562    use super::*;
1563
1564    #[test]
1565    fn test_type1_filename_generation() {
1566        // Test basic os_id without hyphens
1567        let filename =
1568            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1569        assert_eq!(filename, "bootc_fedora-41.20251125.0-1.conf");
1570
1571        // Test primary vs secondary priority
1572        let primary =
1573            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1574        let secondary =
1575            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1576        assert_eq!(primary, "bootc_fedora-41.20251125.0-1.conf");
1577        assert_eq!(secondary, "bootc_fedora-41.20251125.0-0.conf");
1578
1579        // Test os_id with hyphens (should be replaced with underscores)
1580        let filename =
1581            type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1582        assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
1583
1584        // Test multiple hyphens in os_id
1585        let filename =
1586            type1_entry_conf_file_name("my-custom-os", "1.0.0", FILENAME_PRIORITY_PRIMARY);
1587        assert_eq!(filename, "bootc_my_custom_os-1.0.0-1.conf");
1588
1589        // Test rhel example
1590        let filename = type1_entry_conf_file_name("rhel", "9.3.0", FILENAME_PRIORITY_SECONDARY);
1591        assert_eq!(filename, "bootc_rhel-9.3.0-0.conf");
1592    }
1593
1594    #[test]
1595    fn test_grub_filename_parsing() {
1596        // Verify our filename format works correctly with Grub's parsing logic
1597        // Grub parses: bootc_fedora-41.20251125.0-1.conf
1598        // Expected:
1599        //   - name: bootc_fedora
1600        //   - version: 41.20251125.0
1601        //   - release: 1
1602
1603        // For fedora-coreos (with hyphens), we convert to underscores
1604        let filename = type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", "1");
1605        assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
1606
1607        // Grub parsing simulation (from right):
1608        // 1. Strip .conf -> bootc_fedora_coreos-41.20251125.0-1
1609        // 2. Last '-' splits: release="1", remainder="bootc_fedora_coreos-41.20251125.0"
1610        // 3. Second-to-last '-' splits: version="41.20251125.0", name="bootc_fedora_coreos"
1611
1612        let without_ext = filename.strip_suffix(".conf").unwrap();
1613        let parts: Vec<&str> = without_ext.rsplitn(3, '-').collect();
1614        assert_eq!(parts.len(), 3);
1615        assert_eq!(parts[0], "1"); // release
1616        assert_eq!(parts[1], "41.20251125.0"); // version
1617        assert_eq!(parts[2], "bootc_fedora_coreos"); // name
1618    }
1619
1620    #[test]
1621    fn test_sort_keys() {
1622        // Test sort-key generation for systemd-boot
1623        let primary = primary_sort_key("fedora");
1624        let secondary = secondary_sort_key("fedora");
1625
1626        assert_eq!(primary, "bootc-fedora-0");
1627        assert_eq!(secondary, "bootc-fedora-1");
1628
1629        // Systemd-boot sorts ascending, so "bootc-fedora-0" < "bootc-fedora-1"
1630        assert!(primary < secondary);
1631
1632        // Test with hyphenated os_id (sort-key keeps hyphens)
1633        let primary_coreos = primary_sort_key("fedora-coreos");
1634        assert_eq!(primary_coreos, "bootc-fedora-coreos-0");
1635    }
1636
1637    #[test]
1638    fn test_filename_sorting_grub_style() {
1639        // Simulate Grub's descending sort by (name, version, release)
1640
1641        // Test 1: Same version, different release (priority)
1642        let primary =
1643            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1644        let secondary =
1645            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1646
1647        // Descending sort: "bootc_fedora-41.20251125.0-1" > "bootc_fedora-41.20251125.0-0"
1648        assert!(
1649            primary > secondary,
1650            "Primary should sort before secondary in descending order"
1651        );
1652
1653        // Test 2: Different versions
1654        let newer =
1655            type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
1656        let older =
1657            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1658
1659        // Descending sort: version "42" > "41"
1660        assert!(
1661            newer > older,
1662            "Newer version should sort before older in descending order"
1663        );
1664
1665        // Test 3: Different os_id (different name)
1666        let fedora = type1_entry_conf_file_name("fedora", "41.0", FILENAME_PRIORITY_PRIMARY);
1667        let rhel = type1_entry_conf_file_name("rhel", "9.0", FILENAME_PRIORITY_PRIMARY);
1668
1669        // Names differ: bootc_rhel > bootc_fedora (descending alphabetical)
1670        assert!(
1671            rhel > fedora,
1672            "RHEL should sort before Fedora in descending order"
1673        );
1674    }
1675}