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