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