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