Skip to main content

bootc_lib/bootc_composefs/
boot.rs

1//! Composefs boot setup and configuration.
2//!
3//! This module handles setting up boot entries for composefs-based deployments,
4//! including generating BLS (Boot Loader Specification) entries, copying kernel/initrd
5//! files, managing UKI (Unified Kernel Images), and configuring the ESP (EFI System
6//! Partition).
7//!
8//! ## Boot Ordering
9//!
10//! A critical aspect of this module is boot entry ordering, which must work correctly
11//! across both Grub and systemd-boot bootloaders despite their fundamentally different
12//! sorting behaviors.
13//!
14//! ## Critical Context: Grub's Filename Parsing
15//!
16//! **Grub does NOT read BLS fields** - it parses the filename as an RPM package name!
17//! See: <https://github.com/ostreedev/ostree/issues/2961>
18//!
19//! Grub's `split_package_string()` parsing algorithm:
20//! 1. Strip `.conf` suffix
21//! 2. Find LAST `-` → extract **release** field
22//! 3. Find SECOND-TO-LAST `-` → extract **version** field
23//! 4. Remainder → **name** field
24//!
25//! Example: `kernel-5.14.0-362.fc38.conf`
26//! - name: `kernel`
27//! - version: `5.14.0`
28//! - release: `362.fc38`
29//!
30//! **Critical:** Grub sorts by (name, version, release) in DESCENDING order.
31//!
32//! ## Bootloader Differences
33//!
34//! ### Grub
35//! - Ignores BLS sort-key field completely
36//! - Parses filename to extract name-version-release
37//! - Sorts by (name, version, release) DESCENDING
38//! - Any `-` in name/version gets incorrectly split
39//!
40//! ### Systemd-boot
41//! - Reads BLS sort-key field
42//! - Sorts by sort-key ASCENDING (A→Z, 0→9)
43//! - Filename is mostly irrelevant
44//!
45//! ## Implementation Strategy
46//!
47//! **Filenames** (for Grub's RPM-style parsing and descending sort):
48//! - Format: `bootc_{os_id}-{version}-{priority}.conf`
49//! - Replace `-` with `_` in os_id to prevent mis-parsing
50//! - Primary: `bootc_fedora-41.20251125.0-1.conf` → (name=bootc_fedora, version=41.20251125.0, release=1)
51//! - Secondary: `bootc_fedora-41.20251124.0-0.conf` → (name=bootc_fedora, version=41.20251124.0, release=0)
52//! - Grub sorts: Primary (release=1) > Secondary (release=0) when versions equal
53//!
54//! **Sort-keys** (for systemd-boot's ascending sort):
55//! - Primary: `bootc-{os_id}-0` (lower value, sorts first)
56//! - Secondary: `bootc-{os_id}-1` (higher value, sorts second)
57//!
58//! ## Boot Entry Ordering
59//!
60//! After an upgrade, both bootloaders show:
61//! 1. **Primary**: New/upgraded deployment (default boot target)
62//! 2. **Secondary**: Currently booted deployment (rollback option)
63
64use std::cell::Cell;
65use std::fs::create_dir_all;
66use std::io::{Cursor, Read, Seek, SeekFrom, Write};
67use std::os::fd::AsFd;
68use std::path::Path;
69use std::sync::Arc;
70
71use anyhow::{Context, Result, anyhow, bail, ensure};
72use bootc_mount::tempmount::TempMount;
73use camino::{Utf8Path, Utf8PathBuf};
74use cap_std_ext::{
75    cap_std::{ambient_authority, fs::Dir},
76    dirext::CapStdExtDirExt,
77};
78use clap::ValueEnum;
79use composefs::erofs::format::FormatVersion;
80use composefs::fs::read_file;
81use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
82use composefs::tree::{FileSystem, ImageError, RegularFile};
83use composefs_boot::bootloader::{
84    AbootEncoding, AbootEntry, BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT,
85    EFI_ADDON_FILE_EXT, EFI_EXT, PEType, UsrLibModulesVmlinuz,
86};
87use composefs_boot::cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2};
88use composefs_boot::{
89    android_boot::{AndroidBootError, AndroidBootImage, Component},
90    cmdline::ComposefsCmdline as ComposefsBootCmdline,
91    os_release::OsReleaseInfo,
92    uki,
93};
94use composefs_ctl::composefs;
95use composefs_ctl::composefs_boot;
96use composefs_ctl::composefs_oci;
97use fn_error_context::context;
98use linux_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey};
99use ostree_ext::composefs::dumpfile;
100use ostree_ext::composefs_boot::bootloader::Type2Entry;
101use rustix::{mount::MountFlags, path::Arg};
102use schemars::JsonSchema;
103use serde::{Deserialize, Serialize};
104
105use crate::bootc_composefs::boot_partition::BootPartition;
106use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state};
107use crate::bootc_composefs::status::build_composefs_karg;
108use crate::bootc_composefs::uki_addon::{UkiAddonType, UkiAddonsList, list_installed_uki_addons};
109use crate::bootc_kargs::compute_new_kargs;
110use crate::composefs_consts::{
111    BLS_ENTRY_FILE_PREFIX, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED,
112};
113use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey};
114use crate::spec::BootloaderKind;
115use crate::task::Task;
116use crate::{
117    bootc_composefs::repo::open_composefs_repo,
118    store::{ComposefsRepository, Storage},
119};
120use crate::{
121    bootc_composefs::status::get_sorted_grub_uki_boot_entries,
122    install::{AbootDiskLayout, PostFetchState},
123};
124use crate::{
125    composefs_consts::{STATE_DIR_RELATIVE, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED},
126    spec::{Bootloader, Host},
127};
128use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs};
129
130use crate::install::{BOOT, RootSetup, State, UkiAddonOpts};
131
132/// Contains the EFP's filesystem UUID. Used by grub
133pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg";
134/// The EFI Linux directory
135pub(crate) const EFI_LINUX: &str = "EFI/Linux";
136
137pub(crate) const INITRD: &str = "initrd";
138pub(crate) const VMLINUZ: &str = "vmlinuz";
139
140const BOOTC_AUTOENROLL_PATH: &str = "usr/lib/bootc/install/secureboot-keys";
141
142const AUTH_EXT: &str = "auth";
143
144/// We want to be able to control the ordering of UKIs so we put them in a directory that's not the
145/// directory specified by the BLS spec. We do this because we want systemd-boot to only look at
146/// our config files and not show the actual UKIs in the bootloader menu
147/// This is relative to the ESP
148pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc";
149
150/// Directory (relative to the ESP) where systemd-stub looks for UKI addons that apply
151/// to *every* UKI, as opposed to addons scoped to a single UKI (which live alongside
152/// it under [`BOOTC_UKI_DIR`]). Unlike per-UKI addons, these aren't tied to a single
153/// deployment, so they're neither namespaced by deployment verity nor cleaned up by GC.
154///
155/// TODO: This directory is shared, unscoped machine state (any systemd-stub UKI on the
156/// ESP will load whatever's here). Addons are now (re)installed on upgrade/switch
157/// (installed addons are auto-updated when the new image has a matching filename),
158/// but we still blindly overwrite same-named (global) files with no ownership tracking.
159/// Before recommending this feature for wider use we should track which global addon
160/// files are bootc-owned, and decide/document how this interacts with deployment
161/// rollback (a global addon update isn't reverted by rolling back to an older deployment).
162pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons";
163
164/// Where all staged Global UKI Addons are kept
165pub(crate) const STAGED_GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons.staged";
166
167#[derive(thiserror::Error, Debug)]
168pub(crate) enum UKIDigestMismatch {
169    #[error("The UKI has the wrong composefs= parameter (is '{actual}', should be '{expected}')")]
170    DigestParameter {
171        actual: String,
172        expected: String,
173        uki_name: Option<String>,
174    },
175    #[error(
176        "The UKI '{uki_name}' embedded composefs= digest ({actual:?}) doesn't match any of \
177         {combinations_tried} supported xattr filtering mode/EROFS format version combinations. \
178         The image may be corrupt, or was built with an incompatible composefs-rs version."
179    )]
180    UnsupportedCompatibility {
181        actual: Sha512HashValue,
182        uki_name: String,
183        combinations_tried: usize,
184    },
185}
186
187impl UKIDigestMismatch {
188    fn digest_parameter(actual: String, expected: String, uki_name: Option<String>) -> Self {
189        Self::DigestParameter {
190            actual,
191            expected,
192            uki_name,
193        }
194    }
195
196    fn unsupported_compatibility(
197        actual: Sha512HashValue,
198        uki_name: String,
199        combinations_tried: usize,
200    ) -> Self {
201        Self::UnsupportedCompatibility {
202            actual,
203            uki_name,
204            combinations_tried,
205        }
206    }
207
208    fn uki_name(&self) -> Option<&str> {
209        match self {
210            Self::DigestParameter { uki_name, .. } => uki_name.as_deref(),
211            Self::UnsupportedCompatibility { uki_name, .. } => Some(uki_name),
212        }
213    }
214}
215
216pub(crate) fn print_uki_dumpfile_diff(
217    mismatch: &UKIDigestMismatch,
218    repo: &ComposefsRepository,
219    fs: &FileSystem<Sha512HashValue>,
220) {
221    let dumpfile_name = mismatch
222        .uki_name()
223        .and_then(|x| x.strip_suffix(EFI_EXT).map(|x| format!("{x}.dump")));
224
225    let Some(dumpfile_name) = &dumpfile_name else {
226        return;
227    };
228
229    let Some(stored_content) = read_dumpfile_from_fs(fs, dumpfile_name, repo) else {
230        tracing::debug!("Dumpfile {dumpfile_name} not found in filesystem");
231        return;
232    };
233
234    let Ok(tempdir) = tempfile::tempdir() else {
235        tracing::debug!("Creating tempdir failed");
236        return;
237    };
238    let path = tempdir.path();
239    let Ok(tempdir_cap) = Dir::open_ambient_dir(path, ambient_authority()) else {
240        tracing::debug!("Opening tempdir failed");
241        return;
242    };
243
244    let Ok(mut stored_file) = tempdir_cap.create("stored") else {
245        tracing::debug!("Creating 'stored' tmpfile failed");
246        return;
247    };
248    if stored_file.write_all(&stored_content).is_err() {
249        tracing::debug!("Writing to tmpfile failed");
250        return;
251    }
252
253    let Ok(mut current_file) = tempdir_cap.create("current") else {
254        tracing::debug!("Creating 'current' tmpfile failed");
255        return;
256    };
257    if let Err(e) = dumpfile::write_dumpfile(&mut current_file, fs) {
258        tracing::debug!("Writing dumpfile failed: {e}");
259        return;
260    }
261
262    let mut cmd = std::process::Command::new("diff");
263    cmd.arg("--color=auto")
264        .arg(format!("{}/stored", path.display()))
265        .arg(format!("{}/current", path.display()));
266
267    // Redirect stdout to stderr since this is diagnostic output
268    if let Ok(fd) = std::io::stderr().as_fd().try_clone_to_owned() {
269        cmd.stdout(fd);
270    }
271
272    if let Err(e) = cmd.status() {
273        tracing::warn!("diffing dumpfiles failed with Err: {e:?}");
274    }
275}
276
277/// Print the dumpfile diff when a UKI digest mismatch is about to escape.
278pub(crate) fn print_uki_dumpfile_diff_on_mismatch<T>(
279    result: Result<T>,
280    repo: &ComposefsRepository,
281    fs: &FileSystem<Sha512HashValue>,
282) -> Result<T> {
283    if let Err(error) = &result {
284        if let Some(mismatch) = error.downcast_ref::<UKIDigestMismatch>() {
285            print_uki_dumpfile_diff(mismatch, repo, fs);
286        }
287    }
288    result
289}
290
291fn read_regular_file(
292    file: &RegularFile<Sha512HashValue>,
293    repo: &ComposefsRepository,
294) -> Option<Vec<u8>> {
295    match file {
296        RegularFile::External(object_id, _) | RegularFile::ExternalNoVerity(object_id, _) => {
297            repo.read_object(object_id).ok()
298        }
299        RegularFile::Inline(data) => Some(data.to_vec()),
300        RegularFile::Sparse(_) => None,
301    }
302}
303
304fn read_dumpfile_from_fs(
305    fs: &FileSystem<Sha512HashValue>,
306    dumpfile_name: &str,
307    repo: &ComposefsRepository,
308) -> Option<Vec<u8>> {
309    let root = fs.as_dir();
310    let dumpfile_os = std::ffi::OsStr::new(dumpfile_name);
311
312    if let Ok(boot_dir) = root.get_directory_ref(BOOT.as_ref()) {
313        if let Ok(file) = boot_dir.get_file(dumpfile_os) {
314            return read_regular_file(file, repo);
315        }
316    }
317
318    None
319}
320
321pub(crate) enum BootSetupType<'a> {
322    /// For initial setup, i.e. install to-disk
323    Setup((&'a RootSetup, &'a State, &'a PostFetchState, bool)),
324    /// For `bootc upgrade`
325    Upgrade(
326        (
327            &'a Storage,
328            &'a BootedComposefs,
329            &'a Host,
330            Option<&'a UkiAddonOpts>,
331        ),
332    ),
333}
334
335impl BootSetupType<'_> {
336    /// The directory holding GRUB's configuration and the BLS entries: `boot`
337    /// in the target root at install time. On a booted system, it's
338    /// `/sysroot/boot`, or `/boot` if that's a separate boot partition.
339    fn grub_boot_path(&self) -> Result<Utf8PathBuf> {
340        match self {
341            Self::Setup((root_setup, ..)) => Ok(root_setup.physical_root_path.join(BOOT)),
342            Self::Upgrade((storage, ..)) => Ok(storage.require_grub_boot_path()?.to_owned()),
343        }
344    }
345}
346
347/// The absolute path GRUB resolves the kernels and initramfses in BLS entries
348/// against: the root of the filesystem holding `grub_boot_path`.
349fn grub_bls_abs_entries_path(grub_boot_path: &Utf8Path) -> Result<Utf8PathBuf> {
350    let (Some(parent), Some(name)) = (grub_boot_path.parent(), grub_boot_path.file_name()) else {
351        anyhow::bail!("Invalid boot path {grub_boot_path}");
352    };
353    let parent = Dir::open_ambient_dir(parent, ambient_authority())
354        .with_context(|| format!("Opening {parent}"))?;
355    // If "boot" is a partition, we want the paths to be absolute to "/"
356    let is_mountpoint = parent
357        .is_mountpoint(name)
358        .with_context(|| format!("Checking if {grub_boot_path} is a mountpoint"))?;
359    // We can be fairly sure that the kernels we target support `statx`
360    let root = Utf8Path::new("/");
361    Ok(if is_mountpoint == Some(true) {
362        root.to_owned()
363    } else {
364        root.join(BOOT)
365    })
366}
367
368#[derive(
369    ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema,
370)]
371pub enum BootType {
372    #[default]
373    Bls,
374    Uki,
375    Aboot,
376}
377
378impl ::std::fmt::Display for BootType {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        let s = match self {
381            BootType::Bls => "bls",
382            BootType::Uki => "uki",
383            BootType::Aboot => "aboot",
384        };
385
386        write!(f, "{}", s)
387    }
388}
389
390impl TryFrom<&str> for BootType {
391    type Error = anyhow::Error;
392
393    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
394        match value {
395            "bls" => Ok(Self::Bls),
396            "uki" => Ok(Self::Uki),
397            "aboot" => Ok(Self::Aboot),
398            unrecognized => Err(anyhow::anyhow!(
399                "Unrecognized boot option: '{unrecognized}'"
400            )),
401        }
402    }
403}
404
405impl From<&ComposefsBootEntry<Sha512HashValue>> for BootType {
406    fn from(entry: &ComposefsBootEntry<Sha512HashValue>) -> Self {
407        match entry {
408            ComposefsBootEntry::Type1(..) => Self::Bls,
409            ComposefsBootEntry::Type2(..) => Self::Uki,
410            ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls,
411            ComposefsBootEntry::Aboot(..) => Self::Aboot,
412        }
413    }
414}
415
416/// Returns the beginning of the grub2/user.cfg file
417/// where we source a file containing the ESPs filesystem UUID
418pub(crate) fn get_efi_uuid_source() -> String {
419    format!(
420        r#"
421if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then
422        source ${{config_directory}}/{EFI_UUID_FILE}
423fi
424"#
425    )
426}
427
428/// Mount flags shared by all ESP mounts: non-executable, no setuid.
429const ESP_MOUNT_FLAGS: MountFlags =
430    MountFlags::from_bits_retain(MountFlags::NOEXEC.bits() | MountFlags::NOSUID.bits());
431
432/// FAT mount options: owner-only permissions on files (0600) and dirs (0700).
433const ESP_MOUNT_DATA: &std::ffi::CStr = c"fmask=0177,dmask=0077";
434
435/// Fresh `mount(2)` of the ESP into a tempdir. Returns EBUSY if the device
436/// is already mounted in the current mount namespace; callers should use
437/// [`mount_esp_readonly`] or [`mount_esp_writable`] instead of this primitive
438/// so that pre-existing mounts are handled.
439pub fn mount_esp(device: &str) -> Result<TempMount> {
440    TempMount::mount_dev(device, "vfat", ESP_MOUNT_FLAGS, Some(ESP_MOUNT_DATA))
441}
442
443/// Get a read-only view of the ESP for the provided device, gracefully
444/// handling the case where the ESP is already mounted in the root mount
445/// namespace (e.g. via `systemd.mount-extra=UUID=<ESP>:/boot:auto:ro`).
446/// If the ESP is already mounted, that mount is cloned privately into a
447/// tempdir; otherwise a fresh read-write mount is performed. The returned
448/// mount may be rw or ro; callers must not write through it.
449pub fn mount_esp_readonly(device: &str) -> Result<TempMount> {
450    if let Some(existing) = bootc_mount::find_mount_target_by_source(device)? {
451        return TempMount::clone_existing_mount(&existing);
452    }
453    mount_esp(device)
454}
455
456/// Get a read-write view of the ESP for the provided device, gracefully
457/// handling the case where the ESP is already mounted (possibly read-only)
458/// in the current mount namespace.
459///
460/// If the ESP's device is already mounted, that mount is unconditionally
461/// remounted read-write *in place* first -- the same approach already
462/// used for `/sysroot` in `open_dir_remount_rw`, which likewise doesn't
463/// bother checking whether it's already writable before remounting.
464/// This matters because our own fresh `mount(2)` below would otherwise
465/// get `EBUSY` from the kernel's `get_tree_bdev_flags()` if the existing
466/// mount's `MS_RDONLY` state doesn't match what we're requesting (e.g.
467/// via a `systemd.mount-extra=UUID=...:/boot:auto:ro` cmdline karg
468/// written by `bootc install to-filesystem`). Note remounting a private
469/// *clone* of the existing mount would not be sufficient here: that only
470/// clears the clone's own mount-level read-only flag, not the shared
471/// superblock's, so writes through it would still fail with `EROFS`.
472/// Remounting the existing mount directly is safe because bootc always
473/// runs in its own unshared mount namespace (see
474/// `cli::ensure_self_unshared_mount_namespace`).
475pub fn mount_esp_writable(device: &str) -> Result<TempMount> {
476    if let Some(existing) = bootc_mount::find_mount_target_by_source(device)? {
477        rustix::mount::mount_remount(existing.as_str(), ESP_MOUNT_FLAGS, "")
478            .with_context(|| format!("Remounting {existing} read-write"))?;
479    }
480    mount_esp(device)
481}
482
483/// Mount the ESP from `device` at the given path and return a guard that
484/// synchronously unmounts (and flushes) it on drop.
485///
486/// Applies the same pre-emptive remount as `mount_esp_writable`; see
487/// there for details and caveats.
488pub(crate) fn mount_esp_at(
489    device: &str,
490    path: std::path::PathBuf,
491) -> Result<bootc_mount::tempmount::MountGuard> {
492    if let Some(existing) = bootc_mount::find_mount_target_by_source(device)? {
493        rustix::mount::mount_remount(existing.as_str(), ESP_MOUNT_FLAGS, "")
494            .with_context(|| format!("Remounting {existing} read-write"))?;
495    }
496    bootc_mount::tempmount::MountGuard::mount(
497        device,
498        path,
499        "vfat",
500        ESP_MOUNT_FLAGS,
501        Some(ESP_MOUNT_DATA),
502    )
503}
504
505/// Filename release field for primary (new/upgraded) entry.
506/// Grub parses this as the "release" field and sorts descending, so "1" > "0".
507pub(crate) const FILENAME_PRIORITY_PRIMARY: &str = "1";
508
509/// Filename release field for secondary (currently booted) entry.
510pub(crate) const FILENAME_PRIORITY_SECONDARY: &str = "0";
511
512/// Sort-key priority for primary (new/upgraded) entry.
513/// Systemd-boot sorts by sort-key in ascending order, so "0" appears before "1".
514pub(crate) const SORTKEY_PRIORITY_PRIMARY: &str = "0";
515
516/// Sort-key priority for secondary (currently booted) entry.
517pub(crate) const SORTKEY_PRIORITY_SECONDARY: &str = "1";
518
519/// Generate BLS Type 1 entry filename compatible with Grub's RPM-style parsing.
520///
521/// Format: `bootc_{os_id}-{version}-{priority}.conf`
522///
523/// Grub parses this as:
524/// - name: `bootc_{os_id}` (hyphens in os_id replaced with underscores)
525/// - version: `{version}`
526/// - release: `{priority}`
527///
528/// The underscore replacement prevents Grub from mis-parsing os_id values
529/// containing hyphens (e.g., "fedora-coreos" → "fedora_coreos").
530pub fn type1_entry_conf_file_name(
531    os_id: &str,
532    version: impl std::fmt::Display,
533    priority: &str,
534) -> String {
535    let os_id_safe = os_id.replace('-', "_");
536    format!("{BLS_ENTRY_FILE_PREFIX}{os_id_safe}-{version}-{priority}.conf")
537}
538
539/// Generate sort key for the primary (new/upgraded) boot entry.
540/// Format: bootc-{id}-0
541/// Systemd-boot sorts ascending by sort-key, so "0" comes first.
542/// Grub ignores sort-key and uses filename/version ordering.
543pub(crate) fn primary_sort_key(os_id: &str) -> String {
544    format!("bootc-{os_id}-{SORTKEY_PRIORITY_PRIMARY}")
545}
546
547/// Generate sort key for the secondary (currently booted) boot entry.
548/// Format: bootc-{id}-1
549pub(crate) fn secondary_sort_key(os_id: &str) -> String {
550    format!("bootc-{os_id}-{SORTKEY_PRIORITY_SECONDARY}")
551}
552
553/// Returns the os_id in a sort key from [`primary_sort_key`] or
554/// [`secondary_sort_key`], or `None` if it isn't in their format.
555pub(crate) fn os_id_from_sort_key(sort_key: &str) -> Option<&str> {
556    let (os_id, _priority) = sort_key.strip_prefix("bootc-")?.rsplit_once('-')?;
557    Some(os_id)
558}
559
560/// Returns the name of the directory where we store Type1 boot entries
561pub(crate) fn get_type1_dir_name(depl_verity: &str) -> String {
562    format!("{TYPE1_BOOT_DIR_PREFIX}{depl_verity}")
563}
564
565/// Returns the name of a UKI given verity digest
566pub(crate) fn get_uki_name(depl_verity: &str) -> String {
567    format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_EXT}")
568}
569
570/// Returns the name of a UKI Addon directory given verity digest
571pub(crate) fn get_uki_addon_dir_name(depl_verity: &str) -> String {
572    format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_DIR_EXT}")
573}
574
575/// Returns the name of a scoped/local UKI Addon directory given name
576/// with or without the `.addon.efi` prefix
577pub(crate) fn get_scoped_uki_addon_name(name: &str) -> String {
578    let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name);
579    format!("{name_wo_suffix}{EFI_ADDON_FILE_EXT}")
580}
581
582/// Returns the name of a global UKI Addon directory given name
583/// with or without the `.addon.efi` prefix
584pub(crate) fn get_global_uki_addon_name(name: &str) -> String {
585    let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name);
586    format!("{UKI_NAME_PREFIX}{name_wo_suffix}{EFI_ADDON_FILE_EXT}")
587}
588
589/// Compute SHA256Sum of VMlinuz + Initrd
590///
591/// # Arguments
592/// * entry - BootEntry containing VMlinuz and Initrd
593/// * repo - The composefs repository
594#[context("Computing boot digest")]
595fn compute_boot_digest(
596    entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
597    repo: &crate::store::ComposefsRepository,
598) -> Result<String> {
599    let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?;
600
601    let Some(initramfs) = &entry.initramfs else {
602        anyhow::bail!("initramfs not found");
603    };
604
605    let initramfs = read_file(initramfs, &repo).context("Reading intird")?;
606
607    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
608        .context("Creating hasher")?;
609
610    hasher.update(&vmlinuz).context("hashing vmlinuz")?;
611    hasher.update(&initramfs).context("hashing initrd")?;
612
613    let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
614
615    Ok(hex::encode(digest))
616}
617
618#[context("Computing boot digest for Type1 entries")]
619fn compute_boot_digest_type1(dir: &Dir) -> Result<String> {
620    let mut vmlinuz = dir
621        .open(VMLINUZ)
622        .with_context(|| format!("Opening {VMLINUZ}"))?;
623
624    let mut initrd = dir
625        .open(INITRD)
626        .with_context(|| format!("Opening {INITRD}"))?;
627
628    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
629        .context("Creating hasher")?;
630
631    std::io::copy(&mut vmlinuz, &mut hasher)?;
632    std::io::copy(&mut initrd, &mut hasher)?;
633
634    let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
635
636    Ok(hex::encode(digest))
637}
638
639/// Compute SHA256Sum of .linux + .initrd section of the UKI
640///
641/// # Arguments
642/// * entry - BootEntry containing VMlinuz and Initrd
643/// * repo - The composefs repository
644#[context("Computing boot digest")]
645pub(crate) fn compute_boot_digest_uki<R: Read + Seek>(uki_reader: &mut R) -> Result<String> {
646    let vmlinuz = uki::get_section_buffered(uki_reader, ".linux").context(".linux not present")?;
647    uki_reader
648        .seek(SeekFrom::Start(0))
649        .context("Moving seek to 0")?;
650    let initramfs =
651        uki::get_section_buffered(uki_reader, ".initrd").context(".initrd not present")?;
652
653    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
654        .context("Creating hasher")?;
655
656    hasher.update(&vmlinuz).context("hashing vmlinuz")?;
657    hasher.update(&initramfs).context("hashing initrd")?;
658
659    uki_reader.seek(SeekFrom::Start(0))?;
660    match uki::get_section_buffered(uki_reader, ".dtb") {
661        Ok(data) => hasher.update(&data).context("hashing dtb")?,
662        Err(uki::UkiError::MissingSection(_)) => {}
663        Err(error) => return Err(error.into()),
664    }
665
666    let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
667
668    Ok(hex::encode(digest))
669}
670
671#[context("Computing Android boot digest")]
672pub(crate) fn compute_boot_digest_aboot<R: Read + Seek>(
673    reader: &mut R,
674    metadata: &AndroidBootImage,
675) -> Result<String> {
676    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
677        .context("Creating hasher")?;
678    for component in [Component::Kernel, Component::Ramdisk] {
679        hasher.update(&metadata.component(reader, component)?)?;
680    }
681    match metadata.component(reader, Component::Dtb) {
682        Ok(dtb) => hasher.update(&dtb)?,
683        Err(AndroidBootError::MissingComponent("dtb")) => {}
684        Err(error) => return Err(error.into()),
685    }
686    Ok(hex::encode(hasher.finish()?))
687}
688
689/// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum
690///
691/// # Returns
692/// Returns the directory name that has the same sha256 digest for vmlinuz + initrd as the one
693/// that's passed in
694#[context("Checking boot entry duplicates")]
695pub(crate) fn find_vmlinuz_initrd_duplicate(
696    storage: &Storage,
697    digest: &str,
698) -> Result<Option<String>> {
699    let boot_dir = storage.bls_boot_binaries_dir()?;
700
701    for entry in boot_dir.entries_utf8()? {
702        let entry = entry?;
703        let dir_name = entry.file_name()?;
704
705        if !entry.file_type()?.is_dir() {
706            continue;
707        }
708
709        let Some(..) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
710            continue;
711        };
712
713        let entry_digest = compute_boot_digest_type1(&boot_dir.open_dir(&dir_name)?)?;
714
715        if entry_digest == digest {
716            return Ok(Some(dir_name));
717        }
718    }
719
720    Ok(None)
721}
722
723#[context("Writing BLS entries to disk")]
724fn write_bls_boot_entries_to_disk(
725    boot_dir: &Utf8PathBuf,
726    deployment_id: &Sha512HashValue,
727    entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
728    repo: &crate::store::ComposefsRepository,
729) -> Result<()> {
730    let dir_name = get_type1_dir_name(&deployment_id.to_hex());
731
732    // Write the initrd and vmlinuz at /boot/composefs-<id>/
733    let path = boot_dir.join(&dir_name);
734    create_dir_all(&path)?;
735
736    let entries_dir = Dir::open_ambient_dir(&path, ambient_authority())
737        .with_context(|| format!("Opening {path}"))?;
738
739    entries_dir
740        .atomic_write(
741            VMLINUZ,
742            read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?,
743        )
744        .context("Writing vmlinuz to path")?;
745
746    let Some(initramfs) = &entry.initramfs else {
747        anyhow::bail!("initramfs not found");
748    };
749
750    entries_dir
751        .atomic_write(
752            INITRD,
753            read_file(initramfs, &repo).context("Reading initrd")?,
754        )
755        .context("Writing initrd to path")?;
756
757    // Can't call fsync on O_PATH fds, so re-open it as a non O_PATH fd
758    let owned_fd = entries_dir
759        .reopen_as_ownedfd()
760        .context("Reopen as owned fd")?;
761
762    rustix::fs::fsync(owned_fd).context("fsync")?;
763
764    Ok(())
765}
766
767/// Parses /usr/lib/os-release and returns (id, title, version)
768/// Expects a reference to the root of the filesystem, or the root
769/// of a mounted EROFS
770pub fn parse_os_release(root: &Dir) -> Result<Option<(String, Option<String>, Option<String>)>> {
771    // Every update should have its own /usr/lib/os-release
772    let file = root
773        .open_optional("usr/lib/os-release")
774        .context("Opening usr/lib/os-release")?;
775
776    let Some(mut os_rel_file) = file else {
777        return Ok(None);
778    };
779
780    let mut file_contents = String::new();
781    os_rel_file.read_to_string(&mut file_contents)?;
782
783    let parsed = OsReleaseInfo::parse(&file_contents);
784
785    let os_id = parsed
786        .get_value(&["ID"])
787        .unwrap_or_else(|| "bootc".to_string());
788
789    Ok(Some((
790        os_id,
791        parsed.get_pretty_name(),
792        parsed.get_version(),
793    )))
794}
795
796struct BLSEntryPath {
797    /// Where to write vmlinuz/initrd
798    entries_path: Utf8PathBuf,
799    /// The absolute path, with reference to the partition's root, where the vmlinuz/initrd are written to
800    abs_entries_path: Utf8PathBuf,
801    /// Where to write the .conf files
802    config_path: Utf8PathBuf,
803}
804
805/// Replace either karg spelling to ensure only the selected EROFS format remains.
806fn replace_composefs_karg(cmdline: &mut Cmdline, new_karg: &str) -> Result<()> {
807    cmdline.remove(&ParameterKey::from(KARG_V2));
808    cmdline.remove(&ParameterKey::from(KARG_COMPOSEFS_DIGEST));
809    let parameter = Parameter::parse(new_karg).context("Parsing composefs kernel parameter")?;
810    cmdline.add_or_modify(&parameter);
811    Ok(())
812}
813
814/// Sets up and writes BLS entries and binaries (VMLinuz + Initrd) to disk
815///
816/// # Returns
817/// Returns the SHA256Sum of VMLinuz + Initrd combo. Error if any
818#[context("Setting up BLS boot")]
819pub(crate) fn setup_composefs_bls_boot(
820    setup_type: BootSetupType,
821    repo: &crate::store::ComposefsRepository,
822    id: &Sha512HashValue,
823    format_version: FormatVersion,
824    entry: &ComposefsBootEntry<Sha512HashValue>,
825    mounted_erofs: &Dir,
826) -> Result<String> {
827    let id_hex = id.to_hex();
828
829    let (boot_partition, mut cmdline_refs, bootloader) = match setup_type {
830        BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => {
831            // root_setup.kargs has [root=UUID=<UUID>, "rw"]
832            let mut cmdline_options = Cmdline::new();
833
834            cmdline_options.extend(&root_setup.kargs);
835
836            if let Some(user_kargs) = &state.config_opts.karg {
837                for karg in user_kargs {
838                    cmdline_options.extend(karg);
839                }
840            }
841
842            let composefs_cmdline =
843                build_composefs_karg(id.clone(), format_version, allow_missing_fsverity);
844            cmdline_options.extend(&Cmdline::from(&composefs_cmdline));
845
846            // If there's a separate /boot partition, add a systemd.mount-extra
847            // karg so systemd mounts it after reboot. This avoids writing to
848            // /etc/fstab which conflicts with transient etc (see #1388).
849            if let Some(boot) = root_setup.boot_mount_spec() {
850                if !boot.source.is_empty() {
851                    let mount_extra = format!(
852                        "systemd.mount-extra={}:/boot:{}:{}",
853                        boot.source,
854                        boot.fstype,
855                        boot.options.as_deref().unwrap_or("defaults"),
856                    );
857                    cmdline_options.extend(&Cmdline::from(mount_extra.as_str()));
858                    tracing::debug!("Added /boot mount karg: {mount_extra}");
859                }
860            }
861
862            // Locate ESP partition device by walking up to the root disk(s)
863            //
864            // NOTE: Not using [`find_booted_from_esp`] as we aren't booted
865            // into a bootc system
866            let esp_part = root_setup.device_info.find_first_colocated_esp()?;
867
868            (
869                BootPartition::for_install(esp_part.path().into(), postfetch.detected_bootloader)?,
870                cmdline_options,
871                postfetch.detected_bootloader.clone(),
872            )
873        }
874
875        BootSetupType::Upgrade((storage, booted_cfs, host, _)) => {
876            let bootloader = host.require_composefs_booted()?.bootloader.clone();
877
878            let boot_dir = storage.require_boot_dir()?;
879            let current_cfg = get_booted_bls(&boot_dir, booted_cfs)?;
880
881            let mut cmdline = match current_cfg.cfg_type {
882                BLSConfigType::NonEFI { options, .. } => {
883                    let options = options
884                        .ok_or_else(|| anyhow::anyhow!("No 'options' found in BLS Config"))?;
885
886                    Cmdline::from(options)
887                }
888
889                _ => anyhow::bail!("Found NonEFI config"),
890            };
891
892            replace_composefs_karg(
893                &mut cmdline,
894                &build_composefs_karg(
895                    id.clone(),
896                    format_version,
897                    booted_cfs.cmdline.allow_missing_fsverity,
898                ),
899            )?;
900
901            (
902                storage.require_boot_partition()?.clone(),
903                cmdline,
904                bootloader,
905            )
906        }
907    };
908
909    let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
910
911    let current_root = if is_upgrade {
912        Some(&Dir::open_ambient_dir("/", ambient_authority()).context("Opening root")? as &Dir)
913    } else {
914        None
915    };
916
917    compute_new_kargs(mounted_erofs, current_root, &mut cmdline_refs)?;
918
919    let (entry_paths, _tmpdir_guard) = match bootloader.kind()? {
920        BootloaderKind::GRUBClassic => {
921            let boot_path = setup_type.grub_boot_path()?;
922
923            // Grub wants the paths to be absolute against the mounted drive that the kernel +
924            // initrd live in
925            let abs_entries_path = grub_bls_abs_entries_path(&boot_path)?;
926
927            (
928                BLSEntryPath {
929                    entries_path: boot_path.clone(),
930                    config_path: boot_path,
931                    abs_entries_path,
932                },
933                None,
934            )
935        }
936
937        BootloaderKind::BLSCompatible => {
938            let efi_mount = mount_esp_writable(boot_partition.device().as_str())
939                .context("Mounting boot partition")?;
940
941            let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?);
942            let efi_linux_dir = mounted_efi.join(EFI_LINUX);
943
944            (
945                BLSEntryPath {
946                    entries_path: efi_linux_dir,
947                    config_path: mounted_efi.clone(),
948                    abs_entries_path: Utf8PathBuf::from("/").join(EFI_LINUX),
949                },
950                Some(efi_mount),
951            )
952        }
953    };
954
955    let (bls_config, boot_digest, os_id) = match &entry {
956        ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"),
957        ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"),
958        ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"),
959
960        ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => {
961            let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo)
962                .context("Computing boot digest")?;
963
964            let osrel = parse_os_release(mounted_erofs)?;
965
966            let (os_id, title, version, sort_key) = match osrel {
967                Some((id_str, title_opt, version_opt)) => (
968                    id_str.clone(),
969                    title_opt.unwrap_or_else(|| id.to_hex()),
970                    version_opt.unwrap_or_else(|| id.to_hex()),
971                    primary_sort_key(&id_str),
972                ),
973                None => {
974                    let default_id = "bootc".to_string();
975                    (
976                        default_id.clone(),
977                        id.to_hex(),
978                        id.to_hex(),
979                        primary_sort_key(&default_id),
980                    )
981                }
982            };
983
984            let mut bls_config = BLSConfig::default();
985
986            let entries_dir = get_type1_dir_name(&id_hex);
987
988            bls_config
989                .with_title(title)
990                .with_version(version)
991                .with_sort_key(sort_key)
992                .with_cfg(BLSConfigType::NonEFI {
993                    linux: entry_paths
994                        .abs_entries_path
995                        .join(&entries_dir)
996                        .join(VMLINUZ),
997                    initrd: vec![entry_paths.abs_entries_path.join(&entries_dir).join(INITRD)],
998                    options: Some(cmdline_refs),
999                });
1000
1001            let shared_entry = match setup_type {
1002                BootSetupType::Setup(_) => None,
1003                BootSetupType::Upgrade((storage, ..)) => {
1004                    find_vmlinuz_initrd_duplicate(storage, &boot_digest)?
1005                }
1006            };
1007
1008            match shared_entry {
1009                Some(shared_entry) => {
1010                    // Multiple deployments could be using the same kernel + initrd, but there
1011                    // would be only one available
1012                    //
1013                    // Symlinking directories themselves would be better, but vfat does not support
1014                    // symlinks
1015                    match bls_config.cfg_type {
1016                        BLSConfigType::NonEFI {
1017                            ref mut linux,
1018                            ref mut initrd,
1019                            ..
1020                        } => {
1021                            *linux = entry_paths
1022                                .abs_entries_path
1023                                .join(&shared_entry)
1024                                .join(VMLINUZ);
1025
1026                            *initrd = vec![
1027                                entry_paths
1028                                    .abs_entries_path
1029                                    .join(&shared_entry)
1030                                    .join(INITRD),
1031                            ];
1032                        }
1033
1034                        _ => unreachable!(),
1035                    };
1036                }
1037
1038                None => {
1039                    write_bls_boot_entries_to_disk(
1040                        &entry_paths.entries_path,
1041                        id,
1042                        usr_lib_modules_vmlinuz,
1043                        &repo,
1044                    )?;
1045                }
1046            };
1047
1048            (bls_config, boot_digest, os_id)
1049        }
1050    };
1051
1052    let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())
1053        .with_context(|| format!("Opening {:?}", entry_paths.config_path))?;
1054    write_type1_entries(&boot_dir, &setup_type, &os_id, &bls_config)?;
1055
1056    Ok(boot_digest)
1057}
1058
1059struct UKIInfo {
1060    boot_label: String,
1061    version: Option<String>,
1062    os_id: Option<String>,
1063    boot_digest: String,
1064    composefs_digest: Option<Sha512HashValue>,
1065}
1066
1067/// The EROFS format a UKI composefs kernel argument claims for its digest.
1068#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1069enum UkiCandidateFormat {
1070    /// `composefs.digest=v1-<hash>-<lg>:<hex>`
1071    V1,
1072    /// `composefs.digest=v2-<hash>-<lg>:<hex>`
1073    V2,
1074    /// Bare `composefs=<hex>`.  composefs-rs documents this as shorthand for
1075    /// V2, but bootc has sealed several formats under it: 1.16.0 through
1076    /// 1.16.2 predate format versioning (the original composefs-rs encoding
1077    /// that V2 descends from), 1.16.3 sealed V2, and 1.16.4 through 1.16.13
1078    /// sealed V1 because composefs-rs switched its default while `ukify` kept
1079    /// emitting only the bare key.  Only the digest itself identifies the image.
1080    Unspecified,
1081}
1082
1083impl UkiCandidateFormat {
1084    /// Whether an image serialized as `version` satisfies this argument.
1085    fn accepts(self, version: FormatVersion) -> bool {
1086        match self {
1087            Self::V1 => matches!(version, FormatVersion::V0 | FormatVersion::V1),
1088            Self::V2 => version == FormatVersion::V2,
1089            Self::Unspecified => true,
1090        }
1091    }
1092}
1093
1094impl std::fmt::Display for UkiCandidateFormat {
1095    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1096        f.write_str(match self {
1097            Self::V1 => "V1",
1098            Self::V2 => "V2",
1099            Self::Unspecified => "V1 or V2",
1100        })
1101    }
1102}
1103
1104/// One composefs digest from a UKI command line.
1105#[derive(Clone, Debug, PartialEq, Eq)]
1106struct UkiComposefsCandidate {
1107    digest: Sha512HashValue,
1108    insecure: bool,
1109    format: UkiCandidateFormat,
1110}
1111
1112/// Parse every composefs argument emitted by supported bootc UKIs.
1113fn parse_uki_composefs_candidates(cmdline: &str) -> Result<Vec<UkiComposefsCandidate>> {
1114    let mut candidates = Vec::with_capacity(2);
1115    let mut seen_legacy = false;
1116
1117    for parameter in Cmdline::from(cmdline).iter() {
1118        let key = parameter.key();
1119        let legacy = &*key == KARG_V2;
1120        if !legacy && &*key != KARG_COMPOSEFS_DIGEST {
1121            continue;
1122        }
1123
1124        let value = parameter
1125            .value()
1126            .ok_or_else(|| anyhow!("{key}= composefs kernel argument has no value"))?;
1127        if legacy {
1128            anyhow::ensure!(
1129                !seen_legacy,
1130                "duplicate {KARG_V2}= composefs kernel argument"
1131            );
1132            seen_legacy = true;
1133        }
1134
1135        // Reuse the composefs-rs parser for the digest and insecure marker,
1136        // but don't trust the format it infers for the bare legacy key.
1137        let parsed =
1138            ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(&format!("{key}={value}"))?;
1139        let (digest, insecure, format) = match parsed {
1140            None => continue,
1141            Some(ComposefsBootCmdline::V1 { digest, insecure }) => {
1142                (digest, insecure, UkiCandidateFormat::V1)
1143            }
1144            Some(ComposefsBootCmdline::V2 { digest, insecure }) if legacy => {
1145                (digest, insecure, UkiCandidateFormat::Unspecified)
1146            }
1147            Some(ComposefsBootCmdline::V2 { digest, insecure }) => {
1148                (digest, insecure, UkiCandidateFormat::V2)
1149            }
1150        };
1151        candidates.push(UkiComposefsCandidate {
1152            digest,
1153            insecure,
1154            format,
1155        });
1156    }
1157    Ok(candidates)
1158}
1159
1160fn uki_candidates_policy(candidates: &[UkiComposefsCandidate]) -> Result<bool> {
1161    let first = candidates
1162        .first()
1163        .ok_or_else(|| anyhow!("No composefs digest in boot artifact cmdline"))?
1164        .insecure;
1165    anyhow::ensure!(
1166        candidates
1167            .iter()
1168            .all(|candidate| candidate.insecure == first),
1169        "Boot artifact composefs candidates have mixed fs-verity policies"
1170    );
1171    Ok(first)
1172}
1173
1174/// Retain the historical V1 preference for the deployment identity while
1175/// validating every fallback candidate.  This avoids changing the naming of
1176/// existing dual-format UKIs when their kernel arguments are reordered.
1177fn primary_uki_candidate(candidates: &[UkiComposefsCandidate]) -> &UkiComposefsCandidate {
1178    candidates
1179        .iter()
1180        .find(|candidate| candidate.format == UkiCandidateFormat::V1)
1181        .or_else(|| candidates.first())
1182        .expect("UKI candidates were checked as non-empty")
1183}
1184
1185#[derive(Clone, Debug, Default)]
1186pub(crate) struct ExpectedBootImageIds {
1187    v1: Vec<Sha512HashValue>,
1188    v2: Vec<Sha512HashValue>,
1189}
1190
1191impl ExpectedBootImageIds {
1192    /// The boot images whose format satisfies `candidate`.
1193    fn ids_for_candidate(
1194        &self,
1195        candidate: &UkiComposefsCandidate,
1196    ) -> impl Iterator<Item = &Sha512HashValue> {
1197        let (v1, v2): (&[_], &[_]) = match candidate.format {
1198            UkiCandidateFormat::V1 => (&self.v1, &[]),
1199            UkiCandidateFormat::V2 => (&[], &self.v2),
1200            UkiCandidateFormat::Unspecified => (&self.v1, &self.v2),
1201        };
1202        v1.iter().chain(v2)
1203    }
1204
1205    fn contains(&self, candidate: &UkiComposefsCandidate) -> bool {
1206        self.ids_for_candidate(candidate)
1207            .any(|id| id == &candidate.digest)
1208    }
1209
1210    fn add(&mut self, version: FormatVersion, digest: Sha512HashValue) {
1211        let ids = match version {
1212            FormatVersion::V0 | FormatVersion::V1 => &mut self.v1,
1213            FormatVersion::V2 => &mut self.v2,
1214        };
1215        if !ids.contains(&digest) {
1216            ids.push(digest);
1217        }
1218    }
1219}
1220
1221fn validate_uki_candidates(
1222    candidates: &[UkiComposefsCandidate],
1223    expected: &ExpectedBootImageIds,
1224    uki_name: Option<String>,
1225) -> Result<()> {
1226    for candidate in candidates {
1227        if expected.contains(candidate) {
1228            continue;
1229        }
1230        let expected_digests = expected
1231            .ids_for_candidate(candidate)
1232            .map(Sha512HashValue::to_hex)
1233            .collect::<Vec<_>>();
1234        let expected = if expected_digests.is_empty() {
1235            format!("a {} boot image", candidate.format)
1236        } else {
1237            expected_digests.join(", ")
1238        };
1239        return Err(UKIDigestMismatch::digest_parameter(
1240            candidate.digest.to_hex(),
1241            expected,
1242            uki_name,
1243        )
1244        .into());
1245    }
1246    Ok(())
1247}
1248
1249/// Determines the directory (under `mounted_efi`) that a PE binary should be written to.
1250///
1251/// - A UKI, or an addon scoped to a single UKI, is namespaced under [`BOOTC_UKI_DIR`] by
1252///   the deployment's verity digest, so it doesn't collide with other deployments.
1253/// - A global UKI addon applies to every UKI, so it's written to the shared
1254///   [`GLOBAL_UKI_ADDONS_DIR`] instead.
1255fn pe_output_dir(
1256    pe_type: &PEType,
1257    mounted_efi: &Path,
1258    file_path: &Utf8Path,
1259    uki_id: &Sha512HashValue,
1260    is_upgrade: bool,
1261) -> std::path::PathBuf {
1262    if matches!(pe_type, PEType::GlobalUkiAddon) {
1263        if is_upgrade {
1264            return mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR);
1265        }
1266
1267        return mounted_efi.join(GLOBAL_UKI_ADDONS_DIR);
1268    }
1269
1270    let efi_linux_path = mounted_efi.join(BOOTC_UKI_DIR);
1271
1272    match file_path.parent() {
1273        Some(parent) if parent.as_str().ends_with(EFI_ADDON_DIR_EXT) => {
1274            let dir_name = get_uki_addon_dir_name(&uki_id.to_hex());
1275            let renamed_path = parent
1276                .parent()
1277                .map(|p| p.join(&dir_name))
1278                .unwrap_or(dir_name.into());
1279
1280            efi_linux_path.join(renamed_path)
1281        }
1282
1283        Some(parent) => efi_linux_path.join(parent),
1284
1285        None => efi_linux_path,
1286    }
1287}
1288
1289/// Return a UKI/UKI Addon as a file descriptor from a `Type2Entry` boot entry
1290fn file_from_type2_entry(
1291    repo: &crate::store::ComposefsRepository,
1292    entry: &Type2Entry<Sha512HashValue>,
1293) -> Result<std::fs::File> {
1294    match &entry.file {
1295        RegularFile::Inline(..) => {
1296            // UKI/Addons would always be large enough to be an external object
1297            anyhow::bail!("File too small to be UKI/Addon")
1298        }
1299        RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
1300            Ok(std::fs::File::from(repo.open_object(id)?))
1301        }
1302        RegularFile::Sparse(..) => {
1303            anyhow::bail!("Sparse file cannot be a UKI/Addon")
1304        }
1305    }
1306}
1307
1308#[context("Parsing UKI cmdline from {uki_name}")]
1309/// Makes sure there is no composefs= cmdline in a global UKI Addon
1310/// Makes sure if we already have a parsed cmdline, we don't find another one
1311fn parse_uki_cmdline<R: Read + Seek>(
1312    pe_type: &PEType,
1313    missing_fsverity_allowed: bool,
1314    boot_ids: &ExpectedBootImageIds,
1315    uki_reader: &mut R,
1316    uki_name: &str,
1317    uki_info: &mut UKIInfo,
1318) -> Result<()> {
1319    // We expect this in the UKI itself and not in addons
1320    if matches!(pe_type, PEType::Uki) {
1321        let osrel = uki::get_text_section_buffered(uki_reader, ".osrel")?;
1322
1323        let parsed_osrel = OsReleaseInfo::parse(&osrel);
1324
1325        uki_reader.seek(SeekFrom::Start(0))?;
1326        let boot_digest = compute_boot_digest_uki(uki_reader)?;
1327
1328        uki_reader.seek(SeekFrom::Start(0))?;
1329
1330        uki_info.boot_label =
1331            uki::get_boot_label_buffered(uki_reader).context("Getting UKI boot label")?;
1332        uki_info.version = parsed_osrel.get_version();
1333        uki_info.os_id = parsed_osrel.get_value(&["ID"]);
1334        uki_info.boot_digest = boot_digest;
1335
1336        uki_reader.seek(SeekFrom::Start(0))?;
1337    }
1338
1339    // UKI Addon might not even have a cmdline
1340    let cmdline = uki::get_cmdline_buffered(uki_reader);
1341
1342    let cmdline_str = match cmdline {
1343        Ok(ref cmdline) => cmdline,
1344        Err(uki::UkiError::MissingSection(..)) => {
1345            // No .cmdline section here
1346            // We might find it in another PE binary
1347            return Ok(());
1348        }
1349        Err(e) => {
1350            return Err(e).context("Getting UKI cmdline");
1351        }
1352    };
1353
1354    tracing::debug!("cmdline found in {pe_type:?}: {cmdline_str}");
1355
1356    let composefs_candidates = parse_uki_composefs_candidates(&cmdline_str)
1357        .context("Parsing composefs kernel arguments")?;
1358
1359    // We could have a cmdline in an Addon, so don't early error out if we don't find it
1360    // immediately
1361    if composefs_candidates.is_empty() {
1362        return Ok(());
1363    };
1364
1365    // Make sure there's no composefs= in a global UKI Addon
1366    if matches!(pe_type, PEType::GlobalUkiAddon) {
1367        anyhow::bail!("Composefs cmdline {cmdline:?} found in a Global UKI Addon");
1368    }
1369
1370    let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?;
1371    let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone();
1372
1373    // Already found a cmdline, outright refuse another cmdline found in an addon
1374    // or otherwise, even if they're the same
1375    if let Some(found_cmdline) = &uki_info.composefs_digest {
1376        anyhow::bail!("Already had cmdline {found_cmdline:?}, found another {composefs_digest:?}");
1377    };
1378
1379    anyhow::ensure!(
1380        !missing_verity_allowed_cmdline || missing_fsverity_allowed,
1381        "The UKI requests insecure composefs operation, but this repository requires fs-verity.
1382        Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install."
1383    );
1384
1385    // If the UKI cmdline does not match what the user has passed as cmdline option
1386    // NOTE: This will only be checked for new installs and now upgrades/switches
1387    match missing_fsverity_allowed {
1388        true if !missing_verity_allowed_cmdline => {
1389            tracing::warn!(
1390                "--allow-missing-verity passed as option but UKI cmdline does not support it"
1391            );
1392        }
1393
1394        false if missing_verity_allowed_cmdline => {
1395            tracing::warn!("UKI cmdline has composefs set as insecure");
1396        }
1397
1398        _ => { /* no-op */ }
1399    }
1400
1401    validate_uki_candidates(&composefs_candidates, boot_ids, Some(uki_name.into()))?;
1402
1403    uki_info.composefs_digest = Some(composefs_digest);
1404
1405    Ok(())
1406}
1407
1408/// Copies Global UKI Addons from loader/addons to loader/addons.staged in case of
1409/// an upgrade/switch
1410#[context("Staging Global UKI Addons")]
1411fn stage_global_uki_addons(esp_fd: &Dir) -> Result<()> {
1412    // Remove any previously staged addons
1413    if esp_fd.exists(STAGED_GLOBAL_UKI_ADDONS_DIR) {
1414        esp_fd
1415            .remove_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR)
1416            .context("Removing previously staged global addons")?;
1417    };
1418
1419    let Some(global_addons_dir) = esp_fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? else {
1420        // No current Global UKI Addons to copy
1421        return Ok(());
1422    };
1423
1424    esp_fd
1425        .create_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR)
1426        .context("Creating directory for staged Global UKI Addons")?;
1427
1428    let staged_addons_dir = esp_fd
1429        .open_dir(STAGED_GLOBAL_UKI_ADDONS_DIR)
1430        .context("Opening staged Global UKI Addons dir")?;
1431
1432    // Now copy all addons to the staged directory
1433    for ent in global_addons_dir
1434        .entries_utf8()
1435        .context("Reading entries in Global UKI directory")?
1436    {
1437        let entry = ent?;
1438        let entry_name = entry.file_name().context("Reading file name")?;
1439        global_addons_dir
1440            .copy(&entry_name, &staged_addons_dir, &entry_name)
1441            .with_context(|| format!("Copying addon {entry_name}"))?;
1442    }
1443
1444    Ok(())
1445}
1446
1447/// Writes a PortableExecutable to ESP along with any PE specific or Global addons
1448#[context("Writing {} to ESP", entry.file_path.display())]
1449fn write_pe_to_esp(
1450    repo: &crate::store::ComposefsRepository,
1451    entry: &Type2Entry<Sha512HashValue>,
1452    uki_id: &Sha512HashValue,
1453    mounted_efi: impl AsRef<Path>,
1454    is_upgrade: bool,
1455) -> Result<()> {
1456    let mut uki_reader = file_from_type2_entry(repo, entry)?;
1457
1458    let file_path = Utf8Path::from_path(&entry.file_path)
1459        .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
1460
1461    let final_pe_path = pe_output_dir(
1462        &entry.pe_type,
1463        mounted_efi.as_ref(),
1464        file_path,
1465        uki_id,
1466        is_upgrade,
1467    );
1468    create_dir_all(&final_pe_path).with_context(|| format!("Creating {final_pe_path:?}"))?;
1469
1470    let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority())
1471        .with_context(|| format!("Opening {final_pe_path:?}"))?;
1472
1473    let pe_name = match entry.pe_type {
1474        PEType::Uki => get_uki_name(&uki_id.to_hex()),
1475        PEType::UkiAddon | PEType::GlobalUkiAddon => file_path
1476            .components()
1477            .last()
1478            .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))?
1479            .to_string(),
1480    };
1481
1482    // Prefix global Uki Addons for identification
1483    let pe_name = if matches!(entry.pe_type, PEType::GlobalUkiAddon) {
1484        get_global_uki_addon_name(&pe_name)
1485    } else if matches!(entry.pe_type, PEType::UkiAddon) {
1486        get_scoped_uki_addon_name(&pe_name)
1487    } else {
1488        pe_name
1489    };
1490
1491    uki_reader.seek(SeekFrom::Start(0))?;
1492    pe_dir
1493        .atomic_replace_with(pe_name, |writer| std::io::copy(&mut uki_reader, writer))
1494        .context("Writing UKI")?;
1495
1496    rustix::fs::fsync(
1497        pe_dir
1498            .reopen_as_ownedfd()
1499            .context("Reopening as owned fd")?,
1500    )
1501    .context("fsync")?;
1502
1503    Ok(())
1504}
1505
1506fn uki_file_name(file_path: &Path) -> Result<String> {
1507    let file_path = Utf8Path::from_path(file_path)
1508        .ok_or_else(|| anyhow!("UKI path is not valid UTF-8: {file_path:?}"))?;
1509    file_path
1510        .file_name()
1511        .map(str::to_owned)
1512        .ok_or_else(|| anyhow!("Could not get UKI file name from {file_path}"))
1513}
1514
1515/// Inspect primary boot artifacts to discover the image's fs-verity policy
1516/// before creating the durable repository. Final artifact validation occurs
1517/// while installing the boot artifact.
1518pub(crate) fn image_fsverity_policy(
1519    repo: &crate::store::ComposefsRepository,
1520    entries: &[ComposefsBootEntry<Sha512HashValue>],
1521) -> Result<Option<bool>> {
1522    let mut policy = None;
1523    for entry in entries {
1524        let cmdline = match entry {
1525            ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => {
1526                let mut reader = match &entry.file {
1527                    RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
1528                        std::fs::File::from(repo.open_object(id)?)
1529                    }
1530                    RegularFile::Inline(..) | RegularFile::Sparse(..) => {
1531                        anyhow::bail!("UKI file is not a regular external object")
1532                    }
1533                };
1534                uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")?
1535            }
1536            ComposefsBootEntry::Aboot(entry) => {
1537                let payload = read_aboot_payload(entry, repo)?;
1538                compute_aboot_boot_digest(entry.encoding, &payload)?.0
1539            }
1540            _ => continue,
1541        };
1542        let candidates = parse_uki_composefs_candidates(&cmdline)
1543            .context("Parsing composefs kernel arguments in boot artifact")?;
1544        let current = uki_candidates_policy(&candidates)?;
1545        if let Some(previous) = policy {
1546            anyhow::ensure!(
1547                previous == current,
1548                "Primary boot artifacts request conflicting composefs fs-verity policies"
1549            );
1550        } else {
1551            policy = Some(current);
1552        }
1553    }
1554    Ok(policy)
1555}
1556
1557/// The composefs digests embedded in the primary boot artifact, used to
1558/// reconcile the freshly-generated boot image digest before it is mounted.
1559struct ExpectedComposefsDigest {
1560    candidates: Vec<UkiComposefsCandidate>,
1561    uki_name: String,
1562}
1563
1564/// Scans `entries` for the primary UKI or aboot payload and
1565/// extracts the composefs digests embedded in its kernel cmdline.
1566///
1567/// Returns `Ok(None)` for a BLS-only boot setup —
1568/// there's nothing to validate against in that case.
1569fn find_expected_composefs_digest(
1570    repo: &crate::store::ComposefsRepository,
1571    entries: &[ComposefsBootEntry<Sha512HashValue>],
1572) -> Result<Option<ExpectedComposefsDigest>> {
1573    for entry in entries {
1574        let (cmdline, uki_name) = match entry {
1575            ComposefsBootEntry::Aboot(entry) => {
1576                let payload = read_aboot_payload(entry, repo)?;
1577                (
1578                    compute_aboot_boot_digest(entry.encoding, &payload)?.0,
1579                    entry.payload.path.display().to_string(),
1580                )
1581            }
1582            ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => {
1583                let mut reader = match &entry.file {
1584                    RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
1585                        std::fs::File::from(repo.open_object(id)?)
1586                    }
1587                    RegularFile::Inline(..) | RegularFile::Sparse(..) => {
1588                        anyhow::bail!("UKI file is not a regular external object")
1589                    }
1590                };
1591                (
1592                    uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")?,
1593                    uki_file_name(&entry.file_path)?,
1594                )
1595            }
1596            _ => continue,
1597        };
1598        let candidates = parse_uki_composefs_candidates(&cmdline)
1599            .context("Parsing composefs kernel arguments")?;
1600        uki_candidates_policy(&candidates)?;
1601        return Ok(Some(ExpectedComposefsDigest {
1602            candidates,
1603            uki_name,
1604        }));
1605    }
1606    Ok(None)
1607}
1608
1609/// Validates that the freshly-generated boot image digest `computed_id`
1610/// matches what's embedded in the UKI (if any), and if not, searches for an
1611/// [`composefs_oci::XattrFiltering`] mode whose boot image digest does match
1612/// via [`composefs_oci::find_matching_boot_image`], before giving up.
1613///
1614/// This handles images built with older (or newer) composefs-rs tooling
1615/// that computed their embedded UKI digest using a different default xattr
1616/// filtering mode or EROFS format than the one bootc's own build used.
1617#[context("Verifying composefs digest against boot artifact")]
1618pub(crate) fn ensure_correct_composefs_digest(
1619    repo: &Arc<crate::store::ComposefsRepository>,
1620    manifest_digest: &composefs_oci::OciDigest,
1621    computed_id: Sha512HashValue,
1622    expected_ids: ExpectedBootImageIds,
1623    entries: &[ComposefsBootEntry<Sha512HashValue>],
1624) -> Result<RecoveredBootImage> {
1625    let Some(expected) = find_expected_composefs_digest(repo, entries)
1626        .context("Checking boot artifact composefs digest")?
1627    else {
1628        // BLS-only setup; nothing to cross-check.
1629        return Ok(RecoveredBootImage {
1630            id: computed_id,
1631            expected_ids,
1632        });
1633    };
1634    reconcile_uki_candidates(&expected, expected_ids, |digest| {
1635        composefs_oci::find_matching_boot_image(repo, manifest_digest, digest)
1636    })
1637}
1638
1639/// The repository-independent part of [`ensure_correct_composefs_digest`]:
1640/// every UKI candidate must name a boot image of an acceptable format, found
1641/// either among `expected_ids` or via `find_matching`.
1642fn reconcile_uki_candidates(
1643    expected: &ExpectedComposefsDigest,
1644    mut expected_ids: ExpectedBootImageIds,
1645    mut find_matching: impl FnMut(
1646        &Sha512HashValue,
1647    ) -> Result<composefs_oci::BootImageMatch<Sha512HashValue>>,
1648) -> Result<RecoveredBootImage> {
1649    for candidate in &expected.candidates {
1650        if expected_ids.contains(candidate) {
1651            continue;
1652        }
1653        // This is expected when e.g. the repository only generated one of the
1654        // formats in a dual-format UKI, so it isn't worth alarming anyone.
1655        tracing::debug!(
1656            "UKI {} names {} boot image {:?}, which is not among the images generated \
1657             for this pull; searching other xattr filtering modes and EROFS format versions",
1658            expected.uki_name,
1659            candidate.format,
1660            candidate.digest,
1661        );
1662        let mismatch = UKIDigestMismatch::unsupported_compatibility(
1663            candidate.digest.clone(),
1664            expected.uki_name.clone(),
1665            0,
1666        );
1667        let (version, digest) =
1668            resolve_boot_image_match(mismatch, candidate, find_matching(&candidate.digest))?;
1669        expected_ids.add(version, digest);
1670    }
1671    let selected = primary_uki_candidate(&expected.candidates);
1672    anyhow::ensure!(
1673        expected_ids.contains(selected),
1674        "Recovered boot image does not match the primary UKI composefs candidate"
1675    );
1676    Ok(RecoveredBootImage {
1677        id: selected.digest.clone(),
1678        expected_ids,
1679    })
1680}
1681
1682pub(crate) struct RecoveredBootImage {
1683    pub(crate) id: Sha512HashValue,
1684    pub(crate) expected_ids: ExpectedBootImageIds,
1685}
1686
1687/// Interprets the result of searching for a boot image whose digest matches
1688/// `expected` (see [`composefs_oci::find_matching_boot_image`]): uses the
1689/// matching mode's digest if one was found, or fails with an error listing
1690/// every combination tried if not.
1691///
1692/// Factored out from [`ensure_correct_composefs_digest`] purely so this
1693/// decision logic can be unit tested without a real repo or UKI fixture.
1694fn resolve_boot_image_match(
1695    mismatch: UKIDigestMismatch,
1696    candidate: &UkiComposefsCandidate,
1697    find_matching_result: Result<composefs_oci::BootImageMatch<Sha512HashValue>>,
1698) -> Result<(FormatVersion, Sha512HashValue)> {
1699    match find_matching_result.context(
1700        "Searching for a boot image xattr filtering mode/format version matching the UKI digest",
1701    )? {
1702        composefs_oci::BootImageMatch::Found {
1703            mode,
1704            version,
1705            digest,
1706        } => {
1707            tracing::info!(
1708                "Boot image built with {mode:?} xattr filtering (EROFS {version:?}) matches \
1709                 the UKI; using it"
1710            );
1711            anyhow::ensure!(
1712                candidate.format.accepts(version),
1713                "UKI composefs candidate format ({}) does not match recovered EROFS format \
1714                 ({version:?}) for {:?}",
1715                candidate.format,
1716                candidate.digest,
1717            );
1718            Ok((version, digest))
1719        }
1720        composefs_oci::BootImageMatch::NotFound(tried) => match mismatch {
1721            UKIDigestMismatch::UnsupportedCompatibility {
1722                actual, uki_name, ..
1723            } => Err(UKIDigestMismatch::unsupported_compatibility(actual, uki_name, tried).into()),
1724            _ => unreachable!("compatibility search always creates an unsupported mismatch"),
1725        },
1726    }
1727}
1728
1729#[context("Writing Grub menuentry")]
1730fn write_grub_uki_menuentry(
1731    setup_type: &BootSetupType,
1732    boot_label: String,
1733    id: &Sha512HashValue,
1734    esp_device: &str,
1735) -> Result<()> {
1736    let boot_dir = setup_type.grub_boot_path()?;
1737    create_dir_all(&boot_dir).context("Failed to create boot dir")?;
1738
1739    let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
1740
1741    let efi_uuid_source = get_efi_uuid_source();
1742
1743    let user_cfg_name = if is_upgrade {
1744        USER_CFG_STAGED
1745    } else {
1746        USER_CFG
1747    };
1748
1749    let grub_dir = Dir::open_ambient_dir(boot_dir.join("grub2"), ambient_authority())
1750        .context("opening boot/grub2")?;
1751
1752    // Iterate over all available deployments, and generate a menuentry for each
1753    if is_upgrade {
1754        let mut str_buf = String::new();
1755        let boot_dir =
1756            Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?;
1757        let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?;
1758
1759        grub_dir
1760            .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
1761                f.write_all(efi_uuid_source.as_bytes())?;
1762                f.write_all(
1763                    MenuEntry::new(&boot_label, &id.to_hex())
1764                        .to_string()
1765                        .as_bytes(),
1766                )?;
1767
1768                // Write out only the currently booted entry, which should be the very first one
1769                // Even if we have booted into the second menuentry "boot entry", the default will be the
1770                // first one
1771                f.write_all(entries[0].to_string().as_bytes())?;
1772
1773                Ok(())
1774            })
1775            .with_context(|| format!("Writing to {user_cfg_name}"))?;
1776
1777        rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
1778
1779        return Ok(());
1780    }
1781
1782    // Open grub2/efiuuid.cfg and write the EFI partition fs-UUID in there
1783    // This will be sourced by grub2/user.cfg to be used for `--fs-uuid`
1784    let esp_uuid = Task::new("blkid for ESP UUID", "blkid")
1785        .args(["-s", "UUID", "-o", "value", esp_device])
1786        .read()?;
1787
1788    grub_dir.atomic_write(
1789        EFI_UUID_FILE,
1790        format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(),
1791    )?;
1792
1793    // Write to grub2/user.cfg
1794    grub_dir
1795        .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
1796            f.write_all(efi_uuid_source.as_bytes())?;
1797            f.write_all(
1798                MenuEntry::new(&boot_label, &id.to_hex())
1799                    .to_string()
1800                    .as_bytes(),
1801            )?;
1802
1803            Ok(())
1804        })
1805        .with_context(|| format!("Writing to {user_cfg_name}"))?;
1806
1807    rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
1808
1809    Ok(())
1810}
1811
1812/// Writes a deployment's Type 1 boot entry. A staged deployment replaces any
1813/// earlier staged one, whose entry may have another version in its file name,
1814/// and keeps the booted deployment's entry next to it.
1815fn write_type1_entries(
1816    boot_dir: &Dir,
1817    setup_type: &BootSetupType,
1818    os_id: &str,
1819    entry: &BLSConfig,
1820) -> Result<()> {
1821    let (entries_path, booted_bls) = match setup_type {
1822        BootSetupType::Setup(..) => (TYPE1_ENT_PATH, None),
1823
1824        BootSetupType::Upgrade((_, booted_cfs, ..)) => {
1825            boot_dir
1826                .remove_all_optional(TYPE1_ENT_PATH_STAGED)
1827                .with_context(|| format!("Removing {TYPE1_ENT_PATH_STAGED}"))?;
1828
1829            let mut booted_bls = get_booted_bls(boot_dir, booted_cfs)?;
1830            booted_bls.sort_key = Some(secondary_sort_key(os_id));
1831
1832            (TYPE1_ENT_PATH_STAGED, Some(booted_bls))
1833        }
1834    };
1835
1836    boot_dir
1837        .create_dir_all(entries_path)
1838        .with_context(|| format!("Creating {entries_path}"))?;
1839    let entries_dir = boot_dir.open_dir(entries_path)?;
1840
1841    entries_dir
1842        .atomic_write(
1843            type1_entry_conf_file_name(os_id, &entry.version(), FILENAME_PRIORITY_PRIMARY),
1844            entry.to_string().as_bytes(),
1845        )
1846        .context("Writing conf file")?;
1847
1848    if let Some(booted_bls) = booted_bls {
1849        entries_dir.atomic_write(
1850            type1_entry_conf_file_name(os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
1851            booted_bls.to_string().as_bytes(),
1852        )?;
1853    }
1854
1855    rustix::fs::fsync(entries_dir.reopen_as_ownedfd()?).context("fsync")?;
1856
1857    Ok(())
1858}
1859
1860#[context("Writing systemd UKI config")]
1861fn write_systemd_uki_config(
1862    esp_dir: &Dir,
1863    setup_type: &BootSetupType,
1864    boot_label: String,
1865    version: Option<String>,
1866    os_id: Option<String>,
1867    id: &Sha512HashValue,
1868    bootloader: &Bootloader,
1869) -> Result<()> {
1870    let os_id = os_id.as_deref().unwrap_or("bootc");
1871
1872    let mut bls_conf = BLSConfig::default();
1873    bls_conf
1874        .with_title(boot_label)
1875        .with_cfg(BLSConfigType::EFI {
1876            key: EFIKey::for_bootloader(
1877                format!("/{BOOTC_UKI_DIR}/{}", get_uki_name(&id.to_hex())).into(),
1878                bootloader,
1879            ),
1880        })
1881        .with_sort_key(primary_sort_key(os_id))
1882        .with_version(version.unwrap_or_else(|| id.to_hex()));
1883
1884    write_type1_entries(esp_dir, setup_type, os_id, &bls_conf)
1885}
1886
1887#[context("Setting up UKI boot")]
1888pub(crate) fn setup_composefs_uki_boot(
1889    setup_type: BootSetupType,
1890    repo: &crate::store::ComposefsRepository,
1891    id: &Sha512HashValue,
1892    boot_ids: &ExpectedBootImageIds,
1893    entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
1894) -> Result<(String, Sha512HashValue)> {
1895    let (
1896        boot_partition,
1897        bootloader,
1898        missing_fsverity_allowed,
1899        uki_addons_via_cli,
1900        installed_uki_addons,
1901    ) = match setup_type {
1902        BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => {
1903            state.require_no_kargs_for_immutable_boot()?;
1904
1905            // Locate ESP partition device by walking up to the root disk(s)
1906            //
1907            // NOTE: Not using [`find_booted_from_esp`] as we aren't booted
1908            // into a bootc system
1909            let esp_part = root_setup.device_info.find_first_colocated_esp()?;
1910
1911            let mut addons: Vec<UkiAddonsList> = vec![];
1912
1913            let addon_opts = &state.composefs_options.uki_addon_opts;
1914
1915            for addon in addon_opts.scoped.iter().flatten() {
1916                addons.push(UkiAddonsList {
1917                    name: addon.into(),
1918                    addon_type: UkiAddonType::Scoped {
1919                        depl_id: id.to_hex(),
1920                    },
1921                    staged: false,
1922                });
1923            }
1924
1925            for addon in addon_opts.global.iter().flatten() {
1926                addons.push(UkiAddonsList {
1927                    name: addon.into(),
1928                    addon_type: UkiAddonType::Global,
1929                    staged: false,
1930                });
1931            }
1932
1933            (
1934                BootPartition::for_install(esp_part.path().into(), postfetch.detected_bootloader)?,
1935                postfetch.detected_bootloader.clone(),
1936                allow_missing_fsverity,
1937                addons,
1938                vec![],
1939            )
1940        }
1941
1942        BootSetupType::Upgrade((storage, booted_cfs, host, uki_addon_opts)) => {
1943            let bootloader = host.require_composefs_booted()?.bootloader.clone();
1944
1945            // These are the currently installed addons which we will update automatically
1946            // if we find in the new image
1947            //
1948            // Only keep addons referenced by the current deployment
1949            let installed_addons = list_installed_uki_addons(storage, &host.status.staged)?
1950                .into_iter()
1951                .filter(|addon| {
1952                    !addon.staged
1953                        && match &addon.addon_type {
1954                            UkiAddonType::Scoped { depl_id } => {
1955                                *depl_id == *booted_cfs.cmdline.digest
1956                            }
1957                            UkiAddonType::Global => true,
1958                        }
1959                })
1960                .collect::<Vec<_>>();
1961
1962            let target_depl_id = id.to_hex();
1963
1964            let mut addons_via_cli: Vec<UkiAddonsList> = vec![];
1965
1966            if let Some(addons) = &uki_addon_opts {
1967                for addon in addons.global.iter().flatten() {
1968                    addons_via_cli.push(UkiAddonsList {
1969                        name: addon.into(),
1970                        addon_type: UkiAddonType::Global,
1971                        staged: false,
1972                    });
1973                }
1974
1975                for addon in addons.scoped.iter().flatten() {
1976                    addons_via_cli.push(UkiAddonsList {
1977                        name: addon.into(),
1978                        addon_type: UkiAddonType::Scoped {
1979                            depl_id: target_depl_id.clone(),
1980                        },
1981                        staged: false,
1982                    });
1983                }
1984            }
1985
1986            (
1987                storage.require_boot_partition()?.clone(),
1988                bootloader,
1989                booted_cfs.cmdline.allow_missing_fsverity,
1990                addons_via_cli,
1991                installed_addons,
1992            )
1993        }
1994    };
1995
1996    // An addon requested explicitly on the CLI must exist in the image; bail
1997    // immediately if one is missing, before we touch the ESP. Addons carried
1998    // forward on upgrade/switch may be absent from the new image, which is fine.
1999    for addon in &uki_addons_via_cli {
2000        let is_global = matches!(addon.addon_type, UkiAddonType::Global);
2001
2002        let found = entries.iter().any(|entry| {
2003            let ComposefsBootEntry::Type2(entry) = entry else {
2004                return false;
2005            };
2006
2007            if matches!(entry.pe_type, PEType::Uki) {
2008                return false;
2009            }
2010
2011            let entry_is_global = matches!(entry.pe_type, PEType::GlobalUkiAddon);
2012
2013            entry_is_global == is_global
2014                && entry
2015                    .file_path
2016                    .file_name()
2017                    .and_then(|n| n.to_str())
2018                    .and_then(|n| n.strip_suffix(EFI_ADDON_FILE_EXT))
2019                    == Some(addon.name.as_str())
2020        });
2021
2022        if !found {
2023            let kind = if is_global { "global" } else { "scoped" };
2024            anyhow::bail!(
2025                "Requested {kind} UKI addon '{}' was not found in the image",
2026                addon.name
2027            );
2028        }
2029    }
2030
2031    let boot_mount =
2032        mount_esp_writable(boot_partition.device().as_str()).context("Mounting boot partition")?;
2033
2034    let mut uki_info = UKIInfo {
2035        boot_label: "".into(),
2036        version: None,
2037        os_id: None,
2038        boot_digest: "".into(),
2039        composefs_digest: None,
2040    };
2041
2042    let mut entries_to_write: Vec<Type2Entry<Sha512HashValue>> = vec![];
2043
2044    for entry in entries {
2045        match entry {
2046            ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"),
2047            ComposefsBootEntry::UsrLibModulesVmLinuz(..) => {
2048                tracing::debug!("Skipping vmlinuz in /usr/lib/modules")
2049            }
2050            ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"),
2051
2052            ComposefsBootEntry::Type2(entry) => {
2053                // If --uki-addon is not passed, we don't install any addon (whether
2054                // it's scoped to this UKI or a global one)
2055                if matches!(entry.pe_type, PEType::UkiAddon | PEType::GlobalUkiAddon) {
2056                    let addon_name = entry
2057                        .file_path
2058                        .components()
2059                        .last()
2060                        .ok_or_else(|| anyhow::anyhow!("Could not get UKI addon name"))?;
2061
2062                    let addon_name = addon_name.as_str()?;
2063
2064                    let addon_name =
2065                        addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| {
2066                            anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_FILE_EXT}")
2067                        })?;
2068
2069                    match entry.pe_type {
2070                        PEType::Uki => unreachable!("Outer match should've only caught UKI Addons"),
2071                        PEType::UkiAddon => {
2072                            let found = uki_addons_via_cli.iter().chain(&installed_uki_addons).any(
2073                                |addon| {
2074                                    matches!(addon.addon_type, UkiAddonType::Scoped { .. })
2075                                        && addon.name == addon_name
2076                                },
2077                            );
2078
2079                            if !found {
2080                                tracing::info!("Not installing found UKI Addon: {addon_name}");
2081                                continue;
2082                            }
2083                        }
2084                        PEType::GlobalUkiAddon => {
2085                            let found = uki_addons_via_cli.iter().chain(&installed_uki_addons).any(
2086                                |addon| {
2087                                    matches!(addon.addon_type, UkiAddonType::Global)
2088                                        && addon.name == addon_name
2089                                },
2090                            );
2091
2092                            if !found {
2093                                tracing::info!(
2094                                    "Not installing found global UKI Addon: {addon_name}"
2095                                );
2096                                continue;
2097                            }
2098                        }
2099                    }
2100                }
2101
2102                let utf8_file_path = Utf8Path::from_path(&entry.file_path)
2103                    .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
2104
2105                let mut uki_reader = file_from_type2_entry(repo, &entry)?;
2106
2107                parse_uki_cmdline(
2108                    &entry.pe_type,
2109                    missing_fsverity_allowed,
2110                    boot_ids,
2111                    &mut uki_reader,
2112                    utf8_file_path
2113                        .file_name()
2114                        .context("Filename not found for PE binary")?,
2115                    &mut uki_info,
2116                )?;
2117
2118                entries_to_write.push(entry);
2119            }
2120        };
2121    }
2122
2123    let Some(deploy_id) = uki_info.composefs_digest else {
2124        anyhow::bail!("No composefs cmdline found in UKI or UKI Addons");
2125    };
2126
2127    if uki_info.boot_label.is_empty() {
2128        anyhow::bail!("Failed to get boot label from UKI");
2129    }
2130
2131    let UKIInfo {
2132        boot_label,
2133        version,
2134        os_id,
2135        boot_digest,
2136        ..
2137    } = uki_info;
2138
2139    let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
2140
2141    // Create a `ESP/loader/addons.staged` if we have Global UKI Addons to update
2142    if is_upgrade {
2143        stage_global_uki_addons(&boot_mount.fd)?;
2144    }
2145
2146    for entry in entries_to_write {
2147        write_pe_to_esp(repo, &entry, &deploy_id, boot_mount.dir.path(), is_upgrade)?;
2148    }
2149
2150    match bootloader.kind()? {
2151        BootloaderKind::GRUBClassic => write_grub_uki_menuentry(
2152            &setup_type,
2153            boot_label,
2154            &deploy_id,
2155            boot_partition.device().as_str(),
2156        )?,
2157
2158        BootloaderKind::BLSCompatible => write_systemd_uki_config(
2159            &boot_mount.fd,
2160            &setup_type,
2161            boot_label,
2162            version,
2163            os_id,
2164            &deploy_id,
2165            &bootloader,
2166        )?,
2167    };
2168
2169    Ok((boot_digest, deploy_id))
2170}
2171
2172/// A composefs image attached to a temporary directory with the ESP and a
2173/// tmpfs mounted inside it, ready for bootloader installation.
2174///
2175/// The composefs image (a detached `fsmount(2)` fd with no VFS path) is
2176/// attached to a tmpdir via `move_mount(2)`, giving us a real filesystem path
2177/// that `mount(2)` and bootctl can use.  The ESP is mounted at
2178/// `<tmpdir>/boot`.  A tmpfs is also mounted at
2179/// `<tmpdir>/tmp` to provide a writable scratch area for tools invoked with
2180/// `--root`.
2181///
2182/// Drop order matters: the tmpfs guard is declared before `composefs` so it
2183/// is unmounted (and flushed) before the composefs root is detached.
2184pub(crate) struct MountedImageRoot {
2185    // The ESP's device path. The ESP is intentionally *not* mounted here for
2186    // our whole lifetime; it's mounted on demand via `with_esp()` instead.
2187    // That's because `install_via_bootupd` runs bootupd's install tooling
2188    // inside a `ChrootCmd`, which unshares the mount namespace and
2189    // recursively self-binds the chroot directory (our `root_path()`) onto
2190    // itself. If the ESP were already mounted at `<root_path>/boot` when
2191    // that happens, the self-bind would duplicate it into an
2192    // orphaned/shadowed mountinfo entry that's still visible to naive
2193    // `findmnt`-based scans (like bootupd's), causing it to pick the wrong
2194    // filesystem UUID.
2195    esp_device: Utf8PathBuf,
2196    // Tracks whether the ESP is currently mounted, i.e. whether we're
2197    // inside a call to `with_esp()`. Guards `open_esp_dir()` against
2198    // returning the wrong thing: without this, calling it while the ESP
2199    // isn't mounted would silently open the empty `esp_subdir` directory
2200    // that already exists directly in the composefs image, rather than
2201    // failing.
2202    esp_mounted: Cell<bool>,
2203    // Unmounted before `composefs` on drop.
2204    _tmp: bootc_mount::tempmount::MountGuard,
2205    composefs: TempMount,
2206    pub(crate) esp_subdir: &'static str,
2207}
2208
2209impl MountedImageRoot {
2210    /// Find the ESP on `device`, attach the composefs image to a tmpdir, and
2211    /// mount the ESP and a scratch tmpfs inside it.
2212    // TODO: install to all ESPs on multi-device setups
2213    #[context("Preparing image root for bootloader installation")]
2214    pub(crate) fn new(
2215        composefs_mnt_fd: std::os::fd::OwnedFd,
2216        device: &bootc_blockdev::Device,
2217    ) -> Result<Self> {
2218        let roots = device.find_all_roots()?;
2219        let mut esp_part = None;
2220        for root in &roots {
2221            if let Some(esp) = root.find_partition_of_esp_optional()? {
2222                esp_part = Some(esp);
2223                break;
2224            }
2225        }
2226        let esp_part = esp_part.ok_or_else(|| anyhow!("ESP partition not found"))?;
2227
2228        // Attach the detached composefs fsmount fd to a real tmpdir path so
2229        // that mount(2) and bootctl --root can work with it.
2230        let composefs = TempMount::mount_fd(composefs_mnt_fd)
2231            .context("Attaching composefs image to temporary directory")?;
2232
2233        // The ESP goes on /boot even when there's an XBOOTLDR partition,
2234        // where BLS would mount it on /efi: that would need an /efi
2235        // directory in the image, and only the bootloader is installed
2236        // through this mount, with the ESP's path passed explicitly. bootc
2237        // writes the boot entries itself (see `BootPartition`).
2238        let esp_subdir = "boot";
2239
2240        // Mount a tmpfs over /tmp so that tools invoked with --root have a
2241        // writable scratch area without touching the read-only EROFS root.
2242        let tmp_path = composefs.dir.path().join("tmp");
2243        let tmp = bootc_mount::tempmount::MountGuard::mount(
2244            "tmpfs",
2245            tmp_path,
2246            "tmpfs",
2247            MountFlags::NOEXEC | MountFlags::NOSUID | MountFlags::NODEV,
2248            None::<&std::ffi::CStr>,
2249        )
2250        .context("Mounting tmpfs into composefs root")?;
2251
2252        Ok(Self {
2253            esp_device: esp_part.path().into(),
2254            esp_mounted: Cell::new(false),
2255            _tmp: tmp,
2256            composefs,
2257            esp_subdir,
2258        })
2259    }
2260
2261    /// The composefs image as a capability-safe directory (for file reads).
2262    pub(crate) fn dir(&self) -> &Dir {
2263        &self.composefs.fd
2264    }
2265
2266    /// Real filesystem path of the composefs tmpdir root.
2267    pub(crate) fn root_path(&self) -> &std::path::Path {
2268        self.composefs.dir.path()
2269    }
2270
2271    /// Open the mounted ESP as a capability-safe directory.
2272    ///
2273    /// Fails unless called from within a call to [`Self::with_esp`]: the ESP
2274    /// is only actually mounted for the duration of that call, and
2275    /// `esp_subdir` otherwise refers to an ordinary (empty) directory that
2276    /// already exists directly in the composefs image.
2277    pub(crate) fn open_esp_dir(&self) -> Result<Dir> {
2278        if !self.esp_mounted.get() {
2279            bail!("BUG: attempted to open the ESP directory while it is not mounted");
2280        }
2281        self.composefs
2282            .fd
2283            .open_dir(self.esp_subdir)
2284            .with_context(|| format!("Opening ESP at /{}", self.esp_subdir))
2285    }
2286
2287    /// Mount the ESP at `<tmpdir>/<esp_subdir>` for the duration of `f`, then
2288    /// unmount it again. Nothing is mounted there outside of calls to this
2289    /// method, so callers that need to invoke bootloader-install tooling
2290    /// that itself creates a private mount namespace (e.g. via `ChrootCmd`)
2291    /// can safely do so without risking this mount being shadowed/duplicated
2292    /// by that tooling's own mount-namespace setup.
2293    pub(crate) fn with_esp<T>(&self, f: impl FnOnce(&Dir) -> Result<T>) -> Result<T> {
2294        let esp_path = self.root_path().join(self.esp_subdir);
2295        let _guard = mount_esp_at(self.esp_device.as_str(), esp_path)
2296            .context("Mounting ESP into composefs root")?;
2297
2298        self.esp_mounted.set(true);
2299        // Reset `esp_mounted` when we return, including via `?` or panic,
2300        // so a later `with_esp()` call (or a stray `open_esp_dir()` call
2301        // after this one returns) doesn't see a stale "mounted" state.
2302        struct ResetOnDrop<'a>(&'a Cell<bool>);
2303        impl Drop for ResetOnDrop<'_> {
2304            fn drop(&mut self) {
2305                self.0.set(false);
2306            }
2307        }
2308        let _reset = ResetOnDrop(&self.esp_mounted);
2309
2310        let dir = self.open_esp_dir()?;
2311        f(&dir)
2312    }
2313}
2314
2315pub struct SecurebootKeys {
2316    pub dir: Dir,
2317    pub keys: Vec<Utf8PathBuf>,
2318}
2319
2320fn get_secureboot_keys(fs: &Dir, p: &str) -> Result<Option<SecurebootKeys>> {
2321    let mut entries = vec![];
2322
2323    // if the dir doesn't exist, return None
2324    let keys_dir = match fs.open_dir_optional(p)? {
2325        Some(d) => d,
2326        _ => return Ok(None),
2327    };
2328
2329    // https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392
2330
2331    for entry in keys_dir.entries()? {
2332        let dir_e = entry?;
2333        let dirname = dir_e.file_name();
2334        if !dir_e.file_type()?.is_dir() {
2335            bail!("/{p}/{dirname:?} is not a directory");
2336        }
2337
2338        let dir_path: Utf8PathBuf = dirname.try_into()?;
2339        let dir = dir_e.open_dir()?;
2340        for entry in dir.entries()? {
2341            let e = entry?;
2342            let local: Utf8PathBuf = e.file_name().try_into()?;
2343            let path = dir_path.join(local);
2344
2345            if path.extension() != Some(AUTH_EXT) {
2346                continue;
2347            }
2348
2349            if !e.file_type()?.is_file() {
2350                bail!("/{p}/{path:?} is not a file");
2351            }
2352            entries.push(path);
2353        }
2354    }
2355    return Ok(Some(SecurebootKeys {
2356        dir: keys_dir,
2357        keys: entries,
2358    }));
2359}
2360
2361fn read_aboot_payload(
2362    entry: &AbootEntry<Sha512HashValue>,
2363    repo: &ComposefsRepository,
2364) -> Result<Box<[u8]>> {
2365    read_file(&entry.payload.file, repo).context("Reading aboot payload")
2366}
2367
2368fn compute_aboot_boot_digest(encoding: AbootEncoding, payload: &[u8]) -> Result<(String, String)> {
2369    let mut image = Cursor::new(payload);
2370    match encoding {
2371        AbootEncoding::Uki => {
2372            let cmdline = uki::get_cmdline(payload)
2373                .context("Getting UKI command line")?
2374                .to_string();
2375            let digest = compute_boot_digest_uki(&mut image)?;
2376            Ok((cmdline, digest))
2377        }
2378        AbootEncoding::AndroidV2 => {
2379            let metadata = AndroidBootImage::parse(&mut image)?;
2380            let cmdline = metadata.cmdline()?.to_string();
2381            let digest = compute_boot_digest_aboot(&mut image, &metadata)?;
2382            Ok((cmdline, digest))
2383        }
2384    }
2385}
2386
2387fn install_ukiboot(
2388    root: &Dir,
2389    source: &FileSystem<Sha512HashValue>,
2390    repo: &ComposefsRepository,
2391    esp: &Dir,
2392) -> Result<()> {
2393    let (efi_arch, fallback_name) = match std::env::consts::ARCH {
2394        "x86_64" => ("x64", "BOOTX64.EFI"),
2395        "aarch64" => ("aa64", "BOOTAA64.EFI"),
2396        arch => bail!("ukiboot is not supported on {arch}"),
2397    };
2398    let get_file = |path: &str| {
2399        source
2400            .as_dir()
2401            .split_ref(path.as_ref())
2402            .and_then(|(dir, name)| dir.get_file(name))
2403    };
2404
2405    let loader_name = format!("ukiboot{efi_arch}.efi");
2406    let files = [
2407        (
2408            loader_name.clone(),
2409            Some(fallback_name.to_string()),
2410            loader_name.clone(),
2411        ),
2412        (
2413            "ukiboot_a.efi.extra.d/slot_a.addon.efi".into(),
2414            None,
2415            "slot_a.addon.efi".into(),
2416        ),
2417        (
2418            "ukiboot_b.efi.extra.d/slot_b.addon.efi".into(),
2419            None,
2420            "slot_b.addon.efi".into(),
2421        ),
2422    ];
2423    let packaged_root_path = "usr/libexec/ukiboot/efi";
2424    let packaged_root = root
2425        .open_dir_optional(packaged_root_path)
2426        .context("Opening packaged ukiboot EFI directory")?;
2427    let mut vendor = "BOOT".to_string();
2428    let mut packaged_path = Path::new(packaged_root_path).to_path_buf();
2429    if let Some(efidir) = &packaged_root {
2430        // Find expected vendor name from packaged_root subdir
2431        for entry in efidir.entries_utf8()? {
2432            let entry = entry?;
2433            let vendor_name = entry.file_name()?;
2434            let vendor_path = Path::new(packaged_root_path).join(&vendor_name);
2435            if !root.is_file(vendor_path.join(&loader_name)) {
2436                continue;
2437            }
2438            vendor = vendor_name;
2439            packaged_path = vendor_path;
2440            break;
2441        }
2442    }
2443
2444    // Prefer a complete set under /boot, where binaries can be signed independently.
2445    let boot_dir = format!("boot/efi/EFI/{vendor}");
2446    let use_boot_files = files.iter().all(|(ukiboot_part_path, _, _)| {
2447        // Leave errors other than missing files for the read below.
2448        !matches!(
2449            get_file(&format!("{boot_dir}/{ukiboot_part_path}")),
2450            Err(ImageError::NotFound(..))
2451        )
2452    });
2453
2454    for (vendor_path, fallback_path, packaged_name) in files {
2455        let data = if use_boot_files {
2456            let source_path = format!("{boot_dir}/{vendor_path}");
2457            let file = get_file(&source_path)
2458                .with_context(|| format!("Finding ukiboot EFI file {source_path}"))?;
2459            read_file(file, repo).context("Reading ukiboot EFI file")?
2460        } else {
2461            root.read(packaged_path.join(&packaged_name))
2462                .with_context(|| format!("Reading packaged ukiboot EFI file {packaged_name}"))?
2463                .into_boxed_slice()
2464        };
2465        for target in std::iter::once(format!("EFI/{vendor}/{vendor_path}"))
2466            .chain(fallback_path.map(|path| format!("EFI/BOOT/{path}")))
2467        {
2468            let target = Utf8Path::new(&target);
2469            if let Some(parent) = target.parent() {
2470                esp.create_dir_all(parent)?;
2471            }
2472            esp.atomic_write(target, &data)
2473                .with_context(|| format!("Writing ukiboot EFI file to ESP at {target}"))?;
2474        }
2475    }
2476    Ok(())
2477}
2478
2479pub(crate) fn validate_bootloader(
2480    entry: &ComposefsBootEntry<Sha512HashValue>,
2481    bootloader: Bootloader,
2482) -> Result<()> {
2483    if let ComposefsBootEntry::Aboot(entry) = entry {
2484        match (entry.encoding, bootloader) {
2485            (AbootEncoding::AndroidV2, Bootloader::None) => {}
2486            (AbootEncoding::Uki, Bootloader::Ukiboot) => {}
2487            (encoding, bootloader) => {
2488                bail!("aboot encoding {encoding:?} cannot be used with bootloader {bootloader}")
2489            }
2490        }
2491    } else {
2492        ensure!(
2493            bootloader != Bootloader::None,
2494            "Bootloader set to none is not supported with the composefs backend unless using an aboot artifact"
2495        );
2496        ensure!(
2497            bootloader != Bootloader::Ukiboot,
2498            "Bootloader ukiboot requires an aboot artifact with the composefs backend"
2499        );
2500    }
2501    Ok(())
2502}
2503
2504/// Pick the first real bootable entry, skipping UKI addons.
2505///
2506/// Addons can precede primary artifacts in discovery order, including aboot
2507/// payloads. They must not determine the boot type or installation path.
2508pub(crate) fn primary_boot_entry(
2509    entries: &[ComposefsBootEntry<Sha512HashValue>],
2510) -> Result<&ComposefsBootEntry<Sha512HashValue>> {
2511    entries.iter().find(|entry| !matches!(entry, ComposefsBootEntry::Type2(e) if !matches!(e.pe_type, PEType::Uki)))
2512        .ok_or_else(|| anyhow!("No primary boot artifact"))
2513}
2514
2515fn image_bootloader(entry: &ComposefsBootEntry<Sha512HashValue>) -> Option<Bootloader> {
2516    match entry {
2517        ComposefsBootEntry::Aboot(entry) => Some(match entry.encoding {
2518            AbootEncoding::AndroidV2 => Bootloader::None,
2519            AbootEncoding::Uki => Bootloader::Ukiboot,
2520        }),
2521        _ => None,
2522    }
2523}
2524
2525fn validate_composefs_aboot_payload(
2526    id: &Sha512HashValue,
2527    boot_ids: &ExpectedBootImageIds,
2528    encoding: AbootEncoding,
2529    payload: &[u8],
2530    allow_missing_fsverity: bool,
2531) -> Result<String> {
2532    let (cmdline, boot_digest) = compute_aboot_boot_digest(encoding, payload)?;
2533    validate_aboot_composefs_candidates(id, boot_ids, &cmdline, allow_missing_fsverity)?;
2534    Ok(boot_digest)
2535}
2536
2537fn validate_aboot_composefs_candidates(
2538    id: &Sha512HashValue,
2539    boot_ids: &ExpectedBootImageIds,
2540    cmdline: &str,
2541    allow_missing_fsverity: bool,
2542) -> Result<()> {
2543    let candidates = parse_uki_composefs_candidates(cmdline)
2544        .context("Parsing composefs kernel arguments in aboot image")?;
2545    let insecure = uki_candidates_policy(&candidates)?;
2546    validate_uki_candidates(&candidates, boot_ids, None)?;
2547    ensure!(
2548        primary_uki_candidate(&candidates).digest == *id,
2549        "aboot primary composefs digest does not match the selected boot image"
2550    );
2551    ensure!(
2552        !insecure || allow_missing_fsverity,
2553        "The aboot image requests insecure composefs operation, but this repository requires fs-verity"
2554    );
2555    match (allow_missing_fsverity, insecure) {
2556        (true, false) => tracing::warn!(
2557            "--allow-missing-fsverity was requested but the aboot image requires fs-verity"
2558        ),
2559        _ => {}
2560    }
2561
2562    Ok(())
2563}
2564
2565#[cfg(feature = "install-to-disk")]
2566fn aboot_partition_path(
2567    disk: &bootc_blockdev::Device,
2568    label: &str,
2569    payload_size: usize,
2570) -> Result<String> {
2571    let mut partitions = disk
2572        .children
2573        .iter()
2574        .flatten()
2575        .filter(|part| part.partlabel.as_deref() == Some(label));
2576    let partition = partitions
2577        .next()
2578        .ok_or_else(|| anyhow::anyhow!("Missing {label} partition"))?;
2579    ensure!(
2580        partitions.next().is_none(),
2581        "Multiple {label} partitions found"
2582    );
2583    ensure!(
2584        payload_size as u64 <= partition.size,
2585        "{label} image is larger than its partition"
2586    );
2587    Ok(partition.path())
2588}
2589
2590#[cfg(feature = "install-to-disk")]
2591fn write_aboot_partition(path: &str, label: &str, payload: &[u8]) -> Result<()> {
2592    let mut target = std::fs::OpenOptions::new()
2593        .write(true)
2594        .open(&path)
2595        .with_context(|| format!("Opening {label} partition at {path}"))?;
2596    target
2597        .write_all(payload)
2598        .with_context(|| format!("Writing {label} partition at {path}"))?;
2599    target
2600        .sync_all()
2601        .with_context(|| format!("Syncing {path}"))?;
2602    Ok(())
2603}
2604
2605#[cfg(feature = "install-to-disk")]
2606fn aboot_disk_partition_payloads(
2607    layout: AbootDiskLayout,
2608    artifacts: &PreparedAbootArtifacts,
2609) -> Result<Vec<(&'static str, &[u8])>> {
2610    let partitions = match layout {
2611        AbootDiskLayout::Android => vec![
2612            ("boot_b", artifacts.payload.as_ref()),
2613            ("vbmeta_b", artifacts.vbmeta.as_deref().unwrap_or(&[0])),
2614            ("boot_a", artifacts.payload.as_ref()),
2615            ("vbmeta_a", artifacts.vbmeta.as_deref().unwrap_or(&[0])),
2616        ],
2617        AbootDiskLayout::Ukiboot => {
2618            ensure!(
2619                artifacts.vbmeta.is_none(),
2620                "ukiboot layout has no vbmeta partition"
2621            );
2622            vec![
2623                ("ukiboot_b", artifacts.payload.as_ref()),
2624                ("ukiboot_a", artifacts.payload.as_ref()),
2625            ]
2626        }
2627    };
2628    Ok(partitions)
2629}
2630
2631#[cfg(feature = "install-to-disk")]
2632fn install_aboot_disk_payload(
2633    root_setup: &RootSetup,
2634    layout: AbootDiskLayout,
2635    artifacts: &PreparedAbootArtifacts,
2636) -> Result<()> {
2637    let disk = &root_setup.device_info;
2638    let partitions = aboot_disk_partition_payloads(layout, artifacts)?;
2639    let targets = partitions
2640        .iter()
2641        .map(|(label, payload)| aboot_partition_path(disk, label, payload.len()))
2642        .collect::<Result<Vec<_>>>()?;
2643    for ((label, payload), path) in partitions.into_iter().zip(targets) {
2644        write_aboot_partition(&path, label, payload)?;
2645    }
2646    Ok(())
2647}
2648
2649pub(crate) struct PreparedAbootArtifacts {
2650    pub(crate) boot_digest: String,
2651    pub(crate) payload: Box<[u8]>,
2652    pub(crate) vbmeta: Option<Box<[u8]>>,
2653}
2654
2655pub(crate) fn prepare_composefs_aboot_artifacts(
2656    repo: &ComposefsRepository,
2657    id: &Sha512HashValue,
2658    boot_ids: &ExpectedBootImageIds,
2659    entry: &ComposefsBootEntry<Sha512HashValue>,
2660    allow_missing_fsverity: bool,
2661) -> Result<PreparedAbootArtifacts> {
2662    let ComposefsBootEntry::Aboot(entry) = entry else {
2663        bail!("Expected an aboot boot entry")
2664    };
2665    let payload = read_aboot_payload(entry, repo)?;
2666    let boot_digest = validate_composefs_aboot_payload(
2667        id,
2668        boot_ids,
2669        entry.encoding,
2670        &payload,
2671        allow_missing_fsverity,
2672    )?;
2673    let vbmeta = entry
2674        .vbmeta
2675        .as_ref()
2676        .map(|artifact| read_file(&artifact.file, repo).context("Reading vbmeta payload"))
2677        .transpose()?;
2678    Ok(PreparedAbootArtifacts {
2679        boot_digest,
2680        payload,
2681        vbmeta,
2682    })
2683}
2684
2685fn install_composefs_bootloader(
2686    root_setup: &RootSetup,
2687    state: &State,
2688    bootloader: Bootloader,
2689    composefs_mnt_fd: std::os::fd::OwnedFd,
2690    repo: &ComposefsRepository,
2691    fs: &FileSystem<Sha512HashValue>,
2692) -> Result<()> {
2693    if bootloader == Bootloader::None {
2694        return Ok(());
2695    }
2696    let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?;
2697
2698    if bootloader == Bootloader::Ukiboot {
2699        return mounted_root.with_esp(|esp| install_ukiboot(mounted_root.dir(), fs, repo, esp));
2700    }
2701    let boot_uuid = root_setup
2702        .get_boot_uuid()?
2703        .or(root_setup.rootfs_uuid.as_deref())
2704        .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
2705
2706    if cfg!(target_arch = "s390x") {
2707        // TODO: Integrate s390x support into install_via_bootupd
2708        crate::bootloader::install_via_zipl(
2709            &root_setup.device_info.require_single_root()?,
2710            boot_uuid,
2711        )?;
2712    } else if matches!(bootloader, Bootloader::Grub | Bootloader::GrubCC) {
2713        let chroot_target = Utf8Path::from_path(mounted_root.root_path())
2714            .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
2715        // Like the ostree backend, bind the physical root's real /boot (an
2716        // ordinary ext4/xfs/... directory, not yet populated with kernels at
2717        // this point) into the chroot. This gives bootupd both a correct
2718        // filesystem to inspect for `--write-uuid` (rather than the ESP,
2719        // which is otherwise mounted at the composefs root's own /boot) and
2720        // an empty `boot/efi` directory for its EFI component to discover
2721        // and mount the real ESP into, exactly as it would on ostree.
2722        let bind_boot_path = root_setup.physical_root_path.join(BOOT);
2723        crate::bootloader::install_via_bootupd(
2724            &root_setup.device_info,
2725            &root_setup.physical_root_path,
2726            &state.config_opts,
2727            Some(chroot_target),
2728            Some(bind_boot_path.as_path()),
2729        )?;
2730
2731        // FIXME: Remove this hack once we have support in bootupd
2732        if matches!(bootloader, Bootloader::GrubCC) {
2733            // bootupctl wrote this under the physical root's real /boot (via
2734            // the bind mount above), not under the composefs root.
2735            root_setup
2736                .physical_root
2737                .remove_all_optional("boot/grub2")
2738                .context("removing grub2")?;
2739
2740            // install_via_bootupd above has already returned, so it's safe
2741            // to mount the ESP here for the duration of this cleanup.
2742            mounted_root.with_esp(|esp_dir| {
2743                let (os_id, ..) = parse_os_release(mounted_root.dir())?
2744                    .ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
2745
2746                let dir = format!("EFI/{os_id}");
2747
2748                // Files are in EFI/<os-name>/
2749                let efis_dir = esp_dir
2750                    .open_dir(&dir)
2751                    .with_context(|| format!("Opening {dir}"))?;
2752
2753                efis_dir
2754                    .remove_file_optional("bootuuid.cfg")
2755                    .context("Removing bootuuid.cfg")?;
2756                efis_dir
2757                    .remove_file_optional("grub.cfg")
2758                    .context("Removing grub.cfg")?;
2759
2760                let final_name = match std::env::consts::ARCH {
2761                    "x86_64" => "grubx64.efi",
2762                    "aarch64" => "grubaa64-cc.efi",
2763                    arch => anyhow::bail!("GrubCC not supported for: {arch}"),
2764                };
2765
2766                mounted_root
2767                    .dir()
2768                    .copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
2769                    .context("Copying grub-cc binary")?;
2770
2771                Ok(())
2772            })?;
2773        }
2774    } else {
2775        mounted_root.with_esp(|_esp_dir| {
2776            crate::bootloader::install_systemd_boot(
2777                &mounted_root,
2778                &state.config_opts,
2779                get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
2780            )
2781        })?;
2782    }
2783    Ok(())
2784}
2785
2786fn validate_aboot_disk_layout(
2787    encoding: Option<AbootEncoding>,
2788    layout: Option<AbootDiskLayout>,
2789) -> Result<()> {
2790    match (encoding, layout) {
2791        (Some(encoding), Some(layout)) => {
2792            let expected = match encoding {
2793                AbootEncoding::AndroidV2 => AbootDiskLayout::Android,
2794                AbootEncoding::Uki => AbootDiskLayout::Ukiboot,
2795            };
2796            ensure!(
2797                layout == expected,
2798                "Selected aboot disk layout {layout:?} does not match pulled artifact encoding {encoding:?}"
2799            );
2800        }
2801        (Some(_), None) => bail!(
2802            "Pulled image contains an aboot artifact but no aboot disk layout was selected; run to-disk from a container with matching boot artifacts"
2803        ),
2804        (None, Some(layout)) => {
2805            bail!("Selected aboot disk layout {layout:?} but pulled image has no aboot artifact")
2806        }
2807        (None, None) => {}
2808    }
2809    Ok(())
2810}
2811
2812#[context("Setting up composefs boot")]
2813pub(crate) async fn setup_composefs_boot(
2814    root_setup: &RootSetup,
2815    state: &State,
2816    pull_result: &composefs_oci::PullResult<Sha512HashValue>,
2817    allow_missing_fsverity: bool,
2818) -> Result<()> {
2819    const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5";
2820
2821    tracing::info!(
2822        message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID,
2823        bootc.operation = "boot_setup",
2824        bootc.config_digest = %pull_result.config_digest,
2825        bootc.allow_missing_fsverity = allow_missing_fsverity,
2826        "Setting up composefs boot",
2827    );
2828
2829    let mut repo = open_composefs_repo(&root_setup.physical_root)?;
2830    if allow_missing_fsverity {
2831        repo.set_insecure()?;
2832    }
2833
2834    let repo = Arc::new(repo);
2835
2836    let crate::bootc_composefs::repo::BootImage {
2837        id,
2838        boot_ids,
2839        fs,
2840        entries,
2841    } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?;
2842    let entry = primary_boot_entry(&entries)?;
2843    let boot_type = BootType::from(entry);
2844
2845    if root_setup.disk_install {
2846        let encoding = match entry {
2847            ComposefsBootEntry::Aboot(aboot) => Some(aboot.encoding),
2848            _ => None,
2849        };
2850        validate_aboot_disk_layout(encoding, root_setup.aboot_disk_layout)?;
2851    }
2852
2853    let composefs_mnt_fd = repo
2854        .mount(&id.to_hex())
2855        .context("Failed to mount composefs image")?;
2856    let image_root = Dir::reopen_dir(&composefs_mnt_fd)?;
2857
2858    if boot_type == BootType::Aboot {
2859        super::aboot::require_aboot_deploy_in_image(&image_root)?;
2860    }
2861
2862    let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?;
2863
2864    validate_bootloader(entry, postfetch.detected_bootloader)?;
2865    if matches!(boot_type, BootType::Uki | BootType::Aboot) {
2866        state.require_no_kargs_for_immutable_boot()?;
2867    }
2868
2869    install_composefs_bootloader(
2870        root_setup,
2871        state,
2872        postfetch.detected_bootloader,
2873        composefs_mnt_fd,
2874        &repo,
2875        &fs,
2876    )?;
2877
2878    let repo = Arc::try_unwrap(repo).map_err(|_| {
2879        anyhow::anyhow!(
2880            "BUG: Arc<Repository> still has other references after boot image generation"
2881        )
2882    })?;
2883
2884    let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version());
2885    let (boot_digest, deploy_id) = match boot_type {
2886        BootType::Bls => (
2887            setup_composefs_bls_boot(
2888                BootSetupType::Setup((&root_setup, &state, &postfetch, allow_missing_fsverity)),
2889                &repo,
2890                &provisional_deploy_id,
2891                provisional_format,
2892                entry,
2893                &image_root,
2894            )?,
2895            provisional_deploy_id,
2896        ),
2897        BootType::Uki => print_uki_dumpfile_diff_on_mismatch(
2898            setup_composefs_uki_boot(
2899                BootSetupType::Setup((&root_setup, &state, &postfetch, allow_missing_fsverity)),
2900                &repo,
2901                &provisional_deploy_id,
2902                &boot_ids,
2903                entries,
2904            ),
2905            &repo,
2906            &fs,
2907        )?,
2908        BootType::Aboot => {
2909            let artifacts = prepare_composefs_aboot_artifacts(
2910                &repo,
2911                &id,
2912                &boot_ids,
2913                entry,
2914                allow_missing_fsverity,
2915            )?;
2916            #[cfg(feature = "install-to-disk")]
2917            if let Some(layout) = root_setup.aboot_disk_layout {
2918                install_aboot_disk_payload(root_setup, layout, &artifacts)?;
2919            }
2920            (artifacts.boot_digest, provisional_deploy_id)
2921        }
2922    };
2923
2924    write_composefs_state(
2925        &root_setup.physical_root_path,
2926        &deploy_id,
2927        &crate::spec::ImageReference::from(state.target_imgref.clone()),
2928        true,
2929        boot_type,
2930        boot_digest,
2931        &pull_result.manifest_digest.to_string(),
2932        allow_missing_fsverity,
2933    )
2934    .await?;
2935
2936    if let Some(contents) = state.root_ssh_authorized_keys.as_deref() {
2937        let deployment = root_setup
2938            .physical_root
2939            .open_dir(format!("{STATE_DIR_RELATIVE}/{}", deploy_id.to_hex()))
2940            .context("Opening deployment state")?;
2941        crate::install::osconfig::inject_root_ssh_authorized_keys(
2942            &image_root,
2943            &deployment,
2944            state.load_policy()?.as_ref(),
2945            contents,
2946        )?;
2947    }
2948
2949    Ok(())
2950}
2951
2952/// Associate every accepted boot image with its EROFS serialization format.
2953/// A UKI's V1 candidate must never be accepted merely because its digest is a
2954/// valid V2 image (or vice versa). `selected` is the freshly generated image;
2955/// recovered images are added with their actual format by the recovery path.
2956pub(crate) fn expected_boot_image_ids(
2957    v1: Option<Sha512HashValue>,
2958    v2: Option<Sha512HashValue>,
2959    selected: &Sha512HashValue,
2960    selected_format: FormatVersion,
2961) -> ExpectedBootImageIds {
2962    let mut ids = ExpectedBootImageIds::default();
2963    if let Some(v1) = v1 {
2964        ids.add(FormatVersion::V1, v1);
2965    }
2966    if let Some(v2) = v2 {
2967        ids.add(FormatVersion::V2, v2);
2968    }
2969    ids.add(selected_format, selected.clone());
2970    ids
2971}
2972
2973#[cfg(test)]
2974mod tests {
2975    use super::*;
2976    use composefs::erofs::format::FormatVersion;
2977    use composefs_boot::bootloader::{AbootArtifact, AbootEncoding, AbootEntry, Type2Entry};
2978
2979    #[test]
2980    fn aboot_disk_layout_matches_pulled_artifact() {
2981        assert!(validate_aboot_disk_layout(None, None).is_ok());
2982        assert!(
2983            validate_aboot_disk_layout(
2984                Some(AbootEncoding::AndroidV2),
2985                Some(AbootDiskLayout::Android)
2986            )
2987            .is_ok()
2988        );
2989        assert!(
2990            validate_aboot_disk_layout(Some(AbootEncoding::Uki), Some(AbootDiskLayout::Ukiboot))
2991                .is_ok()
2992        );
2993        assert!(
2994            validate_aboot_disk_layout(Some(AbootEncoding::Uki), Some(AbootDiskLayout::Android))
2995                .is_err()
2996        );
2997        assert!(validate_aboot_disk_layout(Some(AbootEncoding::Uki), None).is_err());
2998        assert!(validate_aboot_disk_layout(None, Some(AbootDiskLayout::Ukiboot)).is_err());
2999    }
3000
3001    #[cfg(feature = "install-to-disk")]
3002    #[test]
3003    fn aboot_initial_partition_payloads() -> Result<()> {
3004        let mut artifacts = PreparedAbootArtifacts {
3005            boot_digest: String::new(),
3006            payload: Box::from(&b"boot"[..]),
3007            vbmeta: Some(Box::from(&b"vbmeta"[..])),
3008        };
3009        let android = aboot_disk_partition_payloads(AbootDiskLayout::Android, &artifacts)?;
3010        assert_eq!(
3011            android
3012                .iter()
3013                .map(|(label, payload)| (*label, *payload))
3014                .collect::<Vec<_>>(),
3015            [
3016                ("boot_b", &b"boot"[..]),
3017                ("vbmeta_b", &b"vbmeta"[..]),
3018                ("boot_a", &b"boot"[..]),
3019                ("vbmeta_a", &b"vbmeta"[..]),
3020            ]
3021        );
3022        assert!(aboot_disk_partition_payloads(AbootDiskLayout::Ukiboot, &artifacts).is_err());
3023
3024        artifacts.vbmeta = None;
3025        let android = aboot_disk_partition_payloads(AbootDiskLayout::Android, &artifacts)?;
3026        assert_eq!(android[1].1, &[0]);
3027        assert_eq!(android[3].1, &[0]);
3028        let ukiboot = aboot_disk_partition_payloads(AbootDiskLayout::Ukiboot, &artifacts)?;
3029        assert_eq!(
3030            ukiboot
3031                .iter()
3032                .map(|(label, payload)| (*label, *payload))
3033                .collect::<Vec<_>>(),
3034            [("ukiboot_b", &b"boot"[..]), ("ukiboot_a", &b"boot"[..])]
3035        );
3036        Ok(())
3037    }
3038
3039    #[test]
3040    fn test_grub_bls_abs_entries_path() -> Result<()> {
3041        let td = tempfile::tempdir()?;
3042        let td = Utf8Path::from_path(td.path()).unwrap();
3043        let boot = td.join(BOOT);
3044        std::fs::create_dir(&boot)?;
3045        // boot on the root filesystem
3046        assert_eq!(grub_bls_abs_entries_path(&boot)?, "/boot");
3047        // A separate filesystem
3048        assert_eq!(grub_bls_abs_entries_path(Utf8Path::new("/proc"))?, "/");
3049        // Nothing to check
3050        assert!(grub_bls_abs_entries_path(Utf8Path::new("/")).is_err());
3051        assert!(grub_bls_abs_entries_path(&td.join("nonexistent/boot")).is_err());
3052        Ok(())
3053    }
3054
3055    #[test]
3056    fn test_replace_composefs_karg() {
3057        let mut cmdline =
3058            Cmdline::from("root=UUID=abc composefs=old composefs.digest=v1-sha512-12:stale");
3059        replace_composefs_karg(
3060            &mut cmdline,
3061            "composefs.digest=v1-sha512-12:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3062        )
3063        .unwrap();
3064        let rendered = cmdline.to_string();
3065        assert!(!rendered.contains("composefs=old"));
3066        assert!(!rendered.contains(":stale"));
3067        assert!(rendered.contains("root=UUID=abc"));
3068    }
3069
3070    #[tokio::test]
3071    async fn test_install_ukiboot() -> Result<()> {
3072        use composefs::fs::read_filesystem;
3073        use composefs::repository::RepositoryConfig;
3074
3075        let temp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
3076        let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure();
3077        let (repo, _) = ComposefsRepository::init_path(&*temp, "repo", config)?;
3078        let repo = Arc::new(repo);
3079
3080        let (loader, fallback) = match std::env::consts::ARCH {
3081            "x86_64" => ("ukibootx64.efi", "BOOTX64.EFI"),
3082            "aarch64" => ("ukibootaa64.efi", "BOOTAA64.EFI"),
3083            _ => return Ok(()),
3084        };
3085        let files = [
3086            (loader, loader),
3087            ("slot_a.addon.efi", "ukiboot_a.efi.extra.d/slot_a.addon.efi"),
3088            ("slot_b.addon.efi", "ukiboot_b.efi.extra.d/slot_b.addon.efi"),
3089        ];
3090        let all_signed = (1 << files.len()) - 1;
3091        for (vendor, os_id, layout) in [
3092            ("redhat", Some("rhel"), "symlink"),
3093            ("centos", Some("autosd"), "symlink"),
3094            ("custom", None, "directory"),
3095            ("custom", None, "nested-symlink"),
3096            ("custom", None, "parent-symlink"),
3097            ("BOOT", None, "symlink"),
3098            ("Linux", None, "directory"),
3099            ("BOOT", None, "flat"),
3100        ] {
3101            for signed_mask in 0..=all_signed {
3102                let source = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
3103                let esp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
3104                let packaged_root = "usr/libexec/ukiboot/efi";
3105                let packaged_path = match layout {
3106                    "directory" => format!("{packaged_root}/{vendor}"),
3107                    "nested-symlink" => format!("{packaged_root}/nested/files"),
3108                    "parent-symlink" => "usr/libexec/ukiboot/files".to_string(),
3109                    _ => packaged_root.to_string(),
3110                };
3111                source.create_dir_all(packaged_root)?;
3112                source.create_dir_all(&packaged_path)?;
3113                match layout {
3114                    "symlink" => source.symlink(".", format!("{packaged_root}/{vendor}"))?,
3115                    "nested-symlink" => {
3116                        source.symlink("nested/files", format!("{packaged_root}/{vendor}"))?
3117                    }
3118                    "parent-symlink" => {
3119                        source.symlink("../files", format!("{packaged_root}/{vendor}"))?
3120                    }
3121                    _ => {}
3122                }
3123                // Ignore broken links, regular files, and directories without a main binary.
3124                source.symlink("missing", format!("{packaged_root}/broken"))?;
3125                source.write(format!("{packaged_root}/a-file"), "not a directory")?;
3126                source.create_dir_all(format!("{packaged_root}/empty/{loader}"))?;
3127                if let Some(os_id) = os_id {
3128                    source.create_dir_all("usr/lib")?;
3129                    source.write("usr/lib/os-release", format!("ID={os_id}\n"))?;
3130                }
3131                for (index, (name, vendor_path)) in files.iter().enumerate() {
3132                    source.write(
3133                        format!("{packaged_path}/{name}"),
3134                        format!("unsigned-{name}").repeat(if index == 0 { 512 } else { 1 }),
3135                    )?;
3136                    // A complete /boot directory for another vendor must not be selected.
3137                    let unrelated = format!("boot/efi/EFI/aaa/{vendor_path}");
3138                    source.create_dir_all(Utf8Path::new(&unrelated).parent().unwrap())?;
3139                    source.write(unrelated, "other-vendor")?;
3140                    if signed_mask & (1 << index) != 0 {
3141                        let path = format!("boot/efi/EFI/{vendor}/{vendor_path}");
3142                        source.create_dir_all(Utf8Path::new(&path).parent().unwrap())?;
3143                        source.write(
3144                            path,
3145                            format!("signed-{name}").repeat(if index == 0 { 512 } else { 1 }),
3146                        )?;
3147                    }
3148                }
3149                let fs =
3150                    read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone()))
3151                        .await?;
3152                install_ukiboot(&source, &fs, &repo, &esp)?;
3153                for (index, (name, vendor_path)) in files.iter().enumerate() {
3154                    let expected = if signed_mask == all_signed {
3155                        format!("signed-{name}")
3156                    } else {
3157                        format!("unsigned-{name}")
3158                    }
3159                    .repeat(if index == 0 { 512 } else { 1 });
3160                    assert_eq!(
3161                        esp.read_to_string(format!("EFI/{vendor}/{vendor_path}"))?,
3162                        expected,
3163                        "vendor={vendor}, layout={layout}, signed_mask={signed_mask}"
3164                    );
3165                    if index == 0 {
3166                        assert_eq!(
3167                            esp.read_to_string(format!("EFI/BOOT/{fallback}"))?,
3168                            expected
3169                        );
3170                    } else if vendor != "BOOT" {
3171                        assert!(!esp.exists(format!("EFI/BOOT/{vendor_path}")));
3172                    }
3173                }
3174                assert!(!esp.exists("EFI/aaa"));
3175
3176                let preferred = format!("boot/efi/EFI/{vendor}/{loader}");
3177                source.remove_file_optional(&preferred)?;
3178                if signed_mask == all_signed {
3179                    source.create_dir_all(&preferred)?;
3180                    let fs = read_filesystem(
3181                        source.reopen_as_ownedfd()?,
3182                        ".".into(),
3183                        Some(repo.clone()),
3184                    )
3185                    .await?;
3186                    assert!(install_ukiboot(&source, &fs, &repo, &esp).is_err());
3187                    source.remove_dir(&preferred)?;
3188                }
3189                // Vendor discovery only needs the loader; missing add-ons fail when read.
3190                source.remove_file(format!("{packaged_path}/slot_b.addon.efi"))?;
3191                let fs =
3192                    read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone()))
3193                        .await?;
3194                let error = install_ukiboot(&source, &fs, &repo, &esp).unwrap_err();
3195                assert!(format!("{error:#}").contains("slot_b.addon.efi"));
3196            }
3197        }
3198        Ok(())
3199    }
3200
3201    fn fake_aboot_entry() -> ComposefsBootEntry<Sha512HashValue> {
3202        ComposefsBootEntry::Aboot(AbootEntry {
3203            kver: "1.0".into(),
3204            encoding: AbootEncoding::AndroidV2,
3205            payload: AbootArtifact {
3206                path: "/boot/aboot-1.0.img".into(),
3207                file: RegularFile::External(Sha512HashValue::EMPTY, 4096),
3208            },
3209            vbmeta: None,
3210        })
3211    }
3212
3213    #[test]
3214    fn test_validate_bootloader() {
3215        let bls = ComposefsBootEntry::UsrLibModulesVmLinuz(UsrLibModulesVmlinuz {
3216            kver: "1.0".into(),
3217            vmlinuz: RegularFile::Inline(Default::default()),
3218            initramfs: Some(RegularFile::Inline(Default::default())),
3219            os_release: None,
3220        });
3221        let android = fake_aboot_entry();
3222        let mut ukiboot = fake_aboot_entry();
3223        if let ComposefsBootEntry::Aboot(entry) = &mut ukiboot {
3224            entry.encoding = AbootEncoding::Uki;
3225        }
3226        let uki = ComposefsBootEntry::Type2(Type2Entry {
3227            kver: None,
3228            file_path: "test.efi".into(),
3229            file: RegularFile::Inline(Default::default()),
3230            pe_type: PEType::Uki,
3231        });
3232        assert_eq!(image_bootloader(&android), Some(Bootloader::None));
3233        assert_eq!(image_bootloader(&ukiboot), Some(Bootloader::Ukiboot));
3234        assert_eq!(image_bootloader(&uki), None);
3235        assert_eq!(image_bootloader(&bls), None);
3236        for bootloader in [
3237            Bootloader::None,
3238            Bootloader::Ukiboot,
3239            Bootloader::Grub,
3240            Bootloader::GrubCC,
3241            Bootloader::Systemd,
3242        ] {
3243            for (entry, expected) in [
3244                (&android, bootloader == Bootloader::None),
3245                (&ukiboot, bootloader == Bootloader::Ukiboot),
3246                (
3247                    &uki,
3248                    !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot),
3249                ),
3250                (
3251                    &bls,
3252                    !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot),
3253                ),
3254            ] {
3255                assert_eq!(
3256                    validate_bootloader(entry, bootloader).is_ok(),
3257                    expected,
3258                    "{entry:?}, {bootloader:?}"
3259                );
3260            }
3261        }
3262    }
3263
3264    #[test]
3265    fn test_pe_output_dir() {
3266        let mounted_efi = Path::new("/esp");
3267        let uki_id = Sha512HashValue::EMPTY;
3268        let uki_hex = uki_id.to_hex();
3269
3270        // Boot entry paths are relative to the directory they were discovered in
3271        // (e.g. "/boot/EFI/Linux" or "/boot/loader/addons"), not absolute filesystem paths.
3272
3273        // A UKI itself always lands directly in BOOTC_UKI_DIR.
3274        assert_eq!(
3275            pe_output_dir(
3276                &PEType::Uki,
3277                mounted_efi,
3278                Utf8Path::new("foo.efi"),
3279                &uki_id,
3280                false
3281            ),
3282            mounted_efi.join(BOOTC_UKI_DIR)
3283        );
3284
3285        // A per-UKI addon (nested under a `<name>.efi.extra.d` directory) gets
3286        // renamed into a directory namespaced by the UKI's verity digest.
3287        assert_eq!(
3288            pe_output_dir(
3289                &PEType::UkiAddon,
3290                mounted_efi,
3291                Utf8Path::new("foo.efi.extra.d/bar.addon.efi"),
3292                &uki_id,
3293                false,
3294            ),
3295            mounted_efi
3296                .join(BOOTC_UKI_DIR)
3297                .join(get_uki_addon_dir_name(&uki_hex))
3298        );
3299
3300        // A global UKI addon is written to the shared addons directory, not
3301        // namespaced by any particular UKI's verity digest.
3302        assert_eq!(
3303            pe_output_dir(
3304                &PEType::GlobalUkiAddon,
3305                mounted_efi,
3306                Utf8Path::new("bar.addon.efi"),
3307                &uki_id,
3308                false
3309            ),
3310            mounted_efi.join(GLOBAL_UKI_ADDONS_DIR)
3311        );
3312
3313        // On upgrade/switch global addons are written to `loader/addons.staged`
3314        assert_eq!(
3315            pe_output_dir(
3316                &PEType::GlobalUkiAddon,
3317                mounted_efi,
3318                Utf8Path::new("bar.addon.efi"),
3319                &uki_id,
3320                true
3321            ),
3322            mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR)
3323        );
3324    }
3325
3326    #[test]
3327    fn test_type1_filename_generation() {
3328        // Test basic os_id without hyphens
3329        let filename =
3330            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3331        assert_eq!(filename, "bootc_fedora-41.20251125.0-1.conf");
3332
3333        // Test primary vs secondary priority
3334        let primary =
3335            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3336        let secondary =
3337            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
3338        assert_eq!(primary, "bootc_fedora-41.20251125.0-1.conf");
3339        assert_eq!(secondary, "bootc_fedora-41.20251125.0-0.conf");
3340
3341        // Test os_id with hyphens (should be replaced with underscores)
3342        let filename =
3343            type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3344        assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
3345
3346        // Test multiple hyphens in os_id
3347        let filename =
3348            type1_entry_conf_file_name("my-custom-os", "1.0.0", FILENAME_PRIORITY_PRIMARY);
3349        assert_eq!(filename, "bootc_my_custom_os-1.0.0-1.conf");
3350
3351        // Test rhel example
3352        let filename = type1_entry_conf_file_name("rhel", "9.3.0", FILENAME_PRIORITY_SECONDARY);
3353        assert_eq!(filename, "bootc_rhel-9.3.0-0.conf");
3354    }
3355
3356    #[test]
3357    fn test_grub_filename_parsing() {
3358        // Verify our filename format works correctly with Grub's parsing logic
3359        // Grub parses: bootc_fedora-41.20251125.0-1.conf
3360        // Expected:
3361        //   - name: bootc_fedora
3362        //   - version: 41.20251125.0
3363        //   - release: 1
3364
3365        // For fedora-coreos (with hyphens), we convert to underscores
3366        let filename = type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", "1");
3367        assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
3368
3369        // Grub parsing simulation (from right):
3370        // 1. Strip .conf -> bootc_fedora_coreos-41.20251125.0-1
3371        // 2. Last '-' splits: release="1", remainder="bootc_fedora_coreos-41.20251125.0"
3372        // 3. Second-to-last '-' splits: version="41.20251125.0", name="bootc_fedora_coreos"
3373
3374        let without_ext = filename.strip_suffix(".conf").unwrap();
3375        let parts: Vec<&str> = without_ext.rsplitn(3, '-').collect();
3376        assert_eq!(parts.len(), 3);
3377        assert_eq!(parts[0], "1"); // release
3378        assert_eq!(parts[1], "41.20251125.0"); // version
3379        assert_eq!(parts[2], "bootc_fedora_coreos"); // name
3380    }
3381
3382    #[test]
3383    fn test_os_id_from_sort_key() {
3384        for (sort_key, expected) in [
3385            (primary_sort_key("fedora"), Some("fedora")),
3386            (secondary_sort_key("fedora"), Some("fedora")),
3387            (primary_sort_key("fedora-coreos"), Some("fedora-coreos")),
3388            (secondary_sort_key("bootc"), Some("bootc")),
3389            ("1".to_owned(), None),
3390            ("bootc".to_owned(), None),
3391            ("other-fedora-0".to_owned(), None),
3392        ] {
3393            assert_eq!(os_id_from_sort_key(&sort_key), expected, "{sort_key}");
3394        }
3395    }
3396
3397    #[test]
3398    fn test_sort_keys() {
3399        // Test sort-key generation for systemd-boot
3400        let primary = primary_sort_key("fedora");
3401        let secondary = secondary_sort_key("fedora");
3402
3403        assert_eq!(primary, "bootc-fedora-0");
3404        assert_eq!(secondary, "bootc-fedora-1");
3405
3406        // Systemd-boot sorts ascending, so "bootc-fedora-0" < "bootc-fedora-1"
3407        assert!(primary < secondary);
3408
3409        // Test with hyphenated os_id (sort-key keeps hyphens)
3410        let primary_coreos = primary_sort_key("fedora-coreos");
3411        assert_eq!(primary_coreos, "bootc-fedora-coreos-0");
3412    }
3413
3414    #[test]
3415    fn test_filename_sorting_grub_style() {
3416        // Simulate Grub's descending sort by (name, version, release)
3417
3418        // Test 1: Same version, different release (priority)
3419        let primary =
3420            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3421        let secondary =
3422            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
3423
3424        // Descending sort: "bootc_fedora-41.20251125.0-1" > "bootc_fedora-41.20251125.0-0"
3425        assert!(
3426            primary > secondary,
3427            "Primary should sort before secondary in descending order"
3428        );
3429
3430        // Test 2: Different versions
3431        let newer =
3432            type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
3433        let older =
3434            type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
3435
3436        // Descending sort: version "42" > "41"
3437        assert!(
3438            newer > older,
3439            "Newer version should sort before older in descending order"
3440        );
3441
3442        // Test 3: Different os_id (different name)
3443        let fedora = type1_entry_conf_file_name("fedora", "41.0", FILENAME_PRIORITY_PRIMARY);
3444        let rhel = type1_entry_conf_file_name("rhel", "9.0", FILENAME_PRIORITY_PRIMARY);
3445
3446        // Names differ: bootc_rhel > bootc_fedora (descending alphabetical)
3447        assert!(
3448            rhel > fedora,
3449            "RHEL should sort before Fedora in descending order"
3450        );
3451    }
3452
3453    /// A distinct, non-`EMPTY` digest to use as "the other" digest in
3454    /// `resolve_boot_image_match` tests.
3455    fn other_digest() -> Sha512HashValue {
3456        Sha512HashValue::from_hex("aa".repeat(64)).unwrap()
3457    }
3458
3459    fn uki_v1(digest: Sha512HashValue, insecure: bool) -> String {
3460        ComposefsBootCmdline::new_v1(digest, insecure).to_cmdline_arg()
3461    }
3462
3463    /// The bare legacy `composefs=<hex>` spelling.
3464    fn uki_v2(digest: Sha512HashValue, insecure: bool) -> String {
3465        ComposefsBootCmdline::new_v2(digest, insecure).to_cmdline_arg()
3466    }
3467
3468    fn uki_explicit_v2(digest: &Sha512HashValue) -> String {
3469        format!("composefs.digest=v2-sha512-12:{}", digest.to_hex())
3470    }
3471
3472    fn candidate(format: UkiCandidateFormat, digest: Sha512HashValue) -> UkiComposefsCandidate {
3473        UkiComposefsCandidate {
3474            digest,
3475            insecure: false,
3476            format,
3477        }
3478    }
3479
3480    #[test]
3481    fn test_aboot_composefs_candidates() {
3482        let v1 = Sha512HashValue::EMPTY;
3483        let v2 = other_digest();
3484        let ids = ExpectedBootImageIds {
3485            v1: vec![v1.clone()],
3486            v2: vec![v2.clone()],
3487        };
3488        let dual = format!(
3489            "{} {}",
3490            uki_v1(v1.clone(), false),
3491            uki_v2(v2.clone(), false)
3492        );
3493        assert!(validate_aboot_composefs_candidates(&v1, &ids, &dual, false).is_ok());
3494        let stale_fallback = format!(
3495            "{} {}",
3496            uki_v1(v1.clone(), false),
3497            uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false)
3498        );
3499        assert!(validate_aboot_composefs_candidates(&v1, &ids, &stale_fallback, false).is_err());
3500        assert!(validate_aboot_composefs_candidates(&v2, &ids, &dual, false).is_err());
3501        let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2, true));
3502        assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, false).is_err());
3503        assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, true).is_ok());
3504    }
3505
3506    #[test]
3507    fn test_uki_composefs_candidates() {
3508        let v1 = Sha512HashValue::EMPTY;
3509        let v2 = other_digest();
3510        let secure = format!(
3511            "{} {}",
3512            uki_v1(v1.clone(), false),
3513            uki_v2(v2.clone(), false)
3514        );
3515        let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2.clone(), true));
3516        let cases = [
3517            ("secure dual", secure.clone(), true),
3518            (
3519                "secure dual reversed",
3520                format!(
3521                    "{} {}",
3522                    uki_v2(v2.clone(), false),
3523                    uki_v1(v1.clone(), false)
3524                ),
3525                true,
3526            ),
3527            ("insecure dual", insecure, true),
3528            (
3529                "insecure dual reversed",
3530                format!("{} {}", uki_v2(v2.clone(), true), uki_v1(v1.clone(), true)),
3531                true,
3532            ),
3533            (
3534                "stale secondary",
3535                format!(
3536                    "{} {}",
3537                    uki_v1(v1.clone(), false),
3538                    uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false)
3539                ),
3540                true,
3541            ),
3542            (
3543                "swapped digests",
3544                format!(
3545                    "{} {}",
3546                    uki_v1(v2.clone(), false),
3547                    uki_v2(v1.clone(), false)
3548                ),
3549                true,
3550            ),
3551            (
3552                "mixed policy",
3553                format!("{} {}", uki_v1(v1.clone(), false), uki_v2(v2.clone(), true)),
3554                false,
3555            ),
3556            (
3557                "malformed secondary",
3558                format!("{} composefs=not-a-digest", uki_v1(v1.clone(), false)),
3559                false,
3560            ),
3561            (
3562                "malformed sha512 descriptor",
3563                "composefs.digest=v1-sha512-12:bad".to_string(),
3564                false,
3565            ),
3566            (
3567                "explicit V2 descriptor",
3568                format!("composefs.digest=v2-sha512-12:{}", v2.to_hex()),
3569                true,
3570            ),
3571            (
3572                "duplicate V2 key",
3573                format!(
3574                    "{} {}",
3575                    uki_v2(v2.clone(), false),
3576                    uki_v2(v2.clone(), false)
3577                ),
3578                false,
3579            ),
3580            ("missing value", "composefs".to_string(), false),
3581            ("no candidates", "quiet rw".to_string(), false),
3582        ];
3583
3584        for (name, cmdline, should_pass) in cases {
3585            let result = parse_uki_composefs_candidates(&cmdline).and_then(|candidates| {
3586                uki_candidates_policy(&candidates)?;
3587                Ok(candidates)
3588            });
3589            assert_eq!(result.is_ok(), should_pass, "case {name}: {result:?}");
3590        }
3591        // Only the explicit descriptor identifies V2; the bare key has
3592        // carried both V1 and V2 digests in released UKIs.
3593        for (cmdline, format) in [
3594            (uki_explicit_v2(&v2), UkiCandidateFormat::V2),
3595            (uki_v1(v1.clone(), false), UkiCandidateFormat::V1),
3596            (uki_v2(v2.clone(), false), UkiCandidateFormat::Unspecified),
3597        ] {
3598            let candidates = parse_uki_composefs_candidates(&cmdline).unwrap();
3599            let formats = candidates.iter().map(|c| c.format).collect::<Vec<_>>();
3600            assert_eq!(formats, [format], "{cmdline}");
3601        }
3602        let candidates = parse_uki_composefs_candidates(&format!(
3603            "composefs.digest=v1-sha256-12:{} {} {}",
3604            "aa".repeat(32),
3605            uki_v1(v1.clone(), false),
3606            uki_v2(v2.clone(), false)
3607        ))
3608        .unwrap();
3609        assert_eq!(
3610            candidates
3611                .iter()
3612                .map(|candidate| candidate.digest.clone())
3613                .collect::<Vec<_>>(),
3614            vec![v1.clone(), v2.clone()]
3615        );
3616
3617        let expected = ExpectedBootImageIds {
3618            v1: vec![v1.clone()],
3619            v2: vec![v2.clone()],
3620        };
3621        for (name, cmdline, valid) in [
3622            ("valid dual", secure, true),
3623            ("legacy V1", uki_v2(v1.clone(), false), true),
3624            ("legacy V2", uki_v2(v2.clone(), false), true),
3625            ("explicit V2", uki_explicit_v2(&v2), true),
3626            ("explicit V2 naming V1 image", uki_explicit_v2(&v1), false),
3627            (
3628                "stale secondary",
3629                format!(
3630                    "{} {}",
3631                    uki_v1(v1.clone(), false),
3632                    uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false)
3633                ),
3634                false,
3635            ),
3636            (
3637                "swapped digests",
3638                format!(
3639                    "{} {}",
3640                    uki_v1(v2.clone(), false),
3641                    uki_v2(v1.clone(), false)
3642                ),
3643                false,
3644            ),
3645        ] {
3646            let candidates = parse_uki_composefs_candidates(&cmdline).unwrap();
3647            assert_eq!(
3648                validate_uki_candidates(&candidates, &expected, Some("uki.efi".into())).is_ok(),
3649                valid,
3650                "case {name}"
3651            );
3652        }
3653    }
3654
3655    #[test]
3656    fn test_resolve_boot_image_match() {
3657        let expected = other_digest();
3658        let uki_name = "uki.efi";
3659        // 2 xattr filtering modes x 2 EROFS format versions.
3660        let combinations_tried = 4;
3661
3662        #[derive(Copy, Clone)]
3663        enum FindMatching {
3664            Found,
3665            NotFound,
3666            Errors,
3667        }
3668
3669        let cases = [
3670            (FindMatching::Found, true, vec![]),
3671            (
3672                FindMatching::NotFound,
3673                false,
3674                vec![
3675                    uki_name.into(),
3676                    format!("{expected:?}"),
3677                    format!("doesn't match any of {combinations_tried} supported"),
3678                ],
3679            ),
3680            (
3681                FindMatching::Errors,
3682                false,
3683                vec![
3684                    "search blew up".to_string(),
3685                    "Searching for a boot image xattr filtering mode/format version matching \
3686                     the UKI digest"
3687                        .to_string(),
3688                ],
3689            ),
3690        ];
3691
3692        for (find_matching, should_succeed, want_substrings) in cases {
3693            let find_matching_result = match find_matching {
3694                FindMatching::Found => Ok(composefs_oci::BootImageMatch::Found {
3695                    mode: composefs_oci::XattrFiltering::KeepUserXattrs,
3696                    version: FormatVersion::V2,
3697                    digest: expected.clone(),
3698                }),
3699                FindMatching::NotFound => {
3700                    Ok(composefs_oci::BootImageMatch::NotFound(combinations_tried))
3701                }
3702                FindMatching::Errors => Err(anyhow::anyhow!("search blew up")),
3703            };
3704            let mismatch =
3705                UKIDigestMismatch::unsupported_compatibility(expected.clone(), uki_name.into(), 0);
3706            let candidate = candidate(UkiCandidateFormat::V2, expected.clone());
3707            let result = resolve_boot_image_match(mismatch, &candidate, find_matching_result);
3708            if should_succeed {
3709                assert_eq!(result.unwrap(), (FormatVersion::V2, expected.clone()));
3710                continue;
3711            }
3712            let error = result.unwrap_err();
3713            let msg = format!("{error:#}");
3714            for want in &want_substrings {
3715                assert!(msg.contains(want), "expected {msg:?} to contain {want:?}");
3716            }
3717            if matches!(find_matching, FindMatching::NotFound) {
3718                let mismatch = error.downcast_ref::<UKIDigestMismatch>().unwrap();
3719                assert_eq!(mismatch.uki_name(), Some(uki_name));
3720            } else {
3721                assert!(error.downcast_ref::<UKIDigestMismatch>().is_none());
3722            }
3723        }
3724    }
3725
3726    #[test]
3727    fn test_expected_boot_image_ids_keep_recovered_ids_by_format() {
3728        let standard_v1 = Sha512HashValue::EMPTY;
3729        let standard_v2 = other_digest();
3730        let recovered_v1 = Sha512HashValue::from_hex("bb".repeat(64)).unwrap();
3731        let recovered_v2 = Sha512HashValue::from_hex("cc".repeat(64)).unwrap();
3732
3733        let mut ids = expected_boot_image_ids(
3734            Some(standard_v1.clone()),
3735            Some(standard_v2.clone()),
3736            &recovered_v1,
3737            FormatVersion::V1,
3738        );
3739        ids.add(FormatVersion::V2, recovered_v2.clone());
3740
3741        for candidate in [
3742            candidate(UkiCandidateFormat::V1, standard_v1),
3743            candidate(UkiCandidateFormat::V1, recovered_v1),
3744            candidate(UkiCandidateFormat::V2, standard_v2),
3745            candidate(UkiCandidateFormat::V2, recovered_v2),
3746        ] {
3747            assert!(ids.contains(&candidate), "{candidate:?}");
3748        }
3749    }
3750
3751    /// End-to-end candidate reconciliation for the UKI spellings bootc has
3752    /// shipped; `generated` are the boot images this pull produced and
3753    /// `searchable` what the xattr/format search would find.
3754    #[test]
3755    fn test_reconcile_uki_candidates() {
3756        use FormatVersion::{V1, V2};
3757        let v1 = Sha512HashValue::from_hex("11".repeat(64)).unwrap();
3758        let v2 = Sha512HashValue::from_hex("22".repeat(64)).unwrap();
3759        let unknown = Sha512HashValue::from_hex("33".repeat(64)).unwrap();
3760        let dual = format!(
3761            "{} {}",
3762            uki_v1(v1.clone(), false),
3763            uki_v2(v2.clone(), false)
3764        );
3765        let dual_reversed = format!(
3766            "{} {}",
3767            uki_v2(v2.clone(), false),
3768            uki_v1(v1.clone(), false)
3769        );
3770
3771        /// Boot images as `(format, digest)`.
3772        type Images<'a> = &'a [(FormatVersion, &'a Sha512HashValue)];
3773        /// `(name, cmdline, generated, searchable, selected digest or error)`
3774        type Case<'a> = (
3775            &'a str,
3776            String,
3777            Images<'a>,
3778            Images<'a>,
3779            Result<&'a Sha512HashValue, &'a str>,
3780        );
3781        let cases: &[Case] = &[
3782            // bootc 1.16.4+: V1 under the bare key.
3783            (
3784                "legacy V1",
3785                uki_v2(v1.clone(), false),
3786                &[(V1, &v1)],
3787                &[],
3788                Ok(&v1),
3789            ),
3790            (
3791                "legacy V1 recovered",
3792                uki_v2(v1.clone(), false),
3793                &[(V2, &v2)],
3794                &[(V1, &v1)],
3795                Ok(&v1),
3796            ),
3797            // bootc 1.16.3: V2 under the bare key.
3798            (
3799                "legacy V2",
3800                uki_v2(v2.clone(), false),
3801                &[(V1, &v1), (V2, &v2)],
3802                &[],
3803                Ok(&v2),
3804            ),
3805            (
3806                "legacy V2 recovered",
3807                uki_v2(v2.clone(), false),
3808                &[(V1, &v1)],
3809                &[(V2, &v2)],
3810                Ok(&v2),
3811            ),
3812            ("dual", dual.clone(), &[(V1, &v1), (V2, &v2)], &[], Ok(&v1)),
3813            (
3814                "dual reversed",
3815                dual_reversed,
3816                &[(V1, &v1), (V2, &v2)],
3817                &[],
3818                Ok(&v1),
3819            ),
3820            (
3821                "dual recovered V2",
3822                dual,
3823                &[(V1, &v1)],
3824                &[(V2, &v2)],
3825                Ok(&v1),
3826            ),
3827            (
3828                "explicit V2",
3829                uki_explicit_v2(&v2),
3830                &[(V2, &v2)],
3831                &[],
3832                Ok(&v2),
3833            ),
3834            (
3835                "explicit V2 naming V1 image",
3836                uki_explicit_v2(&v1),
3837                &[(V1, &v1)],
3838                &[(V1, &v1)],
3839                Err("candidate format (V2) does not match recovered EROFS format (V1)"),
3840            ),
3841            (
3842                "explicit V1 naming V2 image",
3843                uki_v1(v2.clone(), false),
3844                &[(V2, &v2)],
3845                &[(V2, &v2)],
3846                Err("candidate format (V1) does not match recovered EROFS format (V2)"),
3847            ),
3848            (
3849                "legacy unknown",
3850                uki_v2(unknown.clone(), false),
3851                &[(V1, &v1), (V2, &v2)],
3852                &[],
3853                Err("doesn't match any of"),
3854            ),
3855        ];
3856
3857        for (name, cmdline, generated, searchable, want) in cases {
3858            let mut ids = ExpectedBootImageIds::default();
3859            for (version, digest) in generated.iter() {
3860                ids.add(*version, (*digest).clone());
3861            }
3862            let expected = ExpectedComposefsDigest {
3863                candidates: parse_uki_composefs_candidates(cmdline).unwrap(),
3864                uki_name: "uki.efi".into(),
3865            };
3866            let result = reconcile_uki_candidates(&expected, ids, |wanted| {
3867                Ok(searchable
3868                    .iter()
3869                    .find(|(_, digest)| *digest == wanted)
3870                    .map_or(composefs_oci::BootImageMatch::NotFound(4), |(v, d)| {
3871                        composefs_oci::BootImageMatch::Found {
3872                            mode: composefs_oci::XattrFiltering::KeepUserXattrs,
3873                            version: *v,
3874                            digest: (*d).clone(),
3875                        }
3876                    }))
3877            });
3878            match (result, want) {
3879                (Ok(recovered), Ok(want)) => {
3880                    assert_eq!(&recovered.id, *want, "case {name}");
3881                    // Everything the UKI names must now validate at ESP-write time.
3882                    validate_uki_candidates(&expected.candidates, &recovered.expected_ids, None)
3883                        .unwrap_or_else(|e| panic!("case {name}: {e:#}"));
3884                }
3885                (Err(e), Err(want)) => {
3886                    let msg = format!("{e:#}");
3887                    assert!(msg.contains(want), "case {name}: {msg:?} lacks {want:?}");
3888                }
3889                (result, _) => panic!("case {name}: unexpected {:?}", result.map(|r| r.id)),
3890            }
3891        }
3892    }
3893}