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