From 91201042f2294e11e59ce122cd03e6ae950215e2 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:50:45 -0700 Subject: [PATCH 1/9] feat(bls): reboot when an entry with boot counting fails to start Add the reboot-on-error loader.conf setting. With auto, which is the default, the machine resets after a failed start only if a boot counter try was just used up and there were tries left, so the entry eventually runs out of tries and the next boot picks another one. yes always resets and no never does. --- crates/bls/src/lib.rs | 71 ++++++++++++++++++++++++++++++++++- crates/bls/src/loader_conf.rs | 46 +++++++++++++++++++++++ crates/boot/src/main.rs | 22 +++++++++-- 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 94148f0..7808da9 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -11,7 +11,7 @@ mod loader_conf; mod pe; mod uki; -pub use loader_conf::{LoaderConf, LoaderTimeout}; +pub use loader_conf::{LoaderConf, LoaderTimeout, RebootOnError}; pub use pe::{ MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, ReadAt, read_pe, read_sections, @@ -958,6 +958,75 @@ mod tests { assert_eq!(conf.default.as_deref(), Some("\"foo")); } + #[test] + fn reboot_on_error_defaults_to_auto() { + assert_eq!(LoaderConf::parse("").reboot_on_error, RebootOnError::Auto); + } + + #[test] + fn reboot_on_error_reads_booleans_and_auto() { + for (value, expected) in [ + ("auto", RebootOnError::Auto), + ("yes", RebootOnError::Yes), + ("true", RebootOnError::Yes), + ("1", RebootOnError::Yes), + ("on", RebootOnError::Yes), + ("y", RebootOnError::Yes), + ("t", RebootOnError::Yes), + ("no", RebootOnError::No), + ("off", RebootOnError::No), + ("0", RebootOnError::No), + ("f", RebootOnError::No), + ("false", RebootOnError::No), + ("n", RebootOnError::No), + ("\"no\"", RebootOnError::No), + ] { + let conf = LoaderConf::parse(&format!("reboot-on-error {value}\n")); + assert_eq!(conf.reboot_on_error, expected, "{value}"); + assert!(conf.warnings.is_empty(), "{value}"); + } + } + + #[test] + fn reboot_on_error_ignores_invalid_values_with_a_warning() { + // The values are case sensitive, as they are in systemd-boot. + for value in ["YES", "Auto", "maybe", ""] { + let conf = LoaderConf::parse(&format!("reboot-on-error {value}\n")); + assert_eq!(conf.reboot_on_error, RebootOnError::Auto, "{value}"); + assert_eq!(conf.warnings.len(), 1, "{value}"); + } + } + + #[test] + fn reboot_on_error_last_value_wins() { + let conf = LoaderConf::parse("reboot-on-error no\nreboot-on-error yes\n"); + assert_eq!(conf.reboot_on_error, RebootOnError::Yes); + } + + #[test] + fn reboot_on_error_decides_from_the_tries_that_were_left() { + assert!(!RebootOnError::Auto.should_reboot(None)); + assert!(!RebootOnError::Auto.should_reboot(Some(0))); + assert!(RebootOnError::Auto.should_reboot(Some(1))); + assert!(RebootOnError::Auto.should_reboot(Some(3))); + assert!(RebootOnError::Yes.should_reboot(None)); + assert!(!RebootOnError::No.should_reboot(Some(3))); + } + + #[test] + fn auto_reboots_are_bounded_by_the_boot_counter() { + // An entry with two tries that keeps failing is rebooted twice, and then stays put. + let mut counter = BootCounter::new(2, 0); + let mut reboots = 0; + for _ in 0..10 { + if RebootOnError::Auto.should_reboot(Some(counter.tries_left)) { + reboots += 1; + } + counter = counter.decremented(); + } + assert_eq!(reboots, 2); + } + #[test] fn loader_conf_empty_has_no_settings() { let conf = LoaderConf::parse(""); diff --git a/crates/bls/src/loader_conf.rs b/crates/bls/src/loader_conf.rs index 27a5001..9244f73 100644 --- a/crates/bls/src/loader_conf.rs +++ b/crates/bls/src/loader_conf.rs @@ -30,6 +30,43 @@ impl LoaderTimeout { } } +/// What to do when the selected entry fails to start, from the `reboot-on-error` setting. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum RebootOnError { + /// Never reboot. + No, + /// Always reboot, which can loop forever if the entry never starts. + Yes, + /// Reboot only if a boot counter try was just used up and tries were left, so that the + /// entry eventually runs out of tries and is not booted any more. + #[default] + Auto, +} + +impl RebootOnError { + /// Parses a `reboot-on-error` value, which is `auto` or a boolean. The words are case + /// sensitive, as in systemd-boot. + fn parse(value: &str) -> Option { + match value { + "auto" => Some(Self::Auto), + "1" | "yes" | "y" | "true" | "t" | "on" => Some(Self::Yes), + "0" | "no" | "n" | "false" | "f" | "off" => Some(Self::No), + _ => None, + } + } + + /// Whether to reboot after the entry failed to start. `consumed_tries_left` is the number of + /// tries that were left before this boot used one up, or None if the entry has no boot + /// counter or the try could not be used up, as then a reboot would not make progress. + pub fn should_reboot(self, consumed_tries_left: Option) -> bool { + match self { + Self::Yes => true, + Self::No => false, + Self::Auto => consumed_tries_left.is_some_and(|left| left > 0), + } + } +} + /// The settings Sprout understands from a `loader.conf` file. /// Reference: #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -41,6 +78,8 @@ pub struct LoaderConf { pub preferred: Option, /// How long to show the boot menu. pub timeout: Option, + /// What to do when the selected entry fails to start. + pub reboot_on_error: RebootOnError, /// Problems found while parsing, such as keys that are not supported. pub warnings: Vec, } @@ -88,6 +127,13 @@ impl LoaderConf { .warnings .push(format!("ignoring invalid loader.conf timeout '{}'", value)), }, + "reboot-on-error" => match RebootOnError::parse(value) { + Some(reboot_on_error) => conf.reboot_on_error = reboot_on_error, + None => conf.warnings.push(format!( + "ignoring invalid loader.conf reboot-on-error '{}'", + value + )), + }, "default" | "preferred" => conf .warnings .push(format!("ignoring loader.conf {} without a value", key)), diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 26973cf..bb392fc 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -27,7 +27,7 @@ use eficore::{ setup, }; use log::{error, info, warn}; -use uefi::{entry, proto::device_path::LoadedImageDevicePath}; +use uefi::{entry, proto::device_path::LoadedImageDevicePath, runtime::ResetType}; use uefi_raw::Status; /// actions: Code that can be configured and executed by Sprout. @@ -112,7 +112,7 @@ fn load_loader_conf(context: &SproutContext) -> Result { } /// Run Sprout, returning an error if one occurs. -fn run() -> Result<()> { +fn run(reboot_on_error: &mut bool) -> Result<()> { // For safety reasons, we will note that Secure Boot is in beta on Sprout. if SecureBoot::enabled().context("unable to determine Secure Boot status")? { warn!("Sprout Secure Boot is in beta. Some functionality may not work as expected."); @@ -644,10 +644,13 @@ fn run() -> Result<()> { // Failing to do so must not prevent the entry from booting. // An entry with no tries left, which was picked by hand, is counted too, so that it can // still be marked as good. + // The tries that were left before this boot used one up, if it did. + let mut consumed_tries_left = None; if let Some(target) = entry.boot_counter() { match target.consume() { Ok(path) => { info!("updated boot counter of entry {}: {}", entry.name(), path); + consumed_tries_left = Some(target.counter.tries_left); // Tell the system where the counter is, so it can mark the boot as good. let path = edera_sprout_bls::boot_path(&path.to_string()); advisory( @@ -663,6 +666,12 @@ fn run() -> Result<()> { } } + // Decide whether a failure to start the entry reboots the machine. This is only decided now, + // as an earlier failure did not use up a try, so a reboot would not make any progress. + *reboot_on_error = loader_conf + .reboot_on_error + .should_reboot(consumed_tries_left); + // Execute all the actions for the selected entry. for action in &entry.declaration().actions { let action = entry.context().stamp(action); @@ -689,7 +698,8 @@ fn efi_main() -> Status { } // Run Sprout, then handle the error. - let result = run(); + let mut reboot_on_error = false; + let result = run(&mut reboot_on_error); if let Err(ref error) = result { // Print an error trace. error!("sprout encountered an error: {}", error); @@ -698,6 +708,12 @@ fn efi_main() -> Status { } // Sleep to allow the user to read the error. uefi::boot::stall(DELAY_ON_ERROR); + + // Reboot when asked to, so that the next boot can use the next try or entry. + if reboot_on_error { + error!("rebooting after a failure to start the boot entry"); + uefi::runtime::reset(ResetType::COLD, Status::SUCCESS, None); + } return Status::ABORTED; } From 7ece731eb41676b5bc0e2842db13f803835010ef Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:50:45 -0700 Subject: [PATCH 2/9] feat(eficore): find the extended boot loader partition on the disk of a partition Look for a filesystem on the same disk whose GPT partition type is the XBOOTLDR type, using the partition info protocol. open_shared moves to the handle module so the graphical menu and the discovery share it. --- crates/boot/src/menu/graphical.rs | 26 ++-------- crates/eficore/src/handle.rs | 21 +++++++- crates/eficore/src/lib.rs | 3 ++ crates/eficore/src/xbootldr.rs | 82 +++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 crates/eficore/src/xbootldr.rs diff --git a/crates/boot/src/menu/graphical.rs b/crates/boot/src/menu/graphical.rs index ebee1d1..e0e22ba 100644 --- a/crates/boot/src/menu/graphical.rs +++ b/crates/boot/src/menu/graphical.rs @@ -8,7 +8,7 @@ use alloc::vec::Vec; use anyhow::{Context, Result, bail}; use core::time::Duration; use eficore::framebuffer::Framebuffer; -use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol}; +use uefi::boot::{OpenProtocolParams, ScopedProtocol}; use uefi::proto::ProtocolPointer; use uefi::proto::console::gop::{BltPixel, GraphicsOutput}; use uefi::proto::console::pointer::{AbsolutePointer, Pointer}; @@ -99,24 +99,6 @@ struct Mouse { absolute: Option>, } -/// Open the protocol `P` on `handle` without taking it from the firmware. -/// An exclusive open makes the firmware disconnect every driver that is using the protocol, -/// and on some firmware that never returns. -fn open_shared(handle: Handle) -> uefi::Result> { - // SAFETY: The protocols opened this way are only used from this thread, and the firmware - // keeps them installed for as long as the menu is open. - unsafe { - uefi::boot::open_protocol::

( - OpenProtocolParams { - handle, - agent: uefi::boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - } -} - /// Open the pointer protocol `P`, preferring the one on the console input handle. /// That one belongs to the console splitter, which combines every device and is never removed. /// Otherwise, the first device that has the protocol is used. @@ -125,10 +107,10 @@ fn open_pointer() -> Option> { let console = uefi::table::system_table_raw() .and_then(|table| unsafe { Handle::from_ptr(table.as_ref().stdin_handle) }); console - .and_then(|handle| open_shared::

(handle).ok()) + .and_then(|handle| eficore::handle::open_shared::

(handle).ok()) .or_else(|| { uefi::boot::get_handle_for_protocol::

() - .and_then(open_shared::

) + .and_then(eficore::handle::open_shared::

) .ok() }) } @@ -407,7 +389,7 @@ impl Screen { for handle in handles { // Opening it exclusively would disconnect the firmware's text console from the // display, and anything printed after the menu may not show up. - let Ok(gop) = open_shared::(handle) else { + let Ok(gop) = eficore::handle::open_shared::(handle) else { continue; }; let (width, height) = gop.current_mode_info().resolution(); diff --git a/crates/eficore/src/handle.rs b/crates/eficore/src/handle.rs index ed0ca1e..c69b616 100644 --- a/crates/eficore/src/handle.rs +++ b/crates/eficore/src/handle.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; -use uefi::boot::SearchType; +use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, SearchType}; +use uefi::proto::ProtocolPointer; use uefi::{Guid, Handle}; use uefi_raw::Status; @@ -24,3 +25,21 @@ pub fn find_handle(protocol: &Guid) -> Result> { } } } + +/// Open the protocol `P` on `handle` without taking it from the firmware. +/// An exclusive open makes the firmware disconnect every driver that is using the protocol, +/// which for a disk or a partition would remove the filesystem Sprout is running from. +pub fn open_shared(handle: Handle) -> uefi::Result> { + // SAFETY: The protocols opened this way are only used from this thread, and the firmware + // keeps them installed for as long as they are open. + unsafe { + uefi::boot::open_protocol::

( + OpenProtocolParams { + handle, + agent: uefi::boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + } +} diff --git a/crates/eficore/src/lib.rs b/crates/eficore/src/lib.rs index 3819196..de0ee96 100644 --- a/crates/eficore/src/lib.rs +++ b/crates/eficore/src/lib.rs @@ -27,6 +27,9 @@ pub mod platform; /// Secure Boot support. pub mod secure; +/// Finding the Extended Boot Loader Partition. +pub mod xbootldr; + /// Support for the shim loader application that enables Secure Boot. pub mod shim; diff --git a/crates/eficore/src/xbootldr.rs b/crates/eficore/src/xbootldr.rs new file mode 100644 index 0000000..04f86bd --- /dev/null +++ b/crates/eficore/src/xbootldr.rs @@ -0,0 +1,82 @@ +use crate::handle::open_shared; +use alloc::boxed::Box; +use alloc::vec::Vec; +use anyhow::{Context, Result}; +use uefi::proto::device_path::{DevicePath, DevicePathNode, DeviceSubType, DeviceType}; +use uefi::proto::media::fs::SimpleFileSystem; +use uefi::proto::media::partition::PartitionInfo; +use uefi::{Guid, guid}; + +/// The GPT partition type of the Extended Boot Loader Partition. +/// Reference: +pub const XBOOTLDR_PARTITION_TYPE: Guid = guid!("bc13c2ff-59e6-4262-a352-b275fd6f7172"); + +/// Whether `node` is the node of a partition on a hard drive. +fn is_hard_drive_node(node: &DevicePathNode) -> bool { + node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE) +} + +/// The device nodes of `path`, without the file path nodes that follow the device. +fn device_nodes(path: &DevicePath) -> Vec<&DevicePathNode> { + path.node_iter() + .filter(|node| node.full_type() != (DeviceType::MEDIA, DeviceSubType::MEDIA_FILE_PATH)) + .collect() +} + +/// Find the Extended Boot Loader Partition on the same disk as the partition at `path`, +/// which is usually the EFI system partition that Sprout was loaded from. Only a partition +/// that has a filesystem the firmware can read is found. Returns the device path of its +/// filesystem, or None if there is no such partition, such as on a disk without a GPT. +pub fn find_xbootldr(path: &DevicePath) -> Result>> { + // The partition is the last hard drive node. The disk is everything before it. + let own = device_nodes(path); + let Some(partition_index) = own.iter().rposition(|node| is_hard_drive_node(node)) else { + // There is no partition, such as when booting from an optical disc or the network. + return Ok(None); + }; + let disk = &own[..partition_index]; + let own_partition = own[partition_index]; + + let handles = + uefi::boot::find_handles::().context("unable to find the filesystems")?; + + let mut found: Option<(u32, Box)> = None; + for handle in handles { + let Ok(candidate_path) = open_shared::(handle) else { + continue; + }; + let nodes = device_nodes(&candidate_path); + + // The candidate must be a partition of the same disk, and not the partition itself. + if nodes.len() != disk.len() + 1 + || nodes[..disk.len()] != *disk + || !is_hard_drive_node(nodes[disk.len()]) + || nodes[disk.len()] == own_partition + { + continue; + } + + // The partition type is only available for a GPT partition. + let Ok(info) = open_shared::(handle) else { + continue; + }; + let Some(entry) = info.gpt_partition_entry() else { + continue; + }; + // The entry is packed, so the field is copied before it is compared. + let partition_type = entry.partition_type_guid.0; + if partition_type != XBOOTLDR_PARTITION_TYPE { + continue; + } + + // There should only be one, but if there are several, use the first partition. + let number = <&uefi::proto::device_path::media::HardDrive>::try_from(nodes[disk.len()]) + .map(|drive| drive.partition_number()) + .unwrap_or(u32::MAX); + if found.as_ref().is_none_or(|(best, _)| number < *best) { + found = Some((number, candidate_path.to_boxed())); + } + } + + Ok(found.map(|(_, path)| path)) +} From f53fa5629d992d9dc627ff91277fb1e39e1f9d16 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:51:09 -0700 Subject: [PATCH 3/9] feat(bls): parse the devicetree key --- crates/bls/src/lib.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 7808da9..6f1c0e9 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -34,6 +34,8 @@ pub struct BlsEntry { pub efi: Option, /// The path to a unified kernel image. pub uki: Option, + /// The path to the flattened devicetree to boot with. + pub devicetree: Option, /// The profile of the unified kernel image to boot. pub profile: Option, /// The architecture the entry is for, such as `x64` or `aa64`. @@ -67,6 +69,7 @@ impl FromStr for BlsEntry { let mut initrd: Vec = Vec::new(); let mut efi: Option = None; let mut uki: Option = None; + let mut devicetree: Option = None; let mut profile: Option = None; let mut architecture: Option = None; let mut sort_key: Option = None; @@ -127,6 +130,11 @@ impl FromStr for BlsEntry { efi = Some(value.trim().to_string()); } + // The path to the flattened devicetree to boot with. + "devicetree" => { + devicetree = Some(value.trim().to_string()); + } + // The path to a unified kernel image. "uki" => { uki = Some(value.trim().to_string()); @@ -169,6 +177,7 @@ impl FromStr for BlsEntry { initrd, efi, uki, + devicetree, profile, architecture, sort_key, @@ -227,6 +236,14 @@ impl BlsEntry { .map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string()) } + /// Fetches the path to the devicetree to boot with, if any. + /// It also converts / to \\ to match EFI path style. + pub fn devicetree_path(&self) -> Option { + self.devicetree + .as_ref() + .map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string()) + } + /// Fetches the paths to the initrds to pass to the kernel, in order. /// It also converts / to \\ to match EFI path style. pub fn initrd_paths(&self) -> Vec { @@ -1139,6 +1156,17 @@ mod tests { assert!(resolve_default_flags(&[]).is_empty()); } + #[test] + fn parse_devicetree_and_convert_the_path() { + let entry: BlsEntry = "linux /vmlinuz\ndevicetree /dtb/board.dtb\n" + .parse() + .unwrap(); + assert_eq!(entry.devicetree.as_deref(), Some("/dtb/board.dtb")); + assert_eq!(entry.devicetree_path().as_deref(), Some("dtb\\board.dtb")); + let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap(); + assert_eq!(entry.devicetree_path(), None); + } + #[test] fn parse_architecture_uki_and_profile() { let entry: BlsEntry = "uki /EFI/Linux/a.efi\narchitecture x64\nprofile 2\n" From fc38773a42171e3d5ca06c6b2958d7afd894cc8c Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:52:21 -0700 Subject: [PATCH 4/9] feat(boot): install the devicetree of a BLS entry Add a devicetree option to the chainload action, set from the devicetree key of a BLS entry. The file is installed as the EFI devicetree table with the memory type that a devicetree has to be in, patched by the fixup protocol when the firmware has one, and the table of the firmware is put back when the image returns or fails to start. It is ignored when Secure Boot is enabled, as it can't be verified, and the DeviceTree loader feature is advertised. --- crates/boot/src/actions/chainload.rs | 46 +++++- crates/boot/src/actions/edera.rs | 1 + crates/boot/src/autoconfigure/bls.rs | 2 + crates/boot/src/autoconfigure/linux.rs | 1 + crates/boot/src/generators/bls.rs | 1 + crates/config/src/actions/chainload.rs | 5 + crates/eficore/src/bootloader_interface.rs | 1 + crates/eficore/src/devicetree.rs | 183 +++++++++++++++++++++ crates/eficore/src/lib.rs | 3 + 9 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 crates/eficore/src/devicetree.rs diff --git a/crates/boot/src/actions/chainload.rs b/crates/boot/src/actions/chainload.rs index 87cf877..5af8c2c 100644 --- a/crates/boot/src/actions/chainload.rs +++ b/crates/boot/src/actions/chainload.rs @@ -1,16 +1,19 @@ use crate::context::SproutContext; use crate::phases::before_handoff; use alloc::boxed::Box; +use alloc::format; use alloc::rc::Rc; use alloc::vec::Vec; use anyhow::{Context, Result, bail}; use edera_sprout_config::actions::chainload::ChainloadConfiguration; use edera_sprout_parsing::{append_initrd, combine_options, empty_is_none}; use eficore::bootloader_interface::BootloaderInterface; +use eficore::devicetree::DeviceTree; use eficore::loader::source::ImageSource; use eficore::loader::{ImageLoadRequest, ImageLoader}; use eficore::media_loader::MediaLoaderHandle; use eficore::media_loader::constants::linux::LINUX_EFI_INITRD_MEDIA_GUID; +use eficore::secure::SecureBoot; use log::warn; use uefi::proto::loaded_image::LoadedImage; use uefi::{CString16, Handle}; @@ -18,19 +21,31 @@ use uefi::{CString16, Handle}; /// Read the initrd at the stamped `path` relative to the sprout image. /// Provides [None] if the path refers to the root of a filesystem rather than a file. pub fn read_initrd(context: &Rc, path: &str) -> Result>> { + read_optional_file(context, path, "initrd") +} + +/// Read the `what` at the stamped `path` relative to the sprout image. +/// Provides [None] if the path refers to the root of a filesystem rather than a file. +fn read_optional_file( + context: &Rc, + path: &str, + what: &str, +) -> Result>> { let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path) - .context("unable to resolve initrd path")?; + .with_context(|| format!("unable to resolve {} path", what))?; - // A path without a file component refers to the root of the filesystem, not an initrd. + // A path without a file component refers to the root of the filesystem, not a file. // This happens when a path template like "$root\\$initrd-0" is stamped with an empty - // initrd value, such as a BLS entry without an initrd or an unused BLS initrd slot. + // value, such as a BLS entry without an initrd or an unused BLS initrd slot. let subpath = eficore::path::device_path_subpath(&resolved.full_path) - .context("unable to get initrd subpath")?; + .with_context(|| format!("unable to get {} subpath", what))?; if subpath.trim_matches('\\').is_empty() { return Ok(None); } - let content = resolved.read_file().context("unable to read initrd")?; + let content = resolved + .read_file() + .with_context(|| format!("unable to read {}", what))?; Ok(Some(content)) } @@ -143,6 +158,24 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat initrd_handle = Some(handle); } + // Install the devicetree, if there is one, for the image to find. It is restored when it is + // dropped, which is once the image has returned or failed to start. A devicetree is not + // verified, so it is not used when Secure Boot is enabled, or when that can't be told. + let mut devicetree = None; + if let Some(path) = empty_is_none( + configuration + .devicetree + .as_ref() + .map(|path| context.stamp(path)), + ) { + if SecureBoot::enabled().unwrap_or(true) { + warn!("ignoring the devicetree, as Secure Boot is enabled"); + } else if let Some(content) = read_optional_file(&context, &path, "devicetree")? { + devicetree = + Some(DeviceTree::install(&content).context("unable to install the devicetree")?); + } + } + // Mark execution of an entry in the bootloader interface. // This is only informational, so it should not prevent booting. if let Err(error) = BootloaderInterface::mark_exec(context.root().timer()) { @@ -175,6 +208,9 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat // Explicitly drop the initrd handle to clarify when it should be unregistered. drop(initrd_handle); + // Explicitly drop the devicetree to clarify when the original is restored. + drop(devicetree); + // Return control to sprout. Ok(()) } diff --git a/crates/boot/src/actions/edera.rs b/crates/boot/src/actions/edera.rs index 641eb9d..0aad623 100644 --- a/crates/boot/src/actions/edera.rs +++ b/crates/boot/src/actions/edera.rs @@ -132,6 +132,7 @@ pub fn edera(context: Rc, configuration: &EderaConfiguration) -> options: vec![], linux_initrd: None, linux_initrd_chain: vec![], + devicetree: None, }, ) .context("unable to chainload to xen"); diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index d19ad52..25e1240 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -125,6 +125,8 @@ pub fn scan( linux_initrd_chain: (0..BLS_INITRD_SLOTS) .map(|slot| format!("{}\\$initrd-{}", root, slot)) .collect(), + // An unset devicetree stamps to the root of the filesystem, which the action skips. + devicetree: Some(format!("{}\\$devicetree", root)), }; // Insert the chainload action into the configuration. diff --git a/crates/boot/src/autoconfigure/linux.rs b/crates/boot/src/autoconfigure/linux.rs index 3b5c7f0..efdbad1 100644 --- a/crates/boot/src/autoconfigure/linux.rs +++ b/crates/boot/src/autoconfigure/linux.rs @@ -237,6 +237,7 @@ pub fn scan( options: vec!["$linux-options".to_string()], linux_initrd: Some("$initrd".to_string()), linux_initrd_chain: vec![], + devicetree: None, }; // Insert the chainload action into the configuration. diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 1dad3b5..57ecd04 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -88,6 +88,7 @@ fn bootable_entry( context.set("options", options); // The command line and kernel version embedded in a unified kernel image. context.set("cmdline", entry.cmdline.clone().unwrap_or_default()); + context.set("devicetree", entry.devicetree_path().unwrap_or_default()); context.set("uname", entry.uname.clone().unwrap_or_default()); // The initrd value keeps the last initrd, which is what it held before // multiple initrds were supported. diff --git a/crates/config/src/actions/chainload.rs b/crates/config/src/actions/chainload.rs index f4984a0..70d89af 100644 --- a/crates/config/src/actions/chainload.rs +++ b/crates/config/src/actions/chainload.rs @@ -23,4 +23,9 @@ pub struct ChainloadConfiguration { /// using the same mechanism as `linux-initrd`. This cannot be used with `linux-initrd`. #[serde(default, rename = "linux-initrd-chain")] pub linux_initrd_chain: Vec, + /// An optional path to a flattened devicetree to give to the image. + /// It is installed as the devicetree of the machine until the image returns. It is not + /// used when Secure Boot is enabled, as it can't be verified. + #[serde(default)] + pub devicetree: Option, } diff --git a/crates/eficore/src/bootloader_interface.rs b/crates/eficore/src/bootloader_interface.rs index c776105..02f9630 100644 --- a/crates/eficore/src/bootloader_interface.rs +++ b/crates/eficore/src/bootloader_interface.rs @@ -67,6 +67,7 @@ impl BootloaderInterface { | LoaderFeatures::SavedEntry | LoaderFeatures::EntryPreferred | LoaderFeatures::SortKey + | LoaderFeatures::DeviceTree } /// Tell the system that Sprout was initialized at the current time. diff --git a/crates/eficore/src/devicetree.rs b/crates/eficore/src/devicetree.rs new file mode 100644 index 0000000..253eb57 --- /dev/null +++ b/crates/eficore/src/devicetree.rs @@ -0,0 +1,183 @@ +use crate::handle::find_handle; +use anyhow::{Context, Result, anyhow, bail}; +use core::ffi::c_void; +use core::ptr::{self, NonNull}; +use log::{info, warn}; +use uefi::boot::{AllocateType, MemoryType, PAGE_SIZE}; +use uefi::proto::unsafe_protocol; +use uefi_raw::{Guid, Status, guid}; + +/// The configuration table that holds the flattened devicetree. +const DTB_TABLE_GUID: Guid = guid!("b1b621d5-f19c-41a5-830b-d9152c69aae0"); + +/// The protocol that firmware such as U-Boot provides to patch a devicetree for the machine. +const DT_FIXUP_GUID: Guid = guid!("e617d64c-fe08-46da-f4dc-bbd5870c7300"); + +/// The magic number at the start of a flattened devicetree. +const FDT_MAGIC: u32 = 0xd00d_feed; + +/// The size of the fixed header of a flattened devicetree. +const FDT_HEADER_SIZE: usize = 7 * 4; + +/// The largest devicetree that is accepted. The Linux stub copies it into a smaller buffer. +const MAX_DTB_SIZE: usize = 32 * 1024 * 1024; + +/// Apply the fixups for the machine to the devicetree. +const DT_APPLY_FIXUPS: u32 = 0x1; + +/// Reserve the memory that the devicetree says to reserve. +const DT_RESERVE_MEMORY: u32 = 0x2; + +/// The protocol that patches a devicetree for the machine. +#[unsafe_protocol(DT_FIXUP_GUID)] +#[repr(C)] +struct DtFixupProtocol { + revision: u64, + fixup: unsafe extern "efiapi" fn( + this: *mut DtFixupProtocol, + fdt: *mut c_void, + buffer_size: *mut usize, + flags: u32, + ) -> Status, +} + +/// A devicetree that is installed for the image that is about to start. When this is dropped, +/// which is when the image returned or failed to start, the devicetree of the firmware is +/// put back and the memory is freed. +pub struct DeviceTree { + /// The pages that hold the devicetree. + pages: NonNull, + /// The number of pages. + count: usize, + /// The devicetree table of the firmware, which is null if there was none. + original: *const c_void, + /// Whether the table points to the pages. + installed: bool, +} + +impl DeviceTree { + /// Check that `dtb` looks like a flattened devicetree that can be installed. + fn validate(dtb: &[u8]) -> Result<()> { + if dtb.len() < FDT_HEADER_SIZE || dtb.len() > MAX_DTB_SIZE { + bail!("the devicetree is {} bytes, which is not valid", dtb.len()); + } + // The header is big endian: the magic number, then the total size. + let magic = u32::from_be_bytes([dtb[0], dtb[1], dtb[2], dtb[3]]); + let total = u32::from_be_bytes([dtb[4], dtb[5], dtb[6], dtb[7]]) as usize; + if magic != FDT_MAGIC { + bail!("the file is not a flattened devicetree"); + } + if total > dtb.len() { + bail!("the devicetree is truncated"); + } + Ok(()) + } + + /// Allocate `count` pages of memory that belongs to the firmware tables, as a devicetree + /// has to be in memory of the type that ACPI tables are in. + fn allocate(count: usize) -> Result> { + uefi::boot::allocate_pages(AllocateType::AnyPages, MemoryType::ACPI_RECLAIM, count) + .context("unable to allocate memory for the devicetree") + } + + /// Install `dtb` as the devicetree of the machine until the returned value is dropped. + pub fn install(dtb: &[u8]) -> Result { + Self::validate(dtb)?; + + // Remember the table of the firmware so it can be put back. + let original = uefi::system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == DTB_TABLE_GUID) + .map(|entry| entry.address) + .unwrap_or(ptr::null()) + }); + + let count = dtb.len().div_ceil(PAGE_SIZE); + let pages = Self::allocate(count)?; + // SAFETY: The pages are at least as large as the devicetree, and don't overlap it. + unsafe { ptr::copy_nonoverlapping(dtb.as_ptr(), pages.as_ptr(), dtb.len()) }; + let mut tree = Self { + pages, + count, + original, + installed: false, + }; + + tree.fixup(dtb)?; + + // SAFETY: The table points to pages that stay allocated until this is dropped, which + // puts the original table back before the pages are freed. + unsafe { + uefi::boot::install_configuration_table(&DTB_TABLE_GUID, tree.pages.as_ptr().cast()) + } + .context("unable to install the devicetree")?; + tree.installed = true; + + info!( + "installed the devicetree at {:p} ({} bytes)", + tree.pages.as_ptr(), + dtb.len() + ); + Ok(tree) + } + + /// Let the firmware patch the devicetree for the machine, if it can. + fn fixup(&mut self, dtb: &[u8]) -> Result<()> { + let Some(handle) = find_handle(&DT_FIXUP_GUID)? else { + return Ok(()); + }; + let mut protocol = uefi::boot::open_protocol_exclusive::(handle) + .context("unable to open the devicetree fixup protocol")?; + let fixup = protocol.fixup; + let this: *mut DtFixupProtocol = &mut *protocol; + let flags = DT_APPLY_FIXUPS | DT_RESERVE_MEMORY; + + // The firmware gets all the memory that was allocated. If that is not enough, it says + // how much it needs, and it is given a copy of the original in a larger allocation. + let mut size = self.count * PAGE_SIZE; + // SAFETY: The pages are valid for the size that is passed. + let mut status = unsafe { fixup(this, self.pages.as_ptr().cast(), &mut size, flags) }; + if status == Status::BUFFER_TOO_SMALL { + let count = size.div_ceil(PAGE_SIZE); + let pages = Self::allocate(count)?; + // SAFETY: The new pages are larger than the devicetree, and don't overlap it. + unsafe { ptr::copy_nonoverlapping(dtb.as_ptr(), pages.as_ptr(), dtb.len()) }; + // SAFETY: The old pages came from the same allocator and nothing refers to them yet. + if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } { + warn!("unable to free the devicetree memory: {}", error); + } + self.pages = pages; + self.count = count; + size = count * PAGE_SIZE; + // SAFETY: The new pages are valid for the size that is passed. + status = unsafe { fixup(this, self.pages.as_ptr().cast(), &mut size, flags) }; + } + if status != Status::SUCCESS { + return Err(anyhow!("the devicetree fixup failed: {:?}", status)); + } + Ok(()) + } +} + +impl Drop for DeviceTree { + fn drop(&mut self) { + if self.installed { + // SAFETY: This puts back the table that the firmware had, which is null if it had + // none, and which removes the table. + let result = + unsafe { uefi::boot::install_configuration_table(&DTB_TABLE_GUID, self.original) }; + if let Err(error) = result { + // The table can still point to the pages, so they must not be freed. + if !(error.status() == Status::NOT_FOUND && self.original.is_null()) { + warn!("unable to restore the devicetree: {}", error); + return; + } + } + } + // SAFETY: Nothing refers to the pages any more. + if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } { + warn!("unable to free the devicetree memory: {}", error); + } + } +} diff --git a/crates/eficore/src/lib.rs b/crates/eficore/src/lib.rs index de0ee96..af7b742 100644 --- a/crates/eficore/src/lib.rs +++ b/crates/eficore/src/lib.rs @@ -6,6 +6,9 @@ #![no_std] extern crate alloc; +/// Installing a devicetree for the image that is started. +pub mod devicetree; + /// EFI handle helpers. pub mod handle; From 8f51342816b75dcf7f5eac59016b396043166edb Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:55:58 -0700 Subject: [PATCH 5/9] fix(eficore): log when the devicetree of the firmware is restored --- crates/eficore/src/devicetree.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/eficore/src/devicetree.rs b/crates/eficore/src/devicetree.rs index 253eb57..38e84d2 100644 --- a/crates/eficore/src/devicetree.rs +++ b/crates/eficore/src/devicetree.rs @@ -175,6 +175,12 @@ impl Drop for DeviceTree { } } } + if self.installed { + info!( + "restored the devicetree table of the firmware ({:p})", + self.original + ); + } // SAFETY: Nothing refers to the pages any more. if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } { warn!("unable to free the devicetree memory: {}", error); From fd000ce34849382a0ce6fef7a384754757dee046 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:59:57 -0700 Subject: [PATCH 6/9] feat(bls): one entry for each profile of a unified kernel image A unified kernel image can have several profiles, each started by a .profile section and made of the sections after it on top of the base. Every profile is an entry. The first has the id of the file, and the others add @ and the identifier or the number of the profile, with a title that names the profile and the profile number as the load options. The profiles share the boot counter of the file, sort in the order they are in the image, and a profile after the first is not picked as the default entry when no default was asked for. The PE reader keeps every section in order, so repeated names and the sections that a profile removes can be told apart, and reads images with more than 96 sections. --- crates/bls/src/lib.rs | 331 ++++++++++++++++++++++++-- crates/bls/src/pe.rs | 56 +++-- crates/bls/src/uki.rs | 156 +++++++++++- crates/boot/src/entries.rs | 32 ++- crates/boot/src/generators/bls.rs | 104 +++++--- crates/boot/src/generators/bls/uki.rs | 31 ++- crates/boot/src/main.rs | 2 +- 7 files changed, 627 insertions(+), 85 deletions(-) diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 6f1c0e9..bbdd3b5 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -13,10 +13,13 @@ mod uki; pub use loader_conf::{LoaderConf, LoaderTimeout, RebootOnError}; pub use pe::{ - MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, ReadAt, read_pe, + MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, PeSection, ReadAt, read_pe, read_sections, }; -pub use uki::{OsRelease, UKI_SECTIONS}; +pub use uki::{ + MAX_PROFILES, OsRelease, ProfileInfo, UKI_SECTIONS, UkiProfile, profile_entry_id, + profile_id_suffix, profile_title, uki_profiles, +}; /// Represents a parsed BLS entry. /// Fields unrelated to Sprout are not included. @@ -282,6 +285,12 @@ impl BlsEntry { self.machine_id.clone() } + /// The number of the profile of a unified kernel image that the entry boots, which is zero + /// for the first profile and for entries that don't have one. + pub fn profile_number(&self) -> u32 { + self.profile.as_deref().and_then(parse_digits).unwrap_or(0) + } + /// Whether the entry has a boot counter with no tries left. pub fn is_bad(&self) -> bool { self.boot_counter.is_some_and(|counter| counter.is_bad()) @@ -347,21 +356,24 @@ impl BootCounter { } } -/// Decides which entries are the default. Each item of `entries` is `(is_default, is_bad)`, -/// and the result says whether each entry is the default afterwards. +/// Decides which entries are the default. Each item of `entries` is +/// `(is_default, is_bad, is_extra_profile)`, and the result says whether each entry is the +/// default afterwards. /// Bad entries, which have no boot counter tries left, are never the default while a good entry -/// exists. If no entry is a default, the first good entry is picked, or the first entry if every -/// entry is bad, so that there is always something to boot. -pub fn resolve_default_flags(entries: &[(bool, bool)]) -> Vec { - let any_good = entries.iter().any(|(_, bad)| !bad); +/// exists. If no entry is a default, the first good entry is picked, which is not a profile of +/// a unified kernel image after the first, unless there is nothing else. If every entry is bad, +/// the first entry is picked, so that there is always something to boot. +pub fn resolve_default_flags(entries: &[(bool, bool, bool)]) -> Vec { + let any_good = entries.iter().any(|(_, bad, _)| !bad); let mut flags: Vec = entries .iter() - .map(|(default, bad)| *default && !(any_good && *bad)) + .map(|(default, bad, _)| *default && !(any_good && *bad)) .collect(); if !flags.iter().any(|default| *default) { let pick = entries .iter() - .position(|(_, bad)| !bad) + .position(|(_, bad, extra)| !bad && !extra) + .or_else(|| entries.iter().position(|(_, bad, _)| !bad)) .or_else(|| (!entries.is_empty()).then_some(0)); if let Some(index) = pick { flags[index] = true; @@ -429,7 +441,9 @@ pub fn sort_bls(a_bls: &BlsEntry, a_name: &str, b_bls: &BlsEntry, b_name: &str) .then_with(|| { compare_versions_optional(a_bls.version().as_deref(), b_bls.version().as_deref()) .reverse() - }), + }) + // The profiles of one image are in the order they are in the image. + .then_with(|| a_bls.profile_number().cmp(&b_bls.profile_number())), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => Ordering::Equal, @@ -443,6 +457,7 @@ pub fn sort_bls(a_bls: &BlsEntry, a_name: &str, b_bls: &BlsEntry, b_name: &str) // the boot counter: more tries left first, then fewer tries done. ordering .then_with(|| compare_versions(a_name, b_name).reverse()) + .then_with(|| a_bls.profile_number().cmp(&b_bls.profile_number())) .then_with(|| { let tries = |entry: &BlsEntry| { entry @@ -786,7 +801,7 @@ mod tests { assert_eq!(entry.version.as_deref(), Some("40.1")); assert_eq!(entry.sort_key.as_deref(), Some("coreos")); assert_eq!(entry.uname.as_deref(), Some("6.5.0")); - assert_eq!(entry.efi.as_deref(), Some("/EFI/Linux/fedora.efi")); + assert_eq!(entry.uki.as_deref(), Some("/EFI/Linux/fedora.efi")); assert!(entry.options.is_none()); assert!(entry.is_valid()); } @@ -875,12 +890,284 @@ mod tests { assert_eq!(strip_extension(".efi", ".efi"), Some(("", ".efi"))); } + fn text_section(name: &str, text: &str) -> PeSection { + PeSection { + name: name.to_string(), + data: Some(text.as_bytes().to_vec()), + } + } + + fn cmdline(profile: &UkiProfile) -> Option<&[u8]> { + profile.sections.get(".cmdline").map(|data| data.as_slice()) + } + + #[test] + fn profiles_without_a_profile_section_are_a_single_base() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".cmdline", "q"), + ]; + let profiles = uki_profiles(§ions); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].index, None); + assert_eq!(cmdline(&profiles[0]), Some(&b"q"[..])); + assert!(profiles[0].sections.contains_key(".osrel")); + } + + #[test] + fn profiles_follow_the_spec_layout() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".cmdline", "base"), + text_section(".profile", "ID=p0"), + text_section(".profile", "ID=p1"), + text_section(".cmdline", "one"), + text_section(".profile", "ID=p2\nTITLE=Two"), + text_section(".cmdline", "two"), + ]; + let profiles = uki_profiles(§ions); + assert_eq!(profiles.len(), 3); + assert_eq!( + profiles.iter().map(|p| p.index).collect::>(), + [Some(0), Some(1), Some(2)] + ); + // The first profile has no sections of its own, so it uses the base. + assert_eq!(cmdline(&profiles[0]), Some(&b"base"[..])); + assert_eq!(cmdline(&profiles[1]), Some(&b"one"[..])); + assert_eq!(cmdline(&profiles[2]), Some(&b"two"[..])); + assert!(profiles.iter().all(|p| p.sections.contains_key(".osrel"))); + assert_eq!(profiles[1].info.id.as_deref(), Some("p1")); + assert_eq!(profiles[2].info.title.as_deref(), Some("Two")); + } + + #[test] + fn the_first_section_of_a_name_wins_within_a_range() { + let sections = [ + text_section(".profile", "ID=p"), + text_section(".cmdline", "a"), + text_section(".cmdline", "b"), + ]; + assert_eq!(cmdline(&uki_profiles(§ions)[0]), Some(&b"a"[..])); + } + + #[test] + fn an_empty_or_oversized_profile_section_masks_the_base() { + let sections = [ + text_section(".cmdline", "base"), + text_section(".profile", "ID=empty"), + text_section(".cmdline", ""), + text_section(".profile", "ID=big"), + PeSection { + name: ".cmdline".to_string(), + data: None, + }, + ]; + let profiles = uki_profiles(§ions); + assert_eq!(cmdline(&profiles[0]), None); + assert_eq!(cmdline(&profiles[1]), None); + } + + #[test] + fn a_profile_can_override_the_os_release() { + let sections = [ + text_section(".osrel", "ID=base\nPRETTY_NAME=Base"), + text_section(".profile", "ID=p"), + text_section(".osrel", "ID=other\nPRETTY_NAME=Other"), + ]; + let profiles = uki_profiles(§ions); + let entry = BlsEntry::from_uki_profile(&profiles[0], "/a.efi"); + assert_eq!(entry.title.as_deref(), Some("Other")); + } + + #[test] + fn a_leading_profile_section_means_an_empty_base() { + let sections = [ + text_section(".profile", "ID=p"), + text_section(".osrel", "ID=a"), + ]; + let profiles = uki_profiles(§ions); + assert_eq!(profiles.len(), 1); + assert!(profiles[0].sections.contains_key(".osrel")); + } + + #[test] + fn profiles_are_capped() { + let sections: Vec = (0..300) + .map(|index| text_section(".profile", &format!("ID=p{index}"))) + .collect(); + assert_eq!(uki_profiles(§ions).len(), MAX_PROFILES); + } + + #[test] + fn profile_info_parses_like_os_release() { + let info = ProfileInfo::parse("# c\nID=\"one\"\nTITLE='Hello World'\nID=two\n"); + assert_eq!(info.id.as_deref(), Some("two")); + assert_eq!(info.title.as_deref(), Some("Hello World")); + let info = ProfileInfo::parse("ID=\nTITLE=\n"); + assert_eq!(info, ProfileInfo::default()); + } + + #[test] + fn profile_ids_and_titles_follow_systemd_boot() { + let named = ProfileInfo { + id: Some("factory".to_string()), + title: None, + }; + let titled = ProfileInfo { + id: Some("factory".to_string()), + title: Some("Factory Reset".to_string()), + }; + let bare = ProfileInfo::default(); + + // The first profile has no suffix, the others have the id or their number. + assert_eq!(profile_id_suffix(0, &named), None); + assert_eq!(profile_id_suffix(1, &named).as_deref(), Some("factory")); + assert_eq!(profile_id_suffix(2, &bare).as_deref(), Some("2")); + + assert_eq!(profile_title("Fedora", 0, &bare), "Fedora"); + assert_eq!( + profile_title("Fedora", 0, &titled), + "Fedora (Factory Reset)" + ); + assert_eq!(profile_title("Fedora", 1, &named), "Fedora (factory)"); + assert_eq!(profile_title("Fedora", 1, &bare), "Fedora (Profile #2)"); + assert_eq!( + profile_title("Fedora", 3, &titled), + "Fedora (Factory Reset)" + ); + } + + #[test] + fn profile_entry_ids_only_keep_the_case_of_the_profile() { + assert_eq!(profile_entry_id("Fedora.EFI", None), "fedora.efi"); + assert_eq!( + profile_entry_id("Fedora.EFI", Some("Factory")), + "fedora.efi@Factory" + ); + } + + #[test] + fn profile_entries_select_the_profile_with_load_options() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".profile", "ID=p0"), + text_section(".profile", "ID=p1"), + ]; + let profiles = uki_profiles(§ions); + let first = BlsEntry::from_uki_profile(&profiles[0], "/a.efi"); + assert_eq!(first.chainload_options(), None); + let second = BlsEntry::from_uki_profile(&profiles[1], "/a.efi"); + assert_eq!(second.chainload_options().as_deref(), Some("@1")); + assert_eq!(second.chainload_path().as_deref(), Some("a.efi")); + assert!(second.initrd_paths().is_empty()); + } + + #[test] + fn profiles_of_one_image_sort_by_number() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".profile", "ID=p0"), + text_section(".profile", "ID=p1"), + ]; + let profiles = uki_profiles(§ions); + let first = BlsEntry::from_uki_profile(&profiles[0], "/a.efi"); + let second = BlsEntry::from_uki_profile(&profiles[1], "/a.efi"); + assert_eq!(sort_bls(&first, "a.efi", &second, "a.efi"), Ordering::Less); + assert_eq!( + sort_bls(&second, "a.efi", &first, "a.efi"), + Ordering::Greater + ); + } + + #[test] + fn a_newer_version_sorts_before_the_profiles_of_an_older_one() { + let new = BlsEntry { + version: Some("2".to_string()), + profile: Some("1".to_string()), + ..sort_entry(Some("linux"), None, Some("2")) + }; + let old = BlsEntry { + version: Some("1".to_string()), + ..sort_entry(Some("linux"), None, Some("1")) + }; + assert_eq!(sort_bls(&new, "a", &old, "a"), Ordering::Less); + } + + #[test] + fn default_flags_never_fall_back_to_extra_profiles() { + // Each item is (is_default, is_bad, is_extra_profile). + let flags = resolve_default_flags(&[(false, false, true), (false, false, false)]); + assert_eq!(flags, [false, true]); + // An extra profile can still be a default that was asked for. + let flags = resolve_default_flags(&[(true, false, true), (false, false, false)]); + assert_eq!(flags, [true, false]); + // With nothing else, an extra profile is better than nothing. + let flags = resolve_default_flags(&[(false, false, true)]); + assert_eq!(flags, [true]); + } + + #[test] + fn pe_keeps_duplicate_sections_in_order() { + let image = pe_image(&[ + (".osrel", b"ID=a"), + (".profile", b"ID=one"), + (".cmdline", b"x"), + (".profile", b"ID=two"), + (".cmdline", b"y"), + (".text", b"code"), + ]); + let pe = read_pe(&mut image.as_slice(), &[".osrel", ".profile", ".cmdline"]).unwrap(); + let names: Vec<_> = pe + .sections + .iter() + .map(|section| section.name.as_str()) + .collect(); + assert_eq!( + names, + [".osrel", ".profile", ".cmdline", ".profile", ".cmdline"] + ); + assert_eq!(pe.sections[3].data.as_deref(), Some(&b"ID=two"[..])); + } + + #[test] + fn pe_keeps_oversized_sections_without_data() { + let big = vec![b'a'; MAX_SECTION_SIZE as usize + 1]; + let image = pe_image(&[(".cmdline", &big), (".osrel", b"ID=x")]); + let pe = read_pe(&mut image.as_slice(), &[".cmdline", ".osrel"]).unwrap(); + assert_eq!(pe.sections[0].name, ".cmdline"); + assert_eq!(pe.sections[0].data, None); + assert_eq!(pe.sections[1].data.as_deref(), Some(&b"ID=x"[..])); + } + + #[test] + fn pe_accepts_many_sections_and_rejects_too_many() { + let many: Vec<(&str, &[u8])> = (0..300).map(|_| (".dtbauto", &b"d"[..])).collect(); + let image = pe_image(&many); + let pe = read_pe(&mut image.as_slice(), &[".dtbauto"]).unwrap(); + assert_eq!(pe.sections.len(), 300); + + let mut image = pe_image(&[(".osrel", b"ID=x")]); + image[0x86..0x88].copy_from_slice(&5000u16.to_le_bytes()); + assert!(read_pe(&mut image.as_slice(), &[".osrel"]).is_err()); + } + + #[test] + fn pe_zero_virtual_size_means_an_empty_section() { + let mut image = pe_image(&[(".cmdline", b"quiet")]); + // The virtual size is at offset 8 of the first section table entry. + let entry = 0x80 + 24; + image[entry + 8..entry + 12].copy_from_slice(&0u32.to_le_bytes()); + let pe = read_pe(&mut image.as_slice(), &[".cmdline"]).unwrap(); + assert_eq!(pe.sections[0].data.as_deref(), Some(&b""[..])); + } + #[test] fn pe_reports_the_machine_type() { let image = pe_image(&[(".osrel", b"ID=x")]); let pe = read_pe(&mut image.as_slice(), &[".osrel"]).unwrap(); assert_eq!(pe.machine, PE_MACHINE_X86_64); - assert!(pe.sections.contains_key(".osrel")); + assert_eq!(pe.sections.len(), 1); + assert_eq!(pe.sections[0].name, ".osrel"); } #[test] @@ -1122,19 +1409,27 @@ mod tests { #[test] fn default_flags_keep_a_good_default() { // The second entry is bad and also matched the default pattern. - let flags = resolve_default_flags(&[(true, false), (true, true), (false, false)]); + let flags = resolve_default_flags(&[ + (true, false, false), + (true, true, false), + (false, false, false), + ]); assert_eq!(flags, [true, false, false]); } #[test] fn default_flags_replace_a_bad_default_with_the_first_good_entry() { - let flags = resolve_default_flags(&[(false, false), (true, true), (false, false)]); + let flags = resolve_default_flags(&[ + (false, false, false), + (true, true, false), + (false, false, false), + ]); assert_eq!(flags, [true, false, false]); } #[test] fn default_flags_fallback_skips_bad_entries() { - let flags = resolve_default_flags(&[(false, true), (false, false)]); + let flags = resolve_default_flags(&[(false, true, false), (false, false, false)]); assert_eq!(flags, [false, true]); } @@ -1142,11 +1437,11 @@ mod tests { fn default_flags_when_every_entry_is_bad() { // A configured default stays, otherwise the first entry is the default. assert_eq!( - resolve_default_flags(&[(false, true), (true, true)]), + resolve_default_flags(&[(false, true, false), (true, true, false)]), [false, true] ); assert_eq!( - resolve_default_flags(&[(false, true), (false, true)]), + resolve_default_flags(&[(false, true, false), (false, true, false)]), [true, false] ); } diff --git a/crates/bls/src/pe.rs b/crates/bls/src/pe.rs index a9f89d6..3766c21 100644 --- a/crates/bls/src/pe.rs +++ b/crates/bls/src/pe.rs @@ -14,8 +14,9 @@ pub const PE_MACHINE_X86_64: u16 = 0x8664; /// The machine type of an aarch64 PE image. pub const PE_MACHINE_AARCH64: u16 = 0xaa64; -/// The most sections a PE image can have. -const MAX_SECTIONS: usize = 96; +/// The most sections that are read from a PE image. A unified kernel image with many profiles +/// has many more sections than the 96 that Windows allows, and the table is still small. +const MAX_SECTIONS: usize = 1024; /// The size of a section table entry. const SECTION_ENTRY_SIZE: usize = 40; @@ -59,22 +60,40 @@ fn u32_at(bytes: &[u8], offset: usize) -> u32 { pub struct PeImage { /// The machine type from the COFF header, such as [PE_MACHINE_X86_64]. pub machine: u16, - /// The contents of the wanted sections that were found. - pub sections: BTreeMap>, + /// The wanted sections, in the order of the section table, including repeated names. + pub sections: Vec, } -/// Reads the sections named in `wanted` from the PE image in `reader`. -/// See [read_pe] for the details. +/// A wanted section of a PE image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeSection { + /// The name of the section, such as `.osrel`. + pub name: String, + /// The contents of the section, or None if it is larger than [MAX_SECTION_SIZE]. A section + /// without contents is still there, as it takes the place of a section with the same name. + pub data: Option>, +} + +/// Reads the sections named in `wanted` from the PE image in `reader`, keeping the first +/// section of each name. See [read_pe] for the details. pub fn read_sections( reader: &mut R, wanted: &[&str], ) -> Result>> { - Ok(read_pe(reader, wanted)?.sections) + let mut sections = BTreeMap::new(); + for section in read_pe(reader, wanted)?.sections { + sections.entry(section.name).or_insert(section.data); + } + // A section that was too large is not available. + Ok(sections + .into_iter() + .filter_map(|(name, data)| data.map(|data| (name, data))) + .collect()) } /// Reads the machine type and the sections named in `wanted` from the PE image in `reader`. /// Only the headers and the wanted sections are read, so this is cheap on large images. -/// A wanted section that is missing, or larger than [MAX_SECTION_SIZE], is left out. +/// A wanted section that is larger than [MAX_SECTION_SIZE] is listed without its contents. /// An image that isn't a valid PE file is an error. pub fn read_pe(reader: &mut R, wanted: &[&str]) -> Result { // The DOS header starts with "MZ" and holds the offset of the PE header at 0x3c. @@ -102,14 +121,14 @@ pub fn read_pe(reader: &mut R, wanted: &[&str]) -> Result { let mut table = vec![0u8; section_count * SECTION_ENTRY_SIZE]; reader.read_at(pe_offset + 24 + optional_header_size, &mut table)?; - let mut sections = BTreeMap::new(); + let mut sections = Vec::new(); for entry in table.as_chunks::().0 { // The name is up to eight bytes, padded with NUL. let name_len = entry[..8].iter().position(|b| *b == 0).unwrap_or(8); let Ok(name) = core::str::from_utf8(&entry[..name_len]) else { continue; }; - if !wanted.contains(&name) || sections.contains_key(name) { + if !wanted.contains(&name) { continue; } @@ -117,18 +136,23 @@ pub fn read_pe(reader: &mut R, wanted: &[&str]) -> Result { let virtual_size = u64::from(u32_at(entry, 8)); let raw_size = u64::from(u32_at(entry, 16)); let raw_offset = u64::from(u32_at(entry, 20)); - let size = if virtual_size == 0 { - raw_size - } else { - virtual_size.min(raw_size) - }; + // A virtual size of zero makes the section empty, which is how a profile removes a section + // of the base image. + let size = virtual_size.min(raw_size); if size > MAX_SECTION_SIZE { + sections.push(PeSection { + name: name.to_string(), + data: None, + }); continue; } let mut data = vec![0u8; size as usize]; reader.read_at(raw_offset, &mut data)?; - sections.insert(name.to_string(), data); + sections.push(PeSection { + name: name.to_string(), + data: Some(data), + }); } Ok(PeImage { machine, sections }) diff --git a/crates/bls/src/uki.rs b/crates/bls/src/uki.rs index e8ec04d..acf4b8a 100644 --- a/crates/bls/src/uki.rs +++ b/crates/bls/src/uki.rs @@ -1,10 +1,14 @@ -use crate::BlsEntry; +use crate::{BlsEntry, PeSection}; use alloc::collections::BTreeMap; +use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; /// The PE sections of a unified kernel image that Sprout reads. -pub const UKI_SECTIONS: [&str; 3] = [".osrel", ".cmdline", ".uname"]; +pub const UKI_SECTIONS: [&str; 4] = [".osrel", ".cmdline", ".uname", ".profile"]; + +/// The most profiles that are read from a unified kernel image. +pub const MAX_PROFILES: usize = 256; /// A parsed os-release file, such as the `.osrel` section of a unified kernel image. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -68,12 +72,26 @@ fn section_text(sections: &BTreeMap>, name: &str) -> Option` in its load options. + pub fn from_uki_profile(profile: &UkiProfile, uki_path: &str) -> Self { + let mut entry = Self::from_uki(&profile.sections, uki_path); + if let Some(index) = profile.index { + entry.profile = (index > 0).then(|| index.to_string()); + entry.title = entry + .title + .take() + .map(|title| profile_title(&title, index, &profile.info)); + } + entry + } + + /// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`. /// The title comes from `PRETTY_NAME`, then `ID`. The version comes from `IMAGE_VERSION`, /// `VERSION_ID`, `BUILD_ID`, then the `.uname` section. The sort key comes from `IMAGE_ID`, /// then `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the /// image reads its own command line. - pub fn from_uki(sections: &BTreeMap>, efi_path: &str) -> Self { + pub fn from_uki(sections: &BTreeMap>, uki_path: &str) -> Self { let os_release = section_text(sections, ".osrel") .map(|text| OsRelease::parse(&text)) .unwrap_or_default(); @@ -89,10 +107,138 @@ impl BlsEntry { title: first(&["PRETTY_NAME", "ID"]), version: first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]).or_else(|| uname.clone()), sort_key: first(&["IMAGE_ID", "ID"]), - efi: Some(efi_path.to_string()), + uki: Some(uki_path.to_string()), cmdline: section_text(sections, ".cmdline"), uname, ..Self::default() } } } + +/// The metadata in the `.profile` section of a profile of a unified kernel image. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ProfileInfo { + /// A short identifier of the profile. + pub id: Option, + /// A title for people to read. + pub title: Option, +} + +impl ProfileInfo { + /// Parses the text of a `.profile` section, which is in the format of an os-release file. + /// A key without a value is treated as missing. + pub fn parse(text: &str) -> Self { + let os_release = OsRelease::parse(text); + let get = |key: &str| { + os_release + .get(key) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }; + Self { + id: get("ID"), + title: get("TITLE"), + } + } +} + +/// The sections that one profile of a unified kernel image boots with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UkiProfile { + /// The number of the profile, or None if the image has no `.profile` sections. + pub index: Option, + /// The metadata of the profile. + pub info: ProfileInfo, + /// The sections of the profile on top of those of the base image. A section that is empty + /// or too large to read, which is how a profile removes a section, is not here. + pub sections: BTreeMap>, +} + +/// Keeps the first section of each name in `range`. +fn first_of_each(range: &[PeSection]) -> BTreeMap>> { + let mut sections = BTreeMap::new(); + for section in range { + sections + .entry(section.name.clone()) + .or_insert_with(|| section.data.clone()); + } + sections +} + +/// Splits the `sections` of a unified kernel image, in the order of the section table, into the +/// profiles of the image. Each `.profile` section starts a profile that goes on until the next +/// one, and the sections before the first are the base that every profile starts from. +/// A section of a profile takes the place of the section of the same name in the base. +/// An image without `.profile` sections has one profile, which is the base. +pub fn uki_profiles(sections: &[PeSection]) -> Vec { + // Sections that are empty or too large are not available. + let usable = |map: BTreeMap>>| -> BTreeMap> { + map.into_iter() + .filter_map(|(name, data)| { + data.filter(|data| !data.is_empty()) + .map(|data| (name, data)) + }) + .collect() + }; + + let starts: Vec = sections + .iter() + .enumerate() + .filter(|(_, section)| section.name == ".profile") + .map(|(index, _)| index) + .collect(); + let Some(&first) = starts.first() else { + return alloc::vec![UkiProfile { + index: None, + info: ProfileInfo::default(), + sections: usable(first_of_each(sections)), + }]; + }; + + let base = first_of_each(§ions[..first]); + starts + .iter() + .take(MAX_PROFILES) + .enumerate() + .map(|(index, start)| { + let end = starts.get(index + 1).copied().unwrap_or(sections.len()); + let range = §ions[*start..end]; + let mut merged = base.clone(); + merged.extend(first_of_each(range)); + UkiProfile { + index: Some(index as u32), + info: range[0] + .data + .as_ref() + .map(|data| ProfileInfo::parse(&String::from_utf8_lossy(data))) + .unwrap_or_default(), + sections: usable(merged), + } + }) + .collect() +} + +/// The part of the id of an entry that comes after the `@`, which names the profile. +/// The first profile has none, and the others have their identifier or their number. +pub fn profile_id_suffix(index: u32, info: &ProfileInfo) -> Option { + (index > 0).then(|| info.id.clone().unwrap_or_else(|| index.to_string())) +} + +/// The title of the entry for a profile, given the `name` of the image. +pub fn profile_title(name: &str, index: u32, info: &ProfileInfo) -> String { + match (&info.title, &info.id) { + (Some(title), _) => format!("{} ({})", name, title), + (None, Some(id)) if index > 0 => format!("{} ({})", name, id), + (None, None) if index > 0 => format!("{} (Profile #{})", name, index + 1), + _ => name.to_string(), + } +} + +/// The id of the entry for a profile of the image with the file name `file_id`, such as +/// `fedora.efi`. The file name is in lower case, as in systemd-boot, and the profile is not. +pub fn profile_entry_id(file_id: &str, suffix: Option<&str>) -> String { + match suffix { + Some(suffix) => format!("{}@{}", file_id.to_lowercase(), suffix), + None => file_id.to_lowercase(), + } +} diff --git a/crates/boot/src/entries.rs b/crates/boot/src/entries.rs index 56d0e08..b0d4923 100644 --- a/crates/boot/src/entries.rs +++ b/crates/boot/src/entries.rs @@ -3,6 +3,7 @@ use crate::context::SproutContext; use alloc::format; use alloc::rc::Rc; use alloc::string::{String, ToString}; +use edera_sprout_bls::profile_entry_id; use edera_sprout_config::entries::EntryDeclaration; use edera_sprout_parsing::{fnmatch_ignore_case, glob_match}; @@ -18,6 +19,7 @@ pub struct BootableEntry { sort_key: Option, boot_counter: Option, id_suffix: Option, + id_profile: Option, } impl BootableEntry { @@ -38,6 +40,7 @@ impl BootableEntry { sort_key: None, boot_counter: None, id_suffix: None, + id_profile: None, } } @@ -51,11 +54,38 @@ impl BootableEntry { /// `fedora.conf`, in lower case. Any other entry has its name as the id. pub fn id(&self) -> String { match self.id_suffix { - Some(ref suffix) => format!("{}{}", self.name, suffix).to_lowercase(), + Some(ref suffix) => { + let file = format!("{}{}", self.sort_name(), suffix); + profile_entry_id(&file, self.id_profile.as_deref()) + } None => self.name.clone(), } } + /// Fetch the name of the entry without the profile of a unified kernel image, which is + /// the same for every profile of the same image. + pub fn sort_name(&self) -> &str { + self.id_profile + .as_deref() + .and_then(|profile| { + self.name + .strip_suffix(profile) + .and_then(|name| name.strip_suffix('@')) + }) + .unwrap_or(&self.name) + } + + /// Fetch whether the entry is a profile of a unified kernel image after the first, which + /// is never picked as the default entry unless it was asked for. + pub fn is_extra_profile(&self) -> bool { + self.id_profile.is_some() + } + + /// Set the profile of a unified kernel image that is part of the id of this entry. + pub fn set_id_profile(&mut self, profile: &str) { + self.id_profile = Some(profile.to_string()); + } + /// Set the file extension that is part of the id of this entry, such as `.conf`. pub fn set_id_suffix(&mut self, suffix: &str) { self.id_suffix = Some(suffix.to_string()); diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 57ecd04..2a47ef4 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -9,7 +9,9 @@ use alloc::{ }; use anyhow::{Context, Result}; use core::{cmp::Ordering, str::FromStr}; -use edera_sprout_bls::{BlsEntry, BootCounter, is_reserved_entry_name, sort_bls, strip_extension}; +use edera_sprout_bls::{ + BlsEntry, BootCounter, is_reserved_entry_name, profile_id_suffix, sort_bls, strip_extension, +}; use edera_sprout_config::generators::bls::BlsConfiguration; use log::{info, warn}; use uefi::{ @@ -54,7 +56,7 @@ fn quirk_initrd_remove_tuned(paths: Vec) -> Vec { fn sort_entries(a: &(BlsEntry, BootableEntry), b: &(BlsEntry, BootableEntry)) -> Ordering { let (a_bls, a_boot) = a; let (b_bls, b_boot) = b; - sort_bls(a_bls, a_boot.name(), b_bls, b_boot.name()) + sort_bls(a_bls, a_boot.sort_name(), b_bls, b_boot.sort_name()) } /// Produces the bootable entry for the BLS `entry` with the id `name` and the `initrds`. @@ -297,8 +299,7 @@ fn generate_type2( let mut found: Vec<(BlsEntry, BootableEntry)> = Vec::new(); for image in uki::scan(resolved.filesystem_handle, &directory_name)? { - // Only unified kernel images are entries. Images built for another architecture, and - // other EFI programs that have no os-release section, are skipped. + // Only unified kernel images that are built for this machine are entries. if image.machine != PE_MACHINE { info!( "skipping {} as it is built for another architecture", @@ -306,13 +307,6 @@ fn generate_type2( ); continue; } - if !image.has_osrel { - info!( - "skipping {} as it has no .osrel section, so it is not a unified kernel image", - image.file_name - ); - continue; - } // The id is the file name without the extension and the boot counter. let file_name = image.file_name; @@ -329,37 +323,71 @@ fn generate_type2( } let id = id.to_string(); - let mut entry = image.entry; - entry.boot_counter = boot_counter; + // Every profile of the image is an entry. They share the file, and so the boot counter. + let mut suffixes: Vec = Vec::new(); + for profile in image.profiles { + // A profile without an os-release section is not a unified kernel image, such as + // other EFI programs. + if !profile.has_osrel { + info!( + "skipping {} as it has no .osrel section, so it is not a unified kernel image", + file_name + ); + continue; + } - let mut boot = bootable_entry(context, bls, &id, &entry, &[]); - boot.set_id_suffix(&extension); + // The first profile has the id of the file, and the others name their profile. + // A profile that repeats the identifier of an earlier one is told apart by its number. + let index = profile.index.unwrap_or(0); + let mut suffix = profile_id_suffix(index, &profile.info); + if let Some(ref name) = suffix + && suffixes.contains(name) + { + suffix = Some(index.to_string()); + } + if let Some(ref name) = suffix { + suffixes.push(name.clone()); + } + let name = match suffix { + Some(ref suffix) => format!("{}@{}", id, suffix), + None => id.clone(), + }; + + let mut entry = profile.entry; + entry.boot_counter = boot_counter; + + let mut boot = bootable_entry(context, bls, &name, &entry, &[]); + boot.set_id_suffix(&extension); + if let Some(ref suffix) = suffix { + boot.set_id_profile(suffix); + } - // An image with the same id as another entry, such as a leftover copy with another boot - // counter, would be ambiguous. Type 1 and type 2 entries have different ids. - if entries - .iter() - .chain(found.iter()) - .any(|(_, other)| other.id() == boot.id()) - { - warn!( - "unified kernel image {} has the same id as another entry, skipping", - file_name - ); - continue; - } + // An image with the same id as another entry, such as a leftover copy with another + // boot counter, would be ambiguous. Type 1 and type 2 entries have different ids. + if entries + .iter() + .chain(found.iter()) + .any(|(_, other)| other.id() == boot.id()) + { + warn!( + "unified kernel image {} has the same id as another entry, skipping", + file_name + ); + continue; + } - if let Some(counter) = boot_counter { - boot.set_boot_counter(BootCounterTarget { - counter, - filesystem: resolved.filesystem_handle, - directory: PathBuf::from(directory.clone()), - id, - file_name, - extension, - }); + if let Some(counter) = boot_counter { + boot.set_boot_counter(BootCounterTarget { + counter, + filesystem: resolved.filesystem_handle, + directory: PathBuf::from(directory.clone()), + id: id.clone(), + file_name: file_name.clone(), + extension: extension.clone(), + }); + } + found.push((entry, boot)); } - found.push((entry, boot)); } Ok(found) } diff --git a/crates/boot/src/generators/bls/uki.rs b/crates/boot/src/generators/bls/uki.rs index 40091e6..942fd46 100644 --- a/crates/boot/src/generators/bls/uki.rs +++ b/crates/boot/src/generators/bls/uki.rs @@ -4,7 +4,9 @@ use alloc::{ vec::Vec, }; use anyhow::{Context, Result, anyhow, bail}; -use edera_sprout_bls::{BlsEntry, PeImage, ReadAt, UKI_SECTIONS, read_pe, strip_extension}; +use edera_sprout_bls::{ + BlsEntry, PeImage, ProfileInfo, ReadAt, UKI_SECTIONS, read_pe, strip_extension, uki_profiles, +}; use log::warn; use uefi::{ CString16, Handle, Status, @@ -18,9 +20,19 @@ pub struct UkiFile { pub file_name: String, /// The machine type of the image. pub machine: u16, - /// Whether the image has an `.osrel` section, which unified kernel images always have. + /// The profiles of the image. An image without profiles has one. + pub profiles: Vec, +} + +/// One profile of a unified kernel image. +pub struct UkiProfileEntry { + /// The number of the profile, or None if the image has no profiles. + pub index: Option, + /// The metadata of the profile. + pub info: ProfileInfo, + /// Whether the profile has an `.osrel` section, which unified kernel images always have. pub has_osrel: bool, - /// The entry made from the sections of the image. + /// The entry made from the sections of the profile. pub entry: BlsEntry, } @@ -94,9 +106,16 @@ pub fn scan(filesystem: Handle, directory: &str) -> Result> { Ok(image) => found.push(UkiFile { file_name, machine: image.machine, - has_osrel: image.sections.contains_key(".osrel"), - // The sections are dropped here, so only the small entry is kept per image. - entry: BlsEntry::from_uki(&image.sections, &path), + // The sections are dropped here, so only the small entries are kept per image. + profiles: uki_profiles(&image.sections) + .into_iter() + .map(|profile| UkiProfileEntry { + index: profile.index, + has_osrel: profile.sections.contains_key(".osrel"), + entry: BlsEntry::from_uki_profile(&profile, &path), + info: profile.info, + }) + .collect(), }), Err(error) => warn!("unable to read unified kernel image {}: {:#}", path, error), } diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index bb392fc..e1780ff 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -547,7 +547,7 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { let default_flags = edera_sprout_bls::resolve_default_flags( &entries .iter() - .map(|entry| (entry.is_default(), entry.is_bad())) + .map(|entry| (entry.is_default(), entry.is_bad(), entry.is_extra_profile())) .collect::>(), ); for (entry, default) in entries.iter_mut().zip(default_flags) { From d757fefa6292a84f0a6a1e0227e6efacb6e8a013 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 20:03:23 -0700 Subject: [PATCH 7/9] feat(bls): read the extended boot loader partition in the BLS generator Add an xbootldr option to the BLS generator. When it is enabled, the partition with the XBOOTLDR type on the same disk as Sprout's partition is read as well, and its entries and unified kernel images are sorted with the others. As their files are on that partition, every entry has an entry-root value, which is the device of the partition or nothing for Sprout's own, to use in a path like $entry-root\$chainload. It is off by default, as existing actions would look for the files on the wrong partition. --- crates/boot/src/autoconfigure/bls.rs | 3 + crates/boot/src/generators/bls.rs | 103 +++++++++++++++++++++++---- crates/config/src/generators/bls.rs | 32 +++++++-- 3 files changed, 120 insertions(+), 18 deletions(-) diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index 25e1240..3ee9de3 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -102,6 +102,9 @@ pub fn scan( }, path: format!("{}\\loader", root), uki_path: None, + // Every filesystem is scanned, including the Extended Boot Loader Partition, which + // gets a generator of its own. + xbootldr: false, pin_names: true, }; diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 2a47ef4..56c493e 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -66,6 +66,7 @@ fn bootable_entry( name: &str, entry: &BlsEntry, initrds: &[String], + entry_root: &str, ) -> BootableEntry { // Produce a new sprout context for the entry with the extracted values. let mut context = context.fork(); @@ -84,6 +85,9 @@ fn bootable_entry( title_base.clone() }; + // The device that the files of the entry are on, for a path like "$entry-root\\$chainload". + // It is empty for the partition that Sprout was loaded from. + context.set("entry-root", entry_root.to_string()); context.set("title-base", title_base); context.set("title", title_full); context.set("chainload", chainload); @@ -132,6 +136,7 @@ fn generate_type1( context: &Rc, bls: &BlsConfiguration, path: &str, + entry_root: &str, ) -> Result> { let mut entries = Vec::new(); @@ -259,7 +264,7 @@ fn generate_type1( continue; } - let mut boot = bootable_entry(context, bls, &name, &entry, &initrds); + let mut boot = bootable_entry(context, bls, &name, &entry, &initrds, entry_root); boot.set_id_suffix(&extension); // Record where the boot counter lives so a try can be consumed when this entry boots. @@ -287,6 +292,7 @@ fn generate_type2( context: &Rc, bls: &BlsConfiguration, uki_path: &str, + entry_root: &str, entries: &[(BlsEntry, BootableEntry)], ) -> Result> { let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), uki_path) @@ -356,7 +362,7 @@ fn generate_type2( let mut entry = profile.entry; entry.boot_counter = boot_counter; - let mut boot = bootable_entry(context, bls, &name, &entry, &[]); + let mut boot = bootable_entry(context, bls, &name, &entry, &[], entry_root); boot.set_id_suffix(&extension); if let Some(ref suffix) = suffix { boot.set_id_profile(suffix); @@ -392,18 +398,19 @@ fn generate_type2( Ok(found) } -/// Generates entries from the BLS entries directory and the unified kernel image directory -/// using the specified `bls` configuration and `context`. The BLS conversion is best-effort -/// and will ignore any unsupported entries. -pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result> { - // Stamp the path to the BLS directory. - let path = context.stamp(&bls.path); - - let uki_path = bls.uki_path_for(&path); - +/// Generates the entries of one partition, from the BLS entries directory at `bls_path` and the +/// unified kernel image directory at `uki_path`, if there is one. The files of the entries are on +/// the device `entry_root`. +fn generate_partition( + context: &Rc, + bls: &BlsConfiguration, + bls_path: &str, + uki_path: Option<&str>, + entry_root: &str, +) -> Result> { // A problem reading the Type #1 entries only stops the unified kernel images when there // are none to add, so that either kind of entry can still boot. - let mut entries = match generate_type1(&context, bls, &path) { + let mut entries = match generate_type1(context, bls, bls_path, entry_root) { Ok(entries) => entries, Err(error) if uki_path.is_some() => { warn!("unable to generate bls entries: {:#}", error); @@ -415,8 +422,8 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result entries.extend(found), Err(error) => warn!( "unable to generate unified kernel image entries: {:#}", @@ -424,6 +431,74 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result) -> Result> { + let Some(path) = eficore::xbootldr::find_xbootldr(context.root().loaded_image_path()?)? else { + return Ok(None); + }; + let mut root = path + .to_string16(DisplayOnly(false), AllowShortcuts(false)) + .context("unable to convert the xbootldr device path to a string")? + .to_string(); + // Add a trailing forward-slash to the root to ensure the device root is completed. + root.push('/'); + Ok(Some(root)) +} + +/// Generates entries from the BLS entries directory and the unified kernel image directory +/// using the specified `bls` configuration and `context`, and from the Extended Boot Loader +/// Partition if that is enabled. The BLS conversion is best-effort and will ignore any +/// unsupported entries. +pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result> { + // Stamp the path to the BLS directory. + let path = context.stamp(&bls.path); + + let uki_path = bls.uki_path_for(&path); + let mut entries = generate_partition( + &context, + bls, + &path, + uki_path.as_deref(), + BlsConfiguration::root_of(&path), + )?; + + // Add the entries of the Extended Boot Loader Partition, which are sorted together with the + // others. A problem with it should not prevent booting from the entries that are found. + if bls.xbootldr { + match xbootldr_root(&context) { + Ok(Some(root)) => { + let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root)); + match generate_partition( + &context, + bls, + &format!("{}\\loader", root), + xbootldr_uki.as_deref(), + &root, + ) { + Ok(found) => { + for (entry, boot) in found { + // An entry that is on both partitions would be ambiguous. + if entries.iter().any(|(_, other)| other.id() == boot.id()) { + warn!( + "xbootldr entry {} has the same id as another entry, skipping", + boot.id() + ); + } else { + entries.push((entry, boot)); + } + } + } + Err(error) => warn!("unable to generate xbootldr entries: {:#}", error), + } + } + Ok(None) => info!("no xbootldr partition was found"), + Err(error) => warn!("unable to find the xbootldr partition: {:#}", error), + } + } // Sort all the entries according to the BLS sort system. entries.sort_by(sort_entries); diff --git a/crates/config/src/generators/bls.rs b/crates/config/src/generators/bls.rs index f69f709..be5d665 100644 --- a/crates/config/src/generators/bls.rs +++ b/crates/config/src/generators/bls.rs @@ -26,6 +26,12 @@ pub struct BlsConfiguration { /// with the same names, so they should not both scan it. #[serde(default, rename = "uki-path")] pub uki_path: Option, + /// Whether to also read the Extended Boot Loader Partition, which is the partition with the + /// XBOOTLDR type on the same disk as the partition Sprout was loaded from. Its entries are + /// sorted with the others, and their paths are on that partition, so an action has to use + /// the `$entry-root` value to find them, such as `$entry-root\\$chainload`. + #[serde(default)] + pub xbootldr: bool, /// Whether generated entries keep the name of the BLS entry file as-is. /// When enabled, the generator name is not prepended, so the entry names match /// the BLS entry ids used by tools like `bootctl set-default`. Variants still append @@ -38,6 +44,12 @@ pub struct BlsConfiguration { } impl BlsConfiguration { + /// The device of the stamped BLS path `bls_path`, which is everything up to the last slash, + /// and which is empty for a path on the partition Sprout was loaded from. + pub fn root_of(bls_path: &str) -> &str { + bls_path.rfind('/').map_or("", |index| &bls_path[..=index]) + } + /// The directory of unified kernel images for the stamped BLS path `bls_path`, /// or None if unified kernel images are disabled. pub fn uki_path_for(&self, bls_path: &str) -> Option { @@ -45,10 +57,7 @@ impl BlsConfiguration { Some("") => None, Some(path) => Some(path.to_string()), // Keep the device of the BLS path, which is everything up to the last slash. - None => { - let device = bls_path.rfind('/').map_or("", |index| &bls_path[..=index]); - Some(format!("{}{}", device, BLS_UKI_DIRECTORY)) - } + None => Some(format!("{}{}", Self::root_of(bls_path), BLS_UKI_DIRECTORY)), } } } @@ -60,6 +69,7 @@ impl Default for BlsConfiguration { entry: Default::default(), path: default_bls_path(), uki_path: None, + xbootldr: false, pin_names: default_pin_names(), } } @@ -77,6 +87,20 @@ fn default_bls_path() -> String { mod tests { use super::*; + #[test] + fn root_is_the_device_of_the_bls_path() { + assert_eq!(BlsConfiguration::root_of("\\loader"), ""); + assert_eq!( + BlsConfiguration::root_of("PciRoot(0x0)/HD(2,GPT,abc)/\\loader"), + "PciRoot(0x0)/HD(2,GPT,abc)/" + ); + } + + #[test] + fn xbootldr_is_off_by_default() { + assert!(!BlsConfiguration::default().xbootldr); + } + #[test] fn uki_path_is_next_to_the_default_bls_path() { let bls = BlsConfiguration::default(); From 48658d3dfae85648c49a5db7ba5c7a62d50ffd2e Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 20:04:33 -0700 Subject: [PATCH 8/9] docs(readme): describe the BLS, boot counting and loader.conf support Update the feature list for what is implemented now, including the graphical menu and the generators, add the command line options that were missing, and document the BLS generator options, boot counting, loader.conf, and the bootloader interface. --- README.md | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 17c71bf..abff499 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,18 @@ We recommend running Sprout without Secure Boot for development, and with Secure ### Current - [x] Loadable driver support -- [x] Basic [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support +- [x] [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support: + Type #1 entries, Type #2 unified kernel images, and the extended boot loader partition +- [x] [UKI support](https://github.com/edera-dev/sprout/issues/6): beta, including images with multiple profiles +- [x] Boot counting and automatic boot assessment +- [x] `loader.conf` support - [x] Chainload support - [x] Linux boot support via EFI stub - [x] Windows boot support via chainload - [x] Load Linux initrd from disk -- [x] Basic boot menu +- [x] Devicetree support +- [x] Basic, simple, and graphical boot menus +- [x] Generators for BLS entries, lists, and matrices, with variants - [x] BLS autoconfiguration support - [x] [Secure Boot support](https://github.com/edera-dev/sprout/issues/20): beta - [x] [Bootloader interface support](https://github.com/edera-dev/sprout/issues/21): beta @@ -83,7 +89,8 @@ We recommend running Sprout without Secure Boot for development, and with Secure ### Roadmap - [ ] [Full-featured boot menu](https://github.com/edera-dev/sprout/issues/1) -- [ ] [UKI support](https://github.com/edera-dev/sprout/issues/6): partial +- [ ] Network boot of unified kernel images (`uki-url`) and devicetree overlays +- [ ] A random seed for the Linux kernel - [ ] [multiboot2 support](https://github.com/edera-dev/sprout/issues/7) - [ ] [Linux boot protocol (boot without EFI stub)](https://github.com/edera-dev/sprout/issues/8) @@ -124,6 +131,12 @@ $ sprout.efi --autoconfigure $ sprout.efi --menu-style=basic # Use the graphical boot menu, which can be used with the mouse. $ sprout.efi --menu-style=graphical +# Show the boot menu for 10 seconds before booting the default entry. +$ sprout.efi --menu-timeout=10 +# Show the boot menu even if an entry was chosen with --boot. +$ sprout.efi --force-menu +# Keep the boot console as it is when an entry is booted. +$ sprout.efi --retain-boot-console ``` ### Boot Linux from ESP @@ -165,6 +178,113 @@ path = "\\sprout\\drivers\\ext4.efi" autoconfigure = true ``` +Sprout reads Type #1 entries from `\loader\entries` and Type #2 unified kernel images (UKIs) from +`\EFI\Linux`, and sorts them as the specification says. A unified kernel image with several profiles +is one entry for each profile. Entries that are not for the architecture of the machine are hidden. +Type #1 entries can use `linux`, `efi`, `uki`, `initrd`, `options`, `devicetree`, `architecture`, +`profile`, `sort-key`, `version`, `machine-id`, and `title`. + +To set up the generator by hand instead of using autoconfiguration, add a generator and an action +that boots the entries. The entry values `$chainload`, `$options`, `$initrd-0` to `$initrd-7`, +`$devicetree`, `$cmdline`, `$title`, `$version`, and `$entry-root` are available. + +```toml +[generators.bls] +# the directory that has the entries directory. this is the default. +bls.path = "\\loader" +# the directory of unified kernel images. by default, this is \EFI\Linux on +# the device of the path. an empty path turns unified kernel images off. +bls.uki-path = "\\EFI\\Linux" +# also read the extended boot loader partition (XBOOTLDR) of the same disk, +# which is sorted with the other entries. its files are on that partition, so +# the action has to use $entry-root, which is empty for Sprout's own partition. +bls.xbootldr = false +# keep the name of the entry file as the name of the entry, so it matches +# the ids that bootctl uses. +bls.pin-names = true +bls.entry.title = "$title" +bls.entry.actions = ["boot-bls"] + +[actions.boot-bls] +chainload.path = "$entry-root\\$chainload" +chainload.options = ["$options"] +chainload.devicetree = "$entry-root\\$devicetree" +chainload.linux-initrd-chain = [ + "$entry-root\\$initrd-0", + "$entry-root\\$initrd-1", +] +``` + +An entry that names a `devicetree` boots with that devicetree installed for the image, which is put +back when the image returns. It is not used when Secure Boot is enabled, as it can't be verified. + +#### Boot counting + +An entry file named like `fedora+3.conf` or `fedora+3.efi` has three tries. Each time Sprout boots it, the +file is renamed, such as to `fedora+2-1.conf`, and the new path is given to the system in +`LoaderBootCountPath` so `systemd-bless-boot` can remove the counter once the boot works. +Entries that have no tries left are sorted last and are not picked as the default entry, but they can still be +booted by hand. If an entry with tries left fails to start, the machine resets, so the next boot can use +the next try or another entry. This is set by `reboot-on-error` in `loader.conf`. + +#### loader.conf + +Sprout reads `\loader\loader.conf` from the partition it was loaded from, as systemd-boot does. + +| Key | Value | +|-------------------|--------------------------------------------------------------------| +| `default` | A pattern for the id of the default entry, or `@saved`. | +| `preferred` | Like `default`, but entries with no boot counter tries are skipped. | +| `timeout` | Seconds, `menu-hidden`, `menu-disabled`, or `menu-force`. | +| `reboot-on-error` | `auto` (the default), `yes`, or `no`. | + +The id of an entry is the name of its file without the boot counter, such as `fedora.conf` or `fedora.efi`. +Patterns ignore case and can use `*`, `?`, and `[a-z]`. With `@saved`, the entry that was booted last is the +default. With `reboot-on-error`, `yes` always resets after an entry fails to start, which can loop forever, +and `auto` only does when a boot counter try was used up and there were tries left. +Other keys are ignored with a warning. + +A hidden menu, from a timeout of zero or `menu-hidden`, still opens when a key is pressed. `menu-disabled` does not. + +#### Bootloader interface + +Sprout uses the same variables as systemd-boot, so `bootctl`, `systemctl reboot --boot-loader-entry`, and +`systemd-bless-boot` can work with it. It publishes `LoaderEntries`, `LoaderEntrySelected`, +`LoaderBootCountPath`, `LoaderFeatures`, and `LoaderInfo`, and reads `LoaderEntryDefault`, +`LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout`, +and `LoaderConfigTimeoutOneShot`. + +The default entry comes from the first of these that matches an entry: + +1. `default-entry` in `sprout.toml` +2. `LoaderEntryPreferred`, then `preferred` in `loader.conf` +3. `LoaderEntryDefault`, then `default` in `loader.conf` + +The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `menu-timeout` in +`sprout.toml`, `LoaderConfigTimeout`, and `loader.conf`. + +### Generators + +Generators make entries when Sprout starts. The `matrix` generator makes an entry for every combination of +its values, the `list` generator makes an entry for each item of a list, and the `bls` generator makes +entries from BLS files. Variants multiply the entries of any generator, and `exclude` removes some of them. + +```toml +# makes an entry for each kernel and each console, such as "Boot \vmlinuz (serial)". +[generators.kernels] +matrix.entry.title = "Boot $kernel ($console)" +matrix.entry.actions = ["boot-kernel"] +matrix.values.kernel = ["\\vmlinuz", "\\vmlinuz-lts"] +variants.console = [ + { name = "serial", values.console-options = "console=ttyS0" }, + { name = "graphics", values.console-options = "console=tty0" }, +] + +[actions.boot-kernel] +chainload.path = "$kernel" +chainload.options = ["$console-options"] +``` + [Edera]: https://edera.dev [Development Guide]: ./DEVELOPMENT.md [Contributing Guide]: ./CONTRIBUTING.md From 46bbaad9483a21cee67a491bdfdb24fe1a8c9c45 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 20:19:21 -0700 Subject: [PATCH 9/9] fix(boot): tidy the devicetree, reboot and profile handling after review Only warn about a devicetree under Secure Boot when the entry has one, skip the fixup if its protocol can't be opened, and reject a devicetree whose size is smaller than its header. Announce a reboot before the delay, advertise the multi-profile UKI feature, and treat a Type 1 entry with a profile after the first as an extra profile that is not picked as the fallback default. A failure to read Sprout's own partition no longer drops the entries of the XBOOTLDR partition, and the README lists every initrd slot and describes the reboot behaviour more exactly. --- README.md | 14 ++++++++--- crates/boot/src/actions/chainload.rs | 29 ++++++++++++++++------ crates/boot/src/entries.rs | 9 ++++++- crates/boot/src/generators/bls.rs | 15 +++++++++-- crates/boot/src/main.rs | 10 ++++++-- crates/config/src/generators/bls.rs | 3 ++- crates/eficore/src/bootloader_interface.rs | 1 + crates/eficore/src/devicetree.rs | 11 +++++--- 8 files changed, 72 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index abff499..be215bf 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ We recommend running Sprout without Secure Boot for development, and with Secure - [x] [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support: Type #1 entries, Type #2 unified kernel images, and the extended boot loader partition - [x] [UKI support](https://github.com/edera-dev/sprout/issues/6): beta, including images with multiple profiles -- [x] Boot counting and automatic boot assessment +- [x] Boot counting, so `systemd-bless-boot` can assess a boot - [x] `loader.conf` support - [x] Chainload support - [x] Linux boot support via EFI stub @@ -209,9 +209,16 @@ bls.entry.actions = ["boot-bls"] chainload.path = "$entry-root\\$chainload" chainload.options = ["$options"] chainload.devicetree = "$entry-root\\$devicetree" +# an entry can have up to eight initrds. unused ones are skipped. chainload.linux-initrd-chain = [ "$entry-root\\$initrd-0", "$entry-root\\$initrd-1", + "$entry-root\\$initrd-2", + "$entry-root\\$initrd-3", + "$entry-root\\$initrd-4", + "$entry-root\\$initrd-5", + "$entry-root\\$initrd-6", + "$entry-root\\$initrd-7", ] ``` @@ -224,8 +231,9 @@ An entry file named like `fedora+3.conf` or `fedora+3.efi` has three tries. Each file is renamed, such as to `fedora+2-1.conf`, and the new path is given to the system in `LoaderBootCountPath` so `systemd-bless-boot` can remove the counter once the boot works. Entries that have no tries left are sorted last and are not picked as the default entry, but they can still be -booted by hand. If an entry with tries left fails to start, the machine resets, so the next boot can use -the next try or another entry. This is set by `reboot-on-error` in `loader.conf`. +booted by hand. By default, if an entry fails to start after a try was used up and it had tries left, the machine +resets, so the next boot can use the next try or another entry. This is the `reboot-on-error` setting +in `loader.conf`. Without boot counting, a failure to start returns to the firmware. #### loader.conf diff --git a/crates/boot/src/actions/chainload.rs b/crates/boot/src/actions/chainload.rs index 5af8c2c..ca551f4 100644 --- a/crates/boot/src/actions/chainload.rs +++ b/crates/boot/src/actions/chainload.rs @@ -13,6 +13,7 @@ use eficore::loader::source::ImageSource; use eficore::loader::{ImageLoadRequest, ImageLoader}; use eficore::media_loader::MediaLoaderHandle; use eficore::media_loader::constants::linux::LINUX_EFI_INITRD_MEDIA_GUID; +use eficore::path::ResolvedPath; use eficore::secure::SecureBoot; use log::warn; use uefi::proto::loaded_image::LoadedImage; @@ -31,6 +32,22 @@ fn read_optional_file( path: &str, what: &str, ) -> Result>> { + let Some(resolved) = resolve_optional_file(context, path, what)? else { + return Ok(None); + }; + let content = resolved + .read_file() + .with_context(|| format!("unable to read {}", what))?; + Ok(Some(content)) +} + +/// Resolve the `what` at the stamped `path` relative to the sprout image. +/// Provides [None] if the path refers to the root of a filesystem rather than a file. +fn resolve_optional_file( + context: &Rc, + path: &str, + what: &str, +) -> Result> { let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path) .with_context(|| format!("unable to resolve {} path", what))?; @@ -42,11 +59,7 @@ fn read_optional_file( if subpath.trim_matches('\\').is_empty() { return Ok(None); } - - let content = resolved - .read_file() - .with_context(|| format!("unable to read {}", what))?; - Ok(Some(content)) + Ok(Some(resolved)) } /// Unloads the image with the contained handle when dropped, unless the handle is taken. @@ -167,10 +180,12 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat .devicetree .as_ref() .map(|path| context.stamp(path)), - ) { + ) && let Some(resolved) = resolve_optional_file(&context, &path, "devicetree")? + { if SecureBoot::enabled().unwrap_or(true) { warn!("ignoring the devicetree, as Secure Boot is enabled"); - } else if let Some(content) = read_optional_file(&context, &path, "devicetree")? { + } else { + let content = resolved.read_file().context("unable to read devicetree")?; devicetree = Some(DeviceTree::install(&content).context("unable to install the devicetree")?); } diff --git a/crates/boot/src/entries.rs b/crates/boot/src/entries.rs index b0d4923..b1ca4c6 100644 --- a/crates/boot/src/entries.rs +++ b/crates/boot/src/entries.rs @@ -20,6 +20,7 @@ pub struct BootableEntry { boot_counter: Option, id_suffix: Option, id_profile: Option, + extra_profile: bool, } impl BootableEntry { @@ -41,6 +42,7 @@ impl BootableEntry { boot_counter: None, id_suffix: None, id_profile: None, + extra_profile: false, } } @@ -78,7 +80,12 @@ impl BootableEntry { /// Fetch whether the entry is a profile of a unified kernel image after the first, which /// is never picked as the default entry unless it was asked for. pub fn is_extra_profile(&self) -> bool { - self.id_profile.is_some() + self.id_profile.is_some() || self.extra_profile + } + + /// Mark this entry as booting a profile of a unified kernel image after the first. + pub fn mark_extra_profile(&mut self) { + self.extra_profile = true; } /// Set the profile of a unified kernel image that is part of the id of this entry. diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 56c493e..64d85a5 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -266,6 +266,9 @@ fn generate_type1( let mut boot = bootable_entry(context, bls, &name, &entry, &initrds, entry_root); boot.set_id_suffix(&extension); + if entry.profile_number() > 0 { + boot.mark_extra_profile(); + } // Record where the boot counter lives so a try can be consumed when this entry boots. if let Some(counter) = boot_counter { @@ -458,13 +461,21 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result entries, + Err(error) if bls.xbootldr => { + warn!("unable to generate bls entries: {:#}", error); + Vec::new() + } + Err(error) => return Err(error), + }; // Add the entries of the Extended Boot Loader Partition, which are sorted together with the // others. A problem with it should not prevent booting from the entries that are found. diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index e1780ff..586bb09 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -706,12 +706,18 @@ fn efi_main() -> Status { for (index, stack) in error.chain().enumerate() { error!("[{}]: {}", index, stack); } - // Sleep to allow the user to read the error. + // Sleep to allow the user to read the error. A reboot is announced first, so it is + // known why the machine is about to reset. + if reboot_on_error { + error!( + "rebooting in {} seconds after a failure to start the boot entry", + DELAY_ON_ERROR.as_secs() + ); + } uefi::boot::stall(DELAY_ON_ERROR); // Reboot when asked to, so that the next boot can use the next try or entry. if reboot_on_error { - error!("rebooting after a failure to start the boot entry"); uefi::runtime::reset(ResetType::COLD, Status::SUCCESS, None); } return Status::ABORTED; diff --git a/crates/config/src/generators/bls.rs b/crates/config/src/generators/bls.rs index be5d665..a4cbffa 100644 --- a/crates/config/src/generators/bls.rs +++ b/crates/config/src/generators/bls.rs @@ -29,7 +29,8 @@ pub struct BlsConfiguration { /// Whether to also read the Extended Boot Loader Partition, which is the partition with the /// XBOOTLDR type on the same disk as the partition Sprout was loaded from. Its entries are /// sorted with the others, and their paths are on that partition, so an action has to use - /// the `$entry-root` value to find them, such as `$entry-root\\$chainload`. + /// the `$entry-root` value to find them, such as `$entry-root\\$chainload`. Its unified kernel + /// images are always in `\\EFI\\Linux`. #[serde(default)] pub xbootldr: bool, /// Whether generated entries keep the name of the BLS entry file as-is. diff --git a/crates/eficore/src/bootloader_interface.rs b/crates/eficore/src/bootloader_interface.rs index 02f9630..feafc22 100644 --- a/crates/eficore/src/bootloader_interface.rs +++ b/crates/eficore/src/bootloader_interface.rs @@ -68,6 +68,7 @@ impl BootloaderInterface { | LoaderFeatures::EntryPreferred | LoaderFeatures::SortKey | LoaderFeatures::DeviceTree + | LoaderFeatures::MultiProfileUki } /// Tell the system that Sprout was initialized at the current time. diff --git a/crates/eficore/src/devicetree.rs b/crates/eficore/src/devicetree.rs index 38e84d2..d3a2b54 100644 --- a/crates/eficore/src/devicetree.rs +++ b/crates/eficore/src/devicetree.rs @@ -67,8 +67,8 @@ impl DeviceTree { if magic != FDT_MAGIC { bail!("the file is not a flattened devicetree"); } - if total > dtb.len() { - bail!("the devicetree is truncated"); + if total < FDT_HEADER_SIZE || total > dtb.len() { + bail!("the devicetree has an invalid size"); } Ok(()) } @@ -127,8 +127,11 @@ impl DeviceTree { let Some(handle) = find_handle(&DT_FIXUP_GUID)? else { return Ok(()); }; - let mut protocol = uefi::boot::open_protocol_exclusive::(handle) - .context("unable to open the devicetree fixup protocol")?; + // Without the protocol the devicetree is used as it is, as in systemd-boot. + let Ok(mut protocol) = crate::handle::open_shared::(handle) else { + warn!("unable to open the devicetree fixup protocol, skipping the fixup"); + return Ok(()); + }; let fixup = protocol.fixup; let this: *mut DtFixupProtocol = &mut *protocol; let flags = DT_APPLY_FIXUPS | DT_RESERVE_MEMORY;