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, ParameterKey};
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    // Remove "root=" from kernel cmdline as systemd-auto-gpt-generator should use DPS
570    // UUID
571    if bootloader == Bootloader::Systemd {
572        cmdline_refs.remove(&ParameterKey::from("root"));
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")?)
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
627    let (bls_config, boot_digest, os_id) = match &entry {
628        ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"),
629        ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"),
630
631        ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => {
632            let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo)
633                .context("Computing boot digest")?;
634
635            let osrel = parse_os_release(fs, &repo)?;
636
637            let (os_id, title, version, sort_key) = match osrel {
638                Some((id_str, title_opt, version_opt)) => (
639                    id_str.clone(),
640                    title_opt.unwrap_or_else(|| id.to_hex()),
641                    version_opt.unwrap_or_else(|| id.to_hex()),
642                    primary_sort_key(&id_str),
643                ),
644                None => {
645                    let default_id = "bootc".to_string();
646                    (
647                        default_id.clone(),
648                        id.to_hex(),
649                        id.to_hex(),
650                        primary_sort_key(&default_id),
651                    )
652                }
653            };
654
655            let mut bls_config = BLSConfig::default();
656
657            bls_config
658                .with_title(title)
659                .with_version(version)
660                .with_sort_key(sort_key)
661                .with_cfg(BLSConfigType::NonEFI {
662                    linux: entry_paths.abs_entries_path.join(&id_hex).join(VMLINUZ),
663                    initrd: vec![entry_paths.abs_entries_path.join(&id_hex).join(INITRD)],
664                    options: Some(cmdline_refs),
665                });
666
667            match find_vmlinuz_initrd_duplicates(&boot_digest)? {
668                Some(shared_entries) => {
669                    // Multiple deployments could be using the same kernel + initrd, but there
670                    // would be only one available
671                    //
672                    // Symlinking directories themselves would be better, but vfat does not support
673                    // symlinks
674
675                    let mut shared_entry: Option<String> = None;
676
677                    let entries =
678                        Dir::open_ambient_dir(entry_paths.entries_path, ambient_authority())
679                            .context("Opening entries path")?
680                            .entries_utf8()
681                            .context("Getting dir entries")?;
682
683                    for ent in entries {
684                        let ent = ent?;
685                        // We shouldn't error here as all our file names are UTF-8 compatible
686                        let ent_name = ent.file_name()?;
687
688                        if shared_entries.contains(&ent_name) {
689                            shared_entry = Some(ent_name);
690                            break;
691                        }
692                    }
693
694                    let shared_entry = shared_entry
695                        .ok_or_else(|| anyhow::anyhow!("Shared boot binaries not found"))?;
696
697                    match bls_config.cfg_type {
698                        BLSConfigType::NonEFI {
699                            ref mut linux,
700                            ref mut initrd,
701                            ..
702                        } => {
703                            *linux = entry_paths
704                                .abs_entries_path
705                                .join(&shared_entry)
706                                .join(VMLINUZ);
707
708                            *initrd = vec![
709                                entry_paths
710                                    .abs_entries_path
711                                    .join(&shared_entry)
712                                    .join(INITRD),
713                            ];
714                        }
715
716                        _ => unreachable!(),
717                    };
718                }
719
720                None => {
721                    write_bls_boot_entries_to_disk(
722                        &entry_paths.entries_path,
723                        id,
724                        usr_lib_modules_vmlinuz,
725                        &repo,
726                    )?;
727                }
728            };
729
730            (bls_config, boot_digest, os_id)
731        }
732    };
733
734    let loader_path = entry_paths.config_path.join("loader");
735
736    let (config_path, booted_bls) = if is_upgrade {
737        let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())?;
738
739        let mut booted_bls = get_booted_bls(&boot_dir)?;
740        booted_bls.sort_key = Some(secondary_sort_key(&os_id));
741
742        let staged_path = loader_path.join(STAGED_BOOT_LOADER_ENTRIES);
743
744        // Delete the staged entries directory if it exists as we want to overwrite the entries
745        // anyway
746        if boot_dir
747            .remove_all_optional(TYPE1_ENT_PATH_STAGED)
748            .context("Failed to remove staged directory")?
749        {
750            tracing::debug!("Removed existing staged entries directory");
751        }
752
753        // This will be atomically renamed to 'loader/entries' on shutdown/reboot
754        (staged_path, Some(booted_bls))
755    } else {
756        (loader_path.join(BOOT_LOADER_ENTRIES), None)
757    };
758
759    create_dir_all(&config_path).with_context(|| format!("Creating {:?}", config_path))?;
760
761    let loader_entries_dir = Dir::open_ambient_dir(&config_path, ambient_authority())
762        .with_context(|| format!("Opening {config_path:?}"))?;
763
764    loader_entries_dir.atomic_write(
765        type1_entry_conf_file_name(&os_id, &bls_config.version(), FILENAME_PRIORITY_PRIMARY),
766        bls_config.to_string().as_bytes(),
767    )?;
768
769    if let Some(booted_bls) = booted_bls {
770        loader_entries_dir.atomic_write(
771            type1_entry_conf_file_name(&os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
772            booted_bls.to_string().as_bytes(),
773        )?;
774    }
775
776    let owned_loader_entries_fd = loader_entries_dir
777        .reopen_as_ownedfd()
778        .context("Reopening as owned fd")?;
779
780    rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?;
781
782    Ok(boot_digest)
783}
784
785struct UKIInfo {
786    boot_label: String,
787    version: Option<String>,
788    os_id: Option<String>,
789    boot_digest: String,
790}
791
792/// Writes a PortableExecutable to ESP along with any PE specific or Global addons
793#[context("Writing {file_path} to ESP")]
794fn write_pe_to_esp(
795    repo: &crate::store::ComposefsRepository,
796    file: &RegularFile<Sha512HashValue>,
797    file_path: &Utf8Path,
798    pe_type: PEType,
799    uki_id: &Sha512HashValue,
800    is_insecure_from_opts: bool,
801    mounted_efi: impl AsRef<Path>,
802    bootloader: &Bootloader,
803) -> Result<Option<UKIInfo>> {
804    let efi_bin = read_file(file, &repo).context("Reading .efi binary")?;
805
806    let mut boot_label: Option<UKIInfo> = None;
807
808    // UKI Extension might not even have a cmdline
809    // TODO: UKI Addon might also have a composefs= cmdline?
810    if matches!(pe_type, PEType::Uki) {
811        let cmdline = uki::get_cmdline(&efi_bin).context("Getting UKI cmdline")?;
812
813        let (composefs_cmdline, insecure) =
814            get_cmdline_composefs::<Sha512HashValue>(cmdline).context("Parsing composefs=")?;
815
816        // If the UKI cmdline does not match what the user has passed as cmdline option
817        // NOTE: This will only be checked for new installs and now upgrades/switches
818        match is_insecure_from_opts {
819            true if !insecure => {
820                tracing::warn!("--insecure passed as option but UKI cmdline does not support it");
821            }
822
823            false if insecure => {
824                tracing::warn!("UKI cmdline has composefs set as insecure");
825            }
826
827            _ => { /* no-op */ }
828        }
829
830        if composefs_cmdline != *uki_id {
831            anyhow::bail!(
832                "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})"
833            );
834        }
835
836        let osrel = uki::get_text_section(&efi_bin, ".osrel")?;
837
838        let parsed_osrel = OsReleaseInfo::parse(osrel);
839
840        let boot_digest = compute_boot_digest_uki(&efi_bin)?;
841
842        boot_label = Some(UKIInfo {
843            boot_label: uki::get_boot_label(&efi_bin).context("Getting UKI boot label")?,
844            version: parsed_osrel.get_version(),
845            os_id: parsed_osrel.get_value(&["ID"]),
846            boot_digest,
847        });
848    }
849
850    // Write the UKI to ESP
851    let efi_linux_path = mounted_efi.as_ref().join(match bootloader {
852        Bootloader::Grub => EFI_LINUX,
853        Bootloader::Systemd => SYSTEMD_UKI_DIR,
854    });
855
856    create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?;
857
858    let final_pe_path = match file_path.parent() {
859        Some(parent) => {
860            let renamed_path = match parent.as_str().ends_with(EFI_ADDON_DIR_EXT) {
861                true => {
862                    let dir_name = format!("{}{}", uki_id.to_hex(), EFI_ADDON_DIR_EXT);
863
864                    parent
865                        .parent()
866                        .map(|p| p.join(&dir_name))
867                        .unwrap_or(dir_name.into())
868                }
869
870                false => parent.to_path_buf(),
871            };
872
873            let full_path = efi_linux_path.join(renamed_path);
874            create_dir_all(&full_path)?;
875
876            full_path
877        }
878
879        None => efi_linux_path,
880    };
881
882    let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority())
883        .with_context(|| format!("Opening {final_pe_path:?}"))?;
884
885    let pe_name = match pe_type {
886        PEType::Uki => &format!("{}{}", uki_id.to_hex(), EFI_EXT),
887        PEType::UkiAddon => file_path
888            .components()
889            .last()
890            .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))?
891            .as_str(),
892    };
893
894    pe_dir
895        .atomic_write(pe_name, efi_bin)
896        .context("Writing UKI")?;
897
898    rustix::fs::fsync(
899        pe_dir
900            .reopen_as_ownedfd()
901            .context("Reopening as owned fd")?,
902    )
903    .context("fsync")?;
904
905    Ok(boot_label)
906}
907
908#[context("Writing Grub menuentry")]
909fn write_grub_uki_menuentry(
910    root_path: Utf8PathBuf,
911    setup_type: &BootSetupType,
912    boot_label: String,
913    id: &Sha512HashValue,
914    esp_device: &String,
915) -> Result<()> {
916    let boot_dir = root_path.join("boot");
917    create_dir_all(&boot_dir).context("Failed to create boot dir")?;
918
919    let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
920
921    let efi_uuid_source = get_efi_uuid_source();
922
923    let user_cfg_name = if is_upgrade {
924        USER_CFG_STAGED
925    } else {
926        USER_CFG
927    };
928
929    let grub_dir = Dir::open_ambient_dir(boot_dir.join("grub2"), ambient_authority())
930        .context("opening boot/grub2")?;
931
932    // Iterate over all available deployments, and generate a menuentry for each
933    if is_upgrade {
934        let mut str_buf = String::new();
935        let boot_dir =
936            Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?;
937        let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?;
938
939        grub_dir
940            .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
941                f.write_all(efi_uuid_source.as_bytes())?;
942                f.write_all(
943                    MenuEntry::new(&boot_label, &id.to_hex())
944                        .to_string()
945                        .as_bytes(),
946                )?;
947
948                // Write out only the currently booted entry, which should be the very first one
949                // Even if we have booted into the second menuentry "boot entry", the default will be the
950                // first one
951                f.write_all(entries[0].to_string().as_bytes())?;
952
953                Ok(())
954            })
955            .with_context(|| format!("Writing to {user_cfg_name}"))?;
956
957        rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
958
959        return Ok(());
960    }
961
962    // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there
963    // This will be sourced by grub2/user.cfg to be used for `--fs-uuid`
964    let esp_uuid = Task::new("blkid for ESP UUID", "blkid")
965        .args(["-s", "UUID", "-o", "value", &esp_device])
966        .read()?;
967
968    grub_dir.atomic_write(
969        EFI_UUID_FILE,
970        format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(),
971    )?;
972
973    // Write to grub2/user.cfg
974    grub_dir
975        .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
976            f.write_all(efi_uuid_source.as_bytes())?;
977            f.write_all(
978                MenuEntry::new(&boot_label, &id.to_hex())
979                    .to_string()
980                    .as_bytes(),
981            )?;
982
983            Ok(())
984        })
985        .with_context(|| format!("Writing to {user_cfg_name}"))?;
986
987    rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
988
989    Ok(())
990}
991
992#[context("Writing systemd UKI config")]
993fn write_systemd_uki_config(
994    esp_dir: &Dir,
995    setup_type: &BootSetupType,
996    boot_label: UKIInfo,
997    id: &Sha512HashValue,
998) -> Result<()> {
999    let os_id = boot_label.os_id.as_deref().unwrap_or("bootc");
1000    let primary_sort_key = primary_sort_key(os_id);
1001
1002    let mut bls_conf = BLSConfig::default();
1003    bls_conf
1004        .with_title(boot_label.boot_label)
1005        .with_cfg(BLSConfigType::EFI {
1006            efi: format!("/{SYSTEMD_UKI_DIR}/{}{}", id.to_hex(), EFI_EXT).into(),
1007        })
1008        .with_sort_key(primary_sort_key.clone())
1009        .with_version(boot_label.version.unwrap_or_else(|| id.to_hex()));
1010
1011    let (entries_dir, booted_bls) = match setup_type {
1012        BootSetupType::Setup(..) => {
1013            esp_dir
1014                .create_dir_all(TYPE1_ENT_PATH)
1015                .with_context(|| format!("Creating {TYPE1_ENT_PATH}"))?;
1016
1017            (esp_dir.open_dir(TYPE1_ENT_PATH)?, None)
1018        }
1019
1020        BootSetupType::Upgrade(_) => {
1021            esp_dir
1022                .create_dir_all(TYPE1_ENT_PATH_STAGED)
1023                .with_context(|| format!("Creating {TYPE1_ENT_PATH_STAGED}"))?;
1024
1025            let mut booted_bls = get_booted_bls(&esp_dir)?;
1026            booted_bls.sort_key = Some(secondary_sort_key(os_id));
1027
1028            (esp_dir.open_dir(TYPE1_ENT_PATH_STAGED)?, Some(booted_bls))
1029        }
1030    };
1031
1032    entries_dir
1033        .atomic_write(
1034            type1_entry_conf_file_name(os_id, &bls_conf.version(), FILENAME_PRIORITY_PRIMARY),
1035            bls_conf.to_string().as_bytes(),
1036        )
1037        .context("Writing conf file")?;
1038
1039    if let Some(booted_bls) = booted_bls {
1040        entries_dir.atomic_write(
1041            type1_entry_conf_file_name(os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
1042            booted_bls.to_string().as_bytes(),
1043        )?;
1044    }
1045
1046    // Write the timeout for bootloader menu if not exists
1047    if !esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) {
1048        esp_dir
1049            .atomic_write(SYSTEMD_LOADER_CONF_PATH, SYSTEMD_TIMEOUT)
1050            .with_context(|| format!("Writing to {SYSTEMD_LOADER_CONF_PATH}"))?;
1051    }
1052
1053    let esp_dir = esp_dir
1054        .reopen_as_ownedfd()
1055        .context("Reopening as owned fd")?;
1056    rustix::fs::fsync(esp_dir).context("fsync")?;
1057
1058    Ok(())
1059}
1060
1061#[context("Setting up UKI boot")]
1062pub(crate) fn setup_composefs_uki_boot(
1063    setup_type: BootSetupType,
1064    repo: crate::store::ComposefsRepository,
1065    id: &Sha512HashValue,
1066    entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
1067) -> Result<String> {
1068    let (root_path, esp_device, bootloader, is_insecure_from_opts, uki_addons) = match setup_type {
1069        BootSetupType::Setup((root_setup, state, postfetch, ..)) => {
1070            state.require_no_kargs_for_uki()?;
1071
1072            let esp_part = esp_in(&root_setup.device_info)?;
1073
1074            (
1075                root_setup.physical_root_path.clone(),
1076                esp_part.node.clone(),
1077                postfetch.detected_bootloader.clone(),
1078                state.composefs_options.insecure,
1079                state.composefs_options.uki_addon.as_ref(),
1080            )
1081        }
1082
1083        BootSetupType::Upgrade((storage, _, host)) => {
1084            let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path
1085            let sysroot_parent = get_sysroot_parent_dev(&storage.physical_root)?;
1086            let bootloader = host.require_composefs_booted()?.bootloader.clone();
1087
1088            (
1089                sysroot,
1090                get_esp_partition(&sysroot_parent)?.0,
1091                bootloader,
1092                false,
1093                None,
1094            )
1095        }
1096    };
1097
1098    let esp_mount = mount_esp(&esp_device).context("Mounting ESP")?;
1099
1100    let mut uki_info: Option<UKIInfo> = None;
1101
1102    for entry in entries {
1103        match entry {
1104            ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"),
1105            ComposefsBootEntry::UsrLibModulesVmLinuz(..) => {
1106                tracing::debug!("Skipping vmlinuz in /usr/lib/modules")
1107            }
1108
1109            ComposefsBootEntry::Type2(entry) => {
1110                // If --uki-addon is not passed, we don't install any addon
1111                if matches!(entry.pe_type, PEType::UkiAddon) {
1112                    let Some(addons) = uki_addons else {
1113                        continue;
1114                    };
1115
1116                    let addon_name = entry
1117                        .file_path
1118                        .components()
1119                        .last()
1120                        .ok_or_else(|| anyhow::anyhow!("Could not get UKI addon name"))?;
1121
1122                    let addon_name = addon_name.as_str()?;
1123
1124                    let addon_name =
1125                        addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| {
1126                            anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_DIR_EXT}")
1127                        })?;
1128
1129                    if !addons.iter().any(|passed_addon| passed_addon == addon_name) {
1130                        continue;
1131                    }
1132                }
1133
1134                let utf8_file_path = Utf8Path::from_path(&entry.file_path)
1135                    .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
1136
1137                let ret = write_pe_to_esp(
1138                    &repo,
1139                    &entry.file,
1140                    utf8_file_path,
1141                    entry.pe_type,
1142                    &id,
1143                    is_insecure_from_opts,
1144                    esp_mount.dir.path(),
1145                    &bootloader,
1146                )?;
1147
1148                if let Some(label) = ret {
1149                    uki_info = Some(label);
1150                }
1151            }
1152        };
1153    }
1154
1155    let uki_info =
1156        uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?;
1157
1158    let boot_digest = uki_info.boot_digest.clone();
1159
1160    match bootloader {
1161        Bootloader::Grub => {
1162            write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)?
1163        }
1164
1165        Bootloader::Systemd => write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, id)?,
1166    };
1167
1168    Ok(boot_digest)
1169}
1170
1171pub struct SecurebootKeys {
1172    pub dir: Dir,
1173    pub keys: Vec<Utf8PathBuf>,
1174}
1175
1176fn get_secureboot_keys(fs: &Dir, p: &str) -> Result<Option<SecurebootKeys>> {
1177    let mut entries = vec![];
1178
1179    // if the dir doesn't exist, return None
1180    let keys_dir = match fs.open_dir_optional(p)? {
1181        Some(d) => d,
1182        _ => return Ok(None),
1183    };
1184
1185    // https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392
1186
1187    for entry in keys_dir.entries()? {
1188        let dir_e = entry?;
1189        let dirname = dir_e.file_name();
1190        if !dir_e.file_type()?.is_dir() {
1191            bail!("/{p}/{dirname:?} is not a directory");
1192        }
1193
1194        let dir_path: Utf8PathBuf = dirname.try_into()?;
1195        let dir = dir_e.open_dir()?;
1196        for entry in dir.entries()? {
1197            let e = entry?;
1198            let local: Utf8PathBuf = e.file_name().try_into()?;
1199            let path = dir_path.join(local);
1200
1201            if path.extension() != Some(AUTH_EXT) {
1202                continue;
1203            }
1204
1205            if !e.file_type()?.is_file() {
1206                bail!("/{p}/{path:?} is not a file");
1207            }
1208            entries.push(path);
1209        }
1210    }
1211    return Ok(Some(SecurebootKeys {
1212        dir: keys_dir,
1213        keys: entries,
1214    }));
1215}
1216
1217#[context("Setting up composefs boot")]
1218pub(crate) async fn setup_composefs_boot(
1219    root_setup: &RootSetup,
1220    state: &State,
1221    image_id: &str,
1222) -> Result<()> {
1223    let repo = open_composefs_repo(&root_setup.physical_root)?;
1224    let mut fs = create_composefs_filesystem(&repo, image_id, None)?;
1225    let entries = fs.transform_for_boot(&repo)?;
1226    let id = fs.commit_image(&repo, None)?;
1227    let mounted_fs = Dir::reopen_dir(
1228        &repo
1229            .mount(&id.to_hex())
1230            .context("Failed to mount composefs image")?,
1231    )?;
1232
1233    let postfetch = PostFetchState::new(state, &mounted_fs)?;
1234
1235    let boot_uuid = root_setup
1236        .get_boot_uuid()?
1237        .or(root_setup.rootfs_uuid.as_deref())
1238        .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
1239
1240    if cfg!(target_arch = "s390x") {
1241        // TODO: Integrate s390x support into install_via_bootupd
1242        crate::bootloader::install_via_zipl(&root_setup.device_info, boot_uuid)?;
1243    } else if postfetch.detected_bootloader == Bootloader::Grub {
1244        crate::bootloader::install_via_bootupd(
1245            &root_setup.device_info,
1246            &root_setup.physical_root_path,
1247            &state.config_opts,
1248            None,
1249        )?;
1250    } else {
1251        crate::bootloader::install_systemd_boot(
1252            &root_setup.device_info,
1253            &root_setup.physical_root_path,
1254            &state.config_opts,
1255            None,
1256            get_secureboot_keys(&mounted_fs, BOOTC_AUTOENROLL_PATH)?,
1257        )?;
1258    }
1259
1260    let Some(entry) = entries.iter().next() else {
1261        anyhow::bail!("No boot entries!");
1262    };
1263
1264    let boot_type = BootType::from(entry);
1265
1266    let boot_digest = match boot_type {
1267        BootType::Bls => setup_composefs_bls_boot(
1268            BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)),
1269            repo,
1270            &id,
1271            entry,
1272            &mounted_fs,
1273        )?,
1274        BootType::Uki => setup_composefs_uki_boot(
1275            BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)),
1276            repo,
1277            &id,
1278            entries,
1279        )?,
1280    };
1281
1282    write_composefs_state(
1283        &root_setup.physical_root_path,
1284        &id,
1285        &crate::spec::ImageReference::from(state.target_imgref.clone()),
1286        None,
1287        boot_type,
1288        boot_digest,
1289        &get_container_manifest_and_config(&get_imgref(
1290            &state.source.imageref.transport.to_string(),
1291            &state.source.imageref.name,
1292        ))
1293        .await?,
1294    )
1295    .await?;
1296
1297    Ok(())
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303
1304    #[test]
1305    fn test_type1_filename_generation() {
1306        // Test basic os_id without hyphens
1307        let filename =
1308            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1309        assert_eq!(filename, "bootc_fedora-41.20251125.0-1.conf");
1310
1311        // Test primary vs secondary priority
1312        let primary =
1313            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1314        let secondary =
1315            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1316        assert_eq!(primary, "bootc_fedora-41.20251125.0-1.conf");
1317        assert_eq!(secondary, "bootc_fedora-41.20251125.0-0.conf");
1318
1319        // Test os_id with hyphens (should be replaced with underscores)
1320        let filename =
1321            type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1322        assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
1323
1324        // Test multiple hyphens in os_id
1325        let filename =
1326            type1_entry_conf_file_name("my-custom-os", "1.0.0", FILENAME_PRIORITY_PRIMARY);
1327        assert_eq!(filename, "bootc_my_custom_os-1.0.0-1.conf");
1328
1329        // Test rhel example
1330        let filename = type1_entry_conf_file_name("rhel", "9.3.0", FILENAME_PRIORITY_SECONDARY);
1331        assert_eq!(filename, "bootc_rhel-9.3.0-0.conf");
1332    }
1333
1334    #[test]
1335    fn test_grub_filename_parsing() {
1336        // Verify our filename format works correctly with Grub's parsing logic
1337        // Grub parses: bootc_fedora-41.20251125.0-1.conf
1338        // Expected:
1339        //   - name: bootc_fedora
1340        //   - version: 41.20251125.0
1341        //   - release: 1
1342
1343        // For fedora-coreos (with hyphens), we convert to underscores
1344        let filename = type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", "1");
1345        assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
1346
1347        // Grub parsing simulation (from right):
1348        // 1. Strip .conf -> bootc_fedora_coreos-41.20251125.0-1
1349        // 2. Last '-' splits: release="1", remainder="bootc_fedora_coreos-41.20251125.0"
1350        // 3. Second-to-last '-' splits: version="41.20251125.0", name="bootc_fedora_coreos"
1351
1352        let without_ext = filename.strip_suffix(".conf").unwrap();
1353        let parts: Vec<&str> = without_ext.rsplitn(3, '-').collect();
1354        assert_eq!(parts.len(), 3);
1355        assert_eq!(parts[0], "1"); // release
1356        assert_eq!(parts[1], "41.20251125.0"); // version
1357        assert_eq!(parts[2], "bootc_fedora_coreos"); // name
1358    }
1359
1360    #[test]
1361    fn test_sort_keys() {
1362        // Test sort-key generation for systemd-boot
1363        let primary = primary_sort_key("fedora");
1364        let secondary = secondary_sort_key("fedora");
1365
1366        assert_eq!(primary, "bootc-fedora-0");
1367        assert_eq!(secondary, "bootc-fedora-1");
1368
1369        // Systemd-boot sorts ascending, so "bootc-fedora-0" < "bootc-fedora-1"
1370        assert!(primary < secondary);
1371
1372        // Test with hyphenated os_id (sort-key keeps hyphens)
1373        let primary_coreos = primary_sort_key("fedora-coreos");
1374        assert_eq!(primary_coreos, "bootc-fedora-coreos-0");
1375    }
1376
1377    #[test]
1378    fn test_filename_sorting_grub_style() {
1379        // Simulate Grub's descending sort by (name, version, release)
1380
1381        // Test 1: Same version, different release (priority)
1382        let primary =
1383            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1384        let secondary =
1385            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1386
1387        // Descending sort: "bootc_fedora-41.20251125.0-1" > "bootc_fedora-41.20251125.0-0"
1388        assert!(
1389            primary > secondary,
1390            "Primary should sort before secondary in descending order"
1391        );
1392
1393        // Test 2: Different versions
1394        let newer =
1395            type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
1396        let older =
1397            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1398
1399        // Descending sort: version "42" > "41"
1400        assert!(
1401            newer > older,
1402            "Newer version should sort before older in descending order"
1403        );
1404
1405        // Test 3: Different os_id (different name)
1406        let fedora = type1_entry_conf_file_name("fedora", "41.0", FILENAME_PRIORITY_PRIMARY);
1407        let rhel = type1_entry_conf_file_name("rhel", "9.0", FILENAME_PRIORITY_PRIMARY);
1408
1409        // Names differ: bootc_rhel > bootc_fedora (descending alphabetical)
1410        assert!(
1411            rhel > fedora,
1412            "RHEL should sort before Fedora in descending order"
1413        );
1414    }
1415}