From 903fe49c99620d8fbb3fe5fed6a987d705167cbe Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:15:03 -0700 Subject: [PATCH 01/10] fix(boot): let the bootloader interface outrank the configuration The default entry and the menu timeout that bootctl sets in LoaderEntryDefault, LoaderEntryPreferred and LoaderConfigTimeout now win over default-entry and menu-timeout in sprout.toml, as they do over loader.conf in systemd-boot. --menu-timeout and the one-shot timeout still win over everything. --- crates/boot/src/main.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 586bb09..519d08a 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -377,7 +377,8 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { let mut force_boot_menu = context.root().options().force_menu; // Pick the menu timeout from the first source that specifies one, in this order: - // the one-shot timeout, --menu-timeout, the configuration, LoaderConfigTimeout, loader.conf. + // the one-shot timeout, --menu-timeout, LoaderConfigTimeout, the configuration, loader.conf. + // The bootloader interface is what tools like bootctl set, so it outranks the configuration. let seconds = |seconds: Option| { seconds.map_or( BootloaderInterfaceTimeout::Unspecified, @@ -396,8 +397,8 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { let timeout = [ bootloader_interface_timeouts.oneshot, seconds(context.root().options().menu_timeout), - seconds(config.options.menu_timeout), bootloader_interface_timeouts.direct, + seconds(config.options.menu_timeout), loader_conf_timeout, ] .into_iter() @@ -473,17 +474,12 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { Some(value) }; - // Pick the default entry from the first source that matches an entry. A source that matches + // Pick the default entry from the first source that matches an entry, where the bootloader + // interface, which is what tools like bootctl set, outranks the configuration. A source that matches // no entry, such as when the entry was removed, is ignored so that the next source is used. // The preferred entry sources never use an entry with no boot counter tries left. // Each source has whether it matches by id, and whether it skips bad entries. for (source, value, by_id, skip_bad) in [ - ( - "configuration", - config.options.default_entry.clone(), - false, - false, - ), ( "bootloader interface preferred", bootloader_interface_preferred_entry.clone(), @@ -502,6 +498,12 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { true, false, ), + ( + "configuration", + config.options.default_entry.clone(), + false, + false, + ), ( "loader.conf default", loader_conf.default.clone(), From 5d2ae1a9fccdf07e902c6046aa33e98b80e86b68 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:15:46 -0700 Subject: [PATCH 02/10] feat(boot): add the bls-strict-mode option Add --bls-strict-mode and bls-strict-mode in the options of sprout.toml. It does nothing yet; the behaviors it controls follow. --- crates/boot/src/main.rs | 7 +++++++ crates/boot/src/options.rs | 10 ++++++++++ crates/config/src/lib.rs | 13 +++++++++++++ 3 files changed, 30 insertions(+) diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 519d08a..9c37e9d 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -199,6 +199,13 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { .context("unable to set loader path in bootloader interface"), ); + // Strict mode can be asked for on the command line or in the configuration. + let mut options = options; + options.bls_strict_mode |= config.options.bls_strict_mode; + if options.bls_strict_mode { + info!("BLS strict mode is enabled"); + } + // Create the root context. let mut root = RootContext::new(loaded_image_path, timer, options); diff --git a/crates/boot/src/options.rs b/crates/boot/src/options.rs index 794c71c..74c6889 100644 --- a/crates/boot/src/options.rs +++ b/crates/boot/src/options.rs @@ -29,6 +29,8 @@ pub struct SproutOptions { pub menu_style: Option, /// Retains the boot console before boot. pub retain_boot_console: bool, + /// Makes Sprout follow the Boot Loader Specification and systemd-boot exactly. + pub bls_strict_mode: bool, } /// The default Sprout options. @@ -42,6 +44,7 @@ impl Default for SproutOptions { menu_timeout: None, menu_style: None, retain_boot_console: false, + bls_strict_mode: false, } } } @@ -60,6 +63,7 @@ impl SproutOptions { MenuTimeout, MenuStyle, RetainBootConsole, + BlsStrictMode, } // All the options for the Sprout executable. @@ -78,6 +82,8 @@ impl SproutOptions { .help_text("Boot menu style, basic, simple or graphical"), Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"]) .help_text("Retain boot console before boot"), + Opt::flag(ArgID::BlsStrictMode, &["--bls-strict-mode"]) + .help_text("Follow the BLS specification and systemd-boot exactly"), ]); // Acquire the arguments as determined by the UEFI core. @@ -141,6 +147,10 @@ impl SproutOptions { // Retain the boot console before booting. result.retain_boot_console = true; } + ArgID::BlsStrictMode => { + // Follow the BLS specification and systemd-boot exactly. + result.bls_strict_mode = true; + } ArgID::Help => { let ctx = HelpWriterContext { options: &OPTIONS, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4dafce4..ee1c180 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -91,6 +91,12 @@ pub struct OptionsConfiguration { /// Enables autoconfiguration of Sprout based on the environment. #[serde(default)] pub autoconfigure: bool, + /// Makes Sprout follow the Boot Loader Specification and systemd-boot exactly where it + /// otherwise differs for convenience or safety. For example, the extended boot loader + /// partition is always read, a one-shot entry only sets the default entry, and entries with + /// missing files are hidden. See the README for everything it changes. + #[serde(rename = "bls-strict-mode", default)] + pub bls_strict_mode: bool, } /// The default configuration, which matches an empty configuration file. @@ -118,6 +124,7 @@ impl Default for OptionsConfiguration { menu_timeout: None, menu_style: Default::default(), autoconfigure: false, + bls_strict_mode: false, } } } @@ -164,6 +171,12 @@ mod tests { assert_eq!(default.options.autoconfigure, empty.options.autoconfigure); } + #[test] + fn bls_strict_mode_is_off_by_default() { + assert!(!RootConfiguration::default().options.bls_strict_mode); + assert!(!empty().options.bls_strict_mode); + } + #[test] fn menu_timeout_is_unset_by_default() { assert_eq!(RootConfiguration::default().options.menu_timeout, None); From 2524067b352390e63eb507a67264b389de688ac6 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:16:13 -0700 Subject: [PATCH 03/10] feat(bls): follow the systemd-boot fallbacks for unified kernel image names --- crates/bls/src/lib.rs | 53 +++++++++++++++++++++++++++++++++++++++++++ crates/bls/src/uki.rs | 30 ++++++++++++++++++------ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index bbdd3b5..a1a0727 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -199,6 +199,16 @@ impl BlsEntry { self.linux.is_some() || self.efi.is_some() || self.uki.is_some() } + /// Whether the entry names more than one of `linux`, `efi` and `uki` to boot. + /// systemd-boot considers such an entry broken and does not show it. + pub fn mixes_boot_targets(&self) -> bool { + [&self.linux, &self.efi, &self.uki] + .iter() + .filter(|target| target.is_some()) + .count() + > 1 + } + /// Whether the entry only boots a unified kernel image, which carries its own initrd. fn is_uki_only(&self) -> bool { self.linux.is_none() && self.efi.is_none() && self.uki.is_some() @@ -806,6 +816,49 @@ mod tests { assert!(entry.is_valid()); } + #[test] + fn uki_title_and_version_use_every_systemd_fallback() { + let sections = uki_sections(&[( + ".osrel", + "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=7.1\nVERSION_ID=7\n", + )]); + let entry = BlsEntry::from_uki(§ions, "/a.efi"); + assert_eq!(entry.title.as_deref(), Some("img")); + assert_eq!(entry.version.as_deref(), Some("7.1")); + + let sections = uki_sections(&[(".osrel", "NAME=N\nID=i\n")]); + assert_eq!( + BlsEntry::from_uki(§ions, "/a.efi").title.as_deref(), + Some("N") + ); + } + + #[test] + fn uki_version_only_falls_back_to_uname_outside_strict_mode() { + let sections = uki_sections(&[(".osrel", "ID=a\n"), (".uname", "6.1.2\n")]); + let loose = BlsEntry::from_uki_with(§ions, "/a.efi", false); + assert_eq!(loose.version.as_deref(), Some("6.1.2")); + let strict = BlsEntry::from_uki_with(§ions, "/a.efi", true); + assert_eq!(strict.version, None); + // The kernel version is still there, it is just not the version of the entry. + assert_eq!(strict.uname.as_deref(), Some("6.1.2")); + } + + #[test] + fn entries_that_mix_boot_targets_are_found() { + for (input, mixed) in [ + ("linux /v\n", false), + ("efi /e.efi\n", false), + ("uki /u.efi\n", false), + ("linux /v\nefi /e.efi\n", true), + ("linux /v\nuki /u.efi\n", true), + ("efi /e.efi\nuki /u.efi\n", true), + ] { + let entry: BlsEntry = input.parse().unwrap(); + assert_eq!(entry.mixes_boot_targets(), mixed, "{input}"); + } + } + #[test] fn uki_entry_falls_back_through_the_fields() { let sections = uki_sections(&[(".osrel", "ID=arch\nBUILD_ID=rolling\n")]); diff --git a/crates/bls/src/uki.rs b/crates/bls/src/uki.rs index acf4b8a..bb2b157 100644 --- a/crates/bls/src/uki.rs +++ b/crates/bls/src/uki.rs @@ -75,7 +75,12 @@ impl BlsEntry { /// Produces an entry for one profile of the unified kernel image at `uki_path`. /// A profile after the first is booted with `@` 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); + Self::from_uki_profile_with(profile, uki_path, false) + } + + /// Like [BlsEntry::from_uki_profile], and `strict` follows systemd-boot exactly. + pub fn from_uki_profile_with(profile: &UkiProfile, uki_path: &str, strict: bool) -> Self { + let mut entry = Self::from_uki_with(&profile.sections, uki_path, strict); if let Some(index) = profile.index { entry.profile = (index > 0).then(|| index.to_string()); entry.title = entry @@ -87,11 +92,21 @@ impl BlsEntry { } /// 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>, uki_path: &str) -> Self { + Self::from_uki_with(sections, uki_path, false) + } + + /// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`. + /// The title comes from `PRETTY_NAME`, `IMAGE_ID`, `NAME`, then `ID`. The version comes from + /// `IMAGE_VERSION`, `VERSION`, `VERSION_ID`, then `BUILD_ID`, as in systemd-boot, and then + /// from the `.uname` section unless `strict` is set. 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_with( + sections: &BTreeMap>, + uki_path: &str, + strict: bool, + ) -> Self { let os_release = section_text(sections, ".osrel") .map(|text| OsRelease::parse(&text)) .unwrap_or_default(); @@ -102,10 +117,11 @@ impl BlsEntry { .map(ToString::to_string) }; let uname = section_text(sections, ".uname"); + let version = first(&["IMAGE_VERSION", "VERSION", "VERSION_ID", "BUILD_ID"]); Self { - title: first(&["PRETTY_NAME", "ID"]), - version: first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]).or_else(|| uname.clone()), + title: first(&["PRETTY_NAME", "IMAGE_ID", "NAME", "ID"]), + version: version.or_else(|| uname.clone().filter(|_| !strict)), sort_key: first(&["IMAGE_ID", "ID"]), uki: Some(uki_path.to_string()), cmdline: section_text(sections, ".cmdline"), From 0b5a2c5b32ebcb986ceb653f31240794b329aae7 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:17:28 -0700 Subject: [PATCH 04/10] feat(boot): make strict mode follow systemd-boot's selection and menu rules In strict mode a one-shot entry only becomes the default, the default entry may be one with no boot counter tries left, no menu timeout means a hidden menu, the menu is shown again when a started image returns, and entries that mix linux, efi and uki, entries whose file is missing, and unified kernel images with no name are hidden. Plain EFI entries are not counted, the XBOOTLDR partition is always read, and a devicetree that can't be installed fails the entry. Outside of strict mode these keep working as they did, and each one is now logged where it applies. The initrd limit of BLS entries is 32. A devicetree that can't be installed is skipped with a warning. --- crates/boot/src/actions/chainload.rs | 35 ++- crates/boot/src/generators/bls.rs | 67 +++++- crates/boot/src/generators/bls/uki.rs | 4 +- crates/boot/src/main.rs | 294 ++++++++++++++++---------- 4 files changed, 269 insertions(+), 131 deletions(-) diff --git a/crates/boot/src/actions/chainload.rs b/crates/boot/src/actions/chainload.rs index ca551f4..2f00106 100644 --- a/crates/boot/src/actions/chainload.rs +++ b/crates/boot/src/actions/chainload.rs @@ -62,6 +62,22 @@ fn resolve_optional_file( Ok(Some(resolved)) } +/// Install the devicetree at the stamped `path` for the image that is about to be started. +/// Provides [None] if there is none to install, such as when the path refers to the root of a +/// filesystem, or when Secure Boot is enabled, as a devicetree can't be verified. +fn install_devicetree(context: &Rc, path: &str) -> Result> { + let Some(resolved) = resolve_optional_file(context, path, "devicetree")? else { + return Ok(None); + }; + if SecureBoot::enabled().unwrap_or(true) { + warn!("ignoring the devicetree, as Secure Boot is enabled"); + return Ok(None); + } + let content = resolved.read_file().context("unable to read devicetree")?; + let devicetree = DeviceTree::install(&content).context("unable to install the devicetree")?; + Ok(Some(devicetree)) +} + /// Unloads the image with the contained handle when dropped, unless the handle is taken. /// This ensures that an image that is loaded but never started is not left in memory. struct UnloadGuard(Option); @@ -180,14 +196,17 @@ 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 { - let content = resolved.read_file().context("unable to read devicetree")?; - devicetree = - Some(DeviceTree::install(&content).context("unable to install the devicetree")?); + ) { + match install_devicetree(&context, &path) { + Ok(installed) => devicetree = installed, + // systemd-boot does not boot an entry whose devicetree can't be installed. Outside + // of strict mode it is booted without it, as before the devicetree was supported. + Err(error) if !context.root().options().bls_strict_mode => warn!( + "booting without the devicetree {}, as it can't be installed: {:#} \ + (systemd-boot and strict mode do not boot the entry)", + path, error + ), + Err(error) => return Err(error), } } diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 64d85a5..ab6fe48 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -15,7 +15,7 @@ use edera_sprout_bls::{ use edera_sprout_config::generators::bls::BlsConfiguration; use log::{info, warn}; use uefi::{ - cstr16, + CString16, cstr16, fs::{FileSystem, PathBuf}, proto::device_path::text::{AllowShortcuts, DisplayOnly}, proto::media::fs::SimpleFileSystem, @@ -38,8 +38,8 @@ const ARCHITECTURE: &str = "x64"; const ARCHITECTURE: &str = "aa64"; /// The number of initrd slots set on each BLS entry. -/// Entries set `initrd-0` through `initrd-7`, and slots without an initrd are empty. -pub const BLS_INITRD_SLOTS: usize = 8; +/// Entries set `initrd-0` through `initrd-31`, and slots without an initrd are empty. +pub const BLS_INITRD_SLOTS: usize = 32; // TODO(azenla): remove this once variable substitution is implemented. /// This function is used to remove the `tuned_initrd` variable from the initrd paths. @@ -138,6 +138,7 @@ fn generate_type1( path: &str, entry_root: &str, ) -> Result> { + let strict = context.root().options().bls_strict_mode; let mut entries = Vec::new(); // Resolve the path to the BLS directory. @@ -252,6 +253,42 @@ fn generate_type1( continue; } + // An entry that names more than one of linux, efi and uki is broken, and systemd-boot + // does not show it. Outside of strict mode, the first of them in that order is booted. + if entry.mixes_boot_targets() { + if strict { + warn!( + "hiding bls entry {} as it has more than one of linux, efi and uki", + name + ); + continue; + } + warn!( + "bls entry {} has more than one of linux, efi and uki, which systemd-boot hides; \ + using the first of linux, efi and uki (strict mode hides it)", + name + ); + } + + // The file that the entry boots has to exist. systemd-boot hides an entry that has + // none, while outside of strict mode it is shown and fails when it is booted. + if let Some(target) = entry.chainload_path() + && let Ok(target_path) = CString16::try_from(format!("\\{}", target).as_str()) + && let Ok(false) = fs.try_exists(PathBuf::from(target_path)) + { + if strict { + warn!( + "hiding bls entry {} as its file {} does not exist", + name, target + ); + continue; + } + warn!( + "bls entry {} refers to the file {}, which does not exist (strict mode hides it)", + name, target + ); + } + // Put the initrds through a quirk modifier to support Fedora. let initrds = quirk_initrd_remove_tuned(entry.initrd_paths()); @@ -271,7 +308,12 @@ fn generate_type1( } // Record where the boot counter lives so a try can be consumed when this entry boots. - if let Some(counter) = boot_counter { + // systemd-boot only counts the tries of the entries it boots as a kernel or as a unified + // kernel image, and not those of a plain EFI program. + let counted = !(strict && entry.linux.is_none() && entry.uki.is_none()); + if let Some(counter) = boot_counter + && counted + { boot.set_boot_counter(BootCounterTarget { counter, filesystem: bls_resolved.filesystem_handle, @@ -307,7 +349,8 @@ fn generate_type2( let directory_name = directory.to_string(); let mut found: Vec<(BlsEntry, BootableEntry)> = Vec::new(); - for image in uki::scan(resolved.filesystem_handle, &directory_name)? { + let strict = context.root().options().bls_strict_mode; + for image in uki::scan(resolved.filesystem_handle, &directory_name, strict)? { // Only unified kernel images that are built for this machine are entries. if image.machine != PE_MACHINE { info!( @@ -362,6 +405,16 @@ fn generate_type2( None => id.clone(), }; + // systemd-boot skips an image that has no name, while outside of strict mode the + // name of the file is used. + if strict && profile.entry.title.is_none() { + warn!( + "hiding unified kernel image {} as it has no name in its os-release", + file_name + ); + continue; + } + let mut entry = profile.entry; entry.boot_counter = boot_counter; @@ -470,7 +523,7 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result entries, - Err(error) if bls.xbootldr => { + Err(error) if bls.xbootldr || context.root().options().bls_strict_mode => { warn!("unable to generate bls entries: {:#}", error); Vec::new() } @@ -479,7 +532,7 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result { let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root)); diff --git a/crates/boot/src/generators/bls/uki.rs b/crates/boot/src/generators/bls/uki.rs index 942fd46..68218ef 100644 --- a/crates/boot/src/generators/bls/uki.rs +++ b/crates/boot/src/generators/bls/uki.rs @@ -62,7 +62,7 @@ impl ReadAt for FileReader { /// Finds the `.efi` files in `directory` on `filesystem` and reads their sections. /// A missing directory has no images. Files that can't be read as a PE image are skipped, /// as one broken file should not prevent booting any of the other entries. -pub fn scan(filesystem: Handle, directory: &str) -> Result> { +pub fn scan(filesystem: Handle, directory: &str, strict: bool) -> Result> { let mut fs = uefi::boot::open_protocol_exclusive::(filesystem) .context("unable to open the unified kernel image filesystem")?; let mut root = fs @@ -112,7 +112,7 @@ pub fn scan(filesystem: Handle, directory: &str) -> Result> { .map(|profile| UkiProfileEntry { index: profile.index, has_osrel: profile.sections.contains_key(".osrel"), - entry: BlsEntry::from_uki_profile(&profile, &path), + entry: BlsEntry::from_uki_profile_with(&profile, &path, strict), info: profile.info, }) .collect(), diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 9c37e9d..6ed425e 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -372,6 +372,9 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { .context("unable to get last booted entry from bootloader interface"), ); + // Whether to follow the specification and systemd-boot exactly. + let strict = context.root().options().bls_strict_mode; + // Acquire the oneshot entry from the bootloader interface. let bootloader_interface_oneshot_entry = advisory( BootloaderInterface::get_oneshot_entry() @@ -412,8 +415,13 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { .find(|timeout| !matches!(timeout, BootloaderInterfaceTimeout::Unspecified)) .unwrap_or_default(); - // The menu timeout in seconds, if no source specified one. - let mut menu_timeout = DEFAULT_MENU_TIMEOUT_SECONDS; + // The menu timeout in seconds, if no source specified one. systemd-boot does not show the + // menu by default, while outside of strict mode it is shown for a while. + let mut menu_timeout = if strict { + 0 + } else { + DEFAULT_MENU_TIMEOUT_SECONDS + }; // Whether the boot menu should wait for the user, instead of counting down. let mut wait_for_user = false; @@ -452,7 +460,13 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { } BootloaderInterfaceTimeout::Unspecified => { - // Do nothing. + if !strict { + info!( + "no menu timeout is set, so the menu is shown for {} seconds \ + (systemd-boot and strict mode do not show it)", + DEFAULT_MENU_TIMEOUT_SECONDS + ); + } } } @@ -486,7 +500,15 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { // no entry, such as when the entry was removed, is ignored so that the next source is used. // The preferred entry sources never use an entry with no boot counter tries left. // Each source has whether it matches by id, and whether it skips bad entries. + // In strict mode, the one-shot entry is the default entry for this boot, like in + // systemd-boot, and the menu or its timeout still decides what is booted. + let oneshot_source = if strict { + bootloader_interface_oneshot_entry.clone() + } else { + None + }; for (source, value, by_id, skip_bad) in [ + ("bootloader interface one-shot", oneshot_source, true, false), ( "bootloader interface preferred", bootloader_interface_preferred_entry.clone(), @@ -523,20 +545,22 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { let Some(pattern) = pattern else { continue; }; - let matches = |entry: &BootableEntry| { - let matched = if by_id { + let pattern_matches = |entry: &BootableEntry| { + if by_id { entry.is_match_id(&pattern) } else { entry.is_match(&pattern) - }; - matched && !(skip_bad && entry.is_bad()) + } }; + let matches = + |entry: &BootableEntry| pattern_matches(entry) && !(skip_bad && entry.is_bad()); // A source that only matches entries with no boot counter tries left is skipped while - // another source could pick a usable entry, unless every entry is bad. + // another source could pick a usable entry, unless every entry is bad. systemd-boot + // does not look at the tries for the default entry, so strict mode does not either. let any_usable = entries.iter().any(|entry| !entry.is_bad()); if entries .iter() - .any(|entry| matches(entry) && (!any_usable || !entry.is_bad())) + .any(|entry| matches(entry) && (strict || !any_usable || !entry.is_bad())) { // Mark the matching entries as the default and unmark all the others. for entry in &mut entries { @@ -548,6 +572,18 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { } break; } + if !skip_bad + && entries + .iter() + .any(|entry| pattern_matches(entry) && entry.is_bad()) + { + warn!( + "the {} entry '{}' has no boot counter tries left, so it is not the default \ + (systemd-boot and strict mode use it)", + source, pattern + ); + continue; + } warn!("ignoring {} entry '{}': no matching entry", source, pattern); } @@ -556,7 +592,11 @@ 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(), entry.is_extra_profile())) + .map(|entry| { + // In strict mode, an entry that was asked for stays the default even without tries. + let bad = entry.is_bad() && !(strict && entry.is_default()); + (entry.is_default(), bad, entry.is_extra_profile()) + }) .collect::>(), ); for (entry, default) in entries.iter_mut().zip(default_flags) { @@ -567,125 +607,151 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { } } - // Convert the menu timeout to a duration. - // A zero timeout boots without showing the menu, so a forced menu with a zero timeout - // waits for the user instead. - let menu_timeout = if wait_for_user || (force_boot_menu && menu_timeout == 0) { - None - } else { - Some(Duration::from_secs(menu_timeout)) - }; + loop { + // Convert the menu timeout to a duration. + // A zero timeout boots without showing the menu, so a forced menu with a zero timeout + // waits for the user instead. + let menu_timeout = if wait_for_user || (force_boot_menu && menu_timeout == 0) { + None + } else { + Some(Duration::from_secs(menu_timeout)) + }; - // Determine the menu style based on the options or configuration. - // We prefer the options over the configuration to allow for overriding. - let menu_style = context - .root() - .options() - .menu_style - .unwrap_or(config.options.menu_style); - - // Find the forced boot entry, unless the boot menu is forced. - // The oneshot entry from the bootloader interface is forced by id, and it is used instead of - // --boot. If the forced boot entry can't be found, such as when it was removed, - // the boot menu is used instead. - let forced_entry = if force_boot_menu { - None - } else if let Some(ref oneshot) = bootloader_interface_oneshot_entry { - let entry = BootableEntry::find_id(oneshot, entries.iter()); - if entry.is_none() { - warn!("unable to find entry '{}', using the boot menu", oneshot); - } - entry - } else { - force_boot_entry.as_ref().and_then(|force_boot_entry| { - let entry = BootableEntry::find(force_boot_entry, entries.iter()); - if entry.is_none() { - warn!( - "unable to find entry '{}', using the boot menu", - force_boot_entry - ); + // Determine the menu style based on the options or configuration. + // We prefer the options over the configuration to allow for overriding. + let menu_style = context + .root() + .options() + .menu_style + .unwrap_or(config.options.menu_style); + + // Find the forced boot entry, unless the boot menu is forced. + // The oneshot entry from the bootloader interface is forced by id, and it is used instead of + // --boot. If the forced boot entry can't be found, such as when it was removed, + // the boot menu is used instead. + let forced_entry = if force_boot_menu { + None + } else if !strict && let Some(ref oneshot) = bootloader_interface_oneshot_entry { + let entry = BootableEntry::find_id(oneshot, entries.iter()); + match entry { + Some(entry) => info!( + "booting the one-shot entry {} without showing the menu \ + (systemd-boot and strict mode only make it the default)", + entry.id() + ), + None => warn!("unable to find entry '{}', using the boot menu", oneshot), } entry - }) - }; + } else { + force_boot_entry.as_ref().and_then(|force_boot_entry| { + let entry = BootableEntry::find(force_boot_entry, entries.iter()); + if entry.is_none() { + warn!( + "unable to find entry '{}', using the boot menu", + force_boot_entry + ); + } + entry + }) + }; - // Use the forced boot entry if possible, otherwise pick an entry using a boot menu. - let entry = match forced_entry { - Some(entry) => entry, - // Delegate to the menu to select an entry to boot. - None => menu::select(&timer, menu_timeout, menu_disabled, menu_style, &entries) - .context("unable to select entry via boot menu")?, - }; + // Use the forced boot entry if possible, otherwise pick an entry using a boot menu. + let entry = match forced_entry { + Some(entry) => entry, + // Delegate to the menu to select an entry to boot. + None => menu::select(&timer, menu_timeout, menu_disabled, menu_style, &entries) + .context("unable to select entry via boot menu")?, + }; - // Tell the bootloader interface what the selected entry is. - advisory( - BootloaderInterface::set_selected_entry(entry.id()) - .context("unable to set selected entry in bootloader interface"), - ); + // Tell the bootloader interface what the selected entry is. + advisory( + BootloaderInterface::set_selected_entry(entry.id()) + .context("unable to set selected entry in bootloader interface"), + ); - // Save the selected entry for the next boot when `@saved` asks for it, and forget a saved - // entry that is no longer used. A forced boot, such as a one-shot entry, changes nothing, - // and neither does an unchanged value. - if forced_entry.is_none() { - let id = entry.id(); - if use_saved_entry { - if !last_booted_entry + // Save the selected entry for the next boot when `@saved` asks for it, and forget a saved + // entry that is no longer used. A forced boot, such as a one-shot entry, changes nothing, + // and neither does an unchanged value. + let oneshot_selected = strict + && bootloader_interface_oneshot_entry .as_deref() - .is_some_and(|saved| saved.eq_ignore_ascii_case(&id)) - { + .is_some_and(|oneshot| entry.is_match_id(oneshot)); + if forced_entry.is_none() && !oneshot_selected { + let id = entry.id(); + if use_saved_entry { + if !last_booted_entry + .as_deref() + .is_some_and(|saved| saved.eq_ignore_ascii_case(&id)) + { + advisory( + BootloaderInterface::set_last_booted_entry(&id) + .context("unable to save last booted entry in bootloader interface"), + ); + } + } else if last_booted_entry.is_some() { advisory( - BootloaderInterface::set_last_booted_entry(&id) - .context("unable to save last booted entry in bootloader interface"), + BootloaderInterface::remove_last_booted_entry() + .context("unable to remove last booted entry in bootloader interface"), ); } - } else if last_booted_entry.is_some() { - advisory( - BootloaderInterface::remove_last_booted_entry() - .context("unable to remove last booted entry in bootloader interface"), - ); } - } - // Execute the late phase, now that the entry is chosen but before its actions are executed. - phase(context.clone(), &config.phases.late).context("unable to execute late phase")?; - - // Consume a try from the boot counter of the selected entry. - // 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( - BootloaderInterface::set_boot_count_path(&path) - .context("unable to set boot count path in bootloader interface"), - ); + // Execute the late phase, now that the entry is chosen but before its actions are executed. + phase(context.clone(), &config.phases.late).context("unable to execute late phase")?; + + // Consume a try from the boot counter of the selected entry. + // 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( + BootloaderInterface::set_boot_count_path(&path) + .context("unable to set boot count path in bootloader interface"), + ); + } + Err(error) => warn!( + "unable to update boot counter of entry {}: {:#}", + entry.name(), + error + ), } - Err(error) => warn!( - "unable to update boot counter of entry {}: {:#}", - entry.name(), - error - ), } - } - // 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); - actions::execute(entry.context().clone(), &action) - .context(format!("unable to execute action '{}'", action))?; + // 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); + actions::execute(entry.context().clone(), &action) + .context(format!("unable to execute action '{}'", action))?; + } + + // Once the image returns, which is not usual, systemd-boot shows the menu again and waits, + // while outside of strict mode Sprout goes on to exit to the firmware. + if strict { + info!("the boot entry returned, showing the menu again"); + force_boot_menu = true; + wait_for_user = true; + menu_disabled = false; + continue; + } + info!( + "the boot entry returned, so Sprout exits to the firmware \ + (systemd-boot and strict mode show the menu again)" + ); + break; } Ok(()) From afbb1e0ef1ea3bb9b80c9763077de4326ccb3f62 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:18:54 -0700 Subject: [PATCH 05/10] feat(boot): read only the boot partitions for BLS in strict mode In strict mode autoconfigure reads BLS entries from the partition Sprout was loaded from and the extended boot loader partition of its disk, with one generator, and no longer makes entries from other disks. Other filesystems that have BLS are still not scanned for Linux kernels. --- crates/boot/src/autoconfigure.rs | 88 ++++++++++++++++++++++++---- crates/boot/src/autoconfigure/bls.rs | 70 ++++++++++++++-------- crates/boot/src/main.rs | 7 ++- 3 files changed, 128 insertions(+), 37 deletions(-) diff --git a/crates/boot/src/autoconfigure.rs b/crates/boot/src/autoconfigure.rs index 28e69cb..68b48e3 100644 --- a/crates/boot/src/autoconfigure.rs +++ b/crates/boot/src/autoconfigure.rs @@ -1,8 +1,11 @@ +use alloc::boxed::Box; use anyhow::{Context, Result}; use edera_sprout_config::RootConfiguration; use log::warn; +use uefi::Handle; use uefi::fs::FileSystem; use uefi::proto::device_path::DevicePath; +use uefi::proto::loaded_image::LoadedImage; use uefi::proto::media::fs::SimpleFileSystem; /// bls: autodetect and configure BLS-enabled filesystems. @@ -16,13 +19,47 @@ pub mod linux; /// windows: autodetect and configure Windows boot configurations. pub mod windows; +/// Find the filesystems that systemd-boot reads BLS entries from: the partition that Sprout +/// was loaded from, and the extended boot loader partition of the same disk. +fn strict_bls_sources(loaded_image_path: &DevicePath) -> (Option, Option) { + let own = eficore::handle::open_shared::(uefi::boot::image_handle()) + .ok() + .and_then(|image| image.device()); + let xbootldr = match eficore::xbootldr::find_xbootldr(loaded_image_path) { + Ok(Some(path)) => uefi::boot::locate_device_path::(&mut &*path).ok(), + Ok(None) => None, + Err(error) => { + warn!("unable to find the xbootldr partition: {:#}", error); + None + } + }; + (own, xbootldr) +} + /// Generate a [RootConfiguration] based on the environment. /// Intakes a `config` to use as the basis of the autoconfiguration. -pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> { +/// With `strict`, only the partition Sprout was loaded from and the extended boot loader +/// partition of its disk are read for BLS entries, as one generator, like systemd-boot does. +pub fn autoconfigure( + config: &mut RootConfiguration, + strict: bool, + loaded_image_path: &DevicePath, +) -> Result<()> { // Find all the filesystems that are on the system. let filesystem_handles = uefi::boot::find_handles::().context("unable to scan filesystems")?; + // The partitions that BLS entries are read from in strict mode. + let (own_handle, xbootldr_handle) = if strict { + strict_bls_sources(loaded_image_path) + } else { + (None, None) + }; + // The device path of the partition of Sprout, which names the strict generator. + let mut own_root: Option> = None; + // Whether either of the partitions has BLS entries, in strict mode. + let mut strict_found = false; + // For each filesystem that was detected, scan it for supported autoconfig mechanisms. for handle in filesystem_handles { // Acquire the device path root for the filesystem. @@ -51,15 +88,37 @@ pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> { // A scan that fails is skipped, as a filesystem that can't be read should not // prevent autoconfiguring the other filesystems. - // Scan the filesystem for BLS supported configurations. - let bls_found = match bls::scan(&mut filesystem, &root, config) { - Ok(found) => found, - Err(error) => { - warn!( - "unable to scan filesystem for bls configurations: {:#}", - error - ); - false + // Scan the filesystem for BLS supported configurations. In strict mode, only the + // partitions that systemd-boot reads get entries, and they get a single generator. + // The other filesystems that have BLS are still not scanned for Linux configurations. + let is_source = Some(handle) == own_handle || Some(handle) == xbootldr_handle; + if strict && Some(handle) == own_handle { + own_root = Some(root.to_boxed()); + } + let bls_found = if strict { + match bls::detect(&mut filesystem) { + Ok(found) => { + strict_found |= found && is_source; + found + } + Err(error) => { + warn!( + "unable to scan filesystem for bls configurations: {:#}", + error + ); + false + } + } + } else { + match bls::scan(&mut filesystem, &root, config) { + Ok(found) => found, + Err(error) => { + warn!( + "unable to scan filesystem for bls configurations: {:#}", + error + ); + false + } } }; @@ -80,5 +139,14 @@ pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> { } } + // In strict mode, one generator reads both partitions, with the root of Sprout's partition. + if strict + && strict_found + && let Some(root) = own_root + && let Err(error) = bls::add_generator(config, &root, true) + { + warn!("unable to add the bls generator: {:#}", error); + } + Ok(()) } diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index 3ee9de3..c6bcc33 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -18,12 +18,8 @@ use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly}; /// by the BLS generator to chainload entries. const BLS_CHAINLOAD_ACTION_PREFIX: &str = "bls-chainload-"; -/// Scan the specified `filesystem` for BLS configurations. -pub fn scan( - filesystem: &mut FileSystem, - root: &DevicePath, - config: &mut RootConfiguration, -) -> Result { +/// Detect whether the `filesystem` has a BLS supported configuration. +pub fn detect(filesystem: &mut FileSystem) -> Result { // BLS has a loader.conf file that can specify its own auto-entries mechanism. let bls_loader_conf_path = Path::new(cstr16!("\\loader\\loader.conf")); // BLS also has an entries directory that can specify explicit entries. @@ -31,17 +27,6 @@ pub fn scan( // BLS Type #2 entries are unified kernel images in the EFI/Linux directory. let bls_uki_path = Path::new(cstr16!("\\EFI\\Linux")); - // Convert the device path root to a string we can use in the configuration. - let mut root = root - .to_string16(DisplayOnly(false), AllowShortcuts(false)) - .context("unable to convert device root to string")? - .to_string(); - // Add a trailing forward-slash to the root to ensure the device root is completed. - root.push('/'); - - // Generate a unique hash of the root path. - let root_unique_hash = unique_hash(&root); - // Whether we have a loader.conf file. let has_loader_conf = filesystem .try_exists(bls_loader_conf_path) @@ -86,9 +71,26 @@ pub fn scan( // Detect if a BLS supported configuration is on this filesystem. // We check loader.conf, the entries directory and unified kernel images, as only one of // them is required. - if !(has_loader_conf || has_entries_dir || has_ukis) { - return Ok(false); - } + Ok(has_loader_conf || has_entries_dir || has_ukis) +} + +/// Add the BLS generator and its action for the partition with the device path `root`. +/// In `strict` mode, the generator also reads the extended boot loader partition of the disk. +pub fn add_generator( + config: &mut RootConfiguration, + root: &DevicePath, + strict: bool, +) -> Result<()> { + // Convert the device path root to a string we can use in the configuration. + let mut root = root + .to_string16(DisplayOnly(false), AllowShortcuts(false)) + .context("unable to convert device root to string")? + .to_string(); + // Add a trailing forward-slash to the root to ensure the device root is completed. + root.push('/'); + + // Generate a unique hash of the root path. + let root_unique_hash = unique_hash(&root); // Generate a unique name for the BLS chainload action. let chainload_action_name = format!("{}{}", BLS_CHAINLOAD_ACTION_PREFIX, root_unique_hash,); @@ -103,8 +105,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, + // gets a generator of its own, unless strict mode reads it with the generator of the + // partition that Sprout was loaded from. + xbootldr: strict, pin_names: true, }; @@ -121,15 +124,18 @@ pub fn scan( // BLS will provide these values to us. // Every initrd slot is chained in order. Unused slots stamp to the root of the // filesystem, which the chainload action skips. + // The files of an entry are on the partition of the entry, which is the root of the + // generator for most entries, and the extended boot loader partition for some in strict mode. + let entry_root = if strict { "$entry-root" } else { &root }; let chainload = ChainloadConfiguration { - path: format!("{}\\$chainload", root), + path: format!("{}\\$chainload", entry_root), options: vec!["$options".to_string()], linux_initrd: None, linux_initrd_chain: (0..BLS_INITRD_SLOTS) - .map(|slot| format!("{}\\$initrd-{}", root, slot)) + .map(|slot| format!("{}\\$initrd-{}", entry_root, slot)) .collect(), // An unset devicetree stamps to the root of the filesystem, which the action skips. - devicetree: Some(format!("{}\\$devicetree", root)), + devicetree: Some(format!("{}\\$devicetree", entry_root)), }; // Insert the chainload action into the configuration. @@ -141,6 +147,18 @@ pub fn scan( }, ); - // We had a BLS supported configuration, so return true. + Ok(()) +} + +/// Scan the specified `filesystem` for BLS configurations. +pub fn scan( + filesystem: &mut FileSystem, + root: &DevicePath, + config: &mut RootConfiguration, +) -> Result { + if !detect(filesystem)? { + return Ok(false); + } + add_generator(config, root, false)?; Ok(true) } diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 6ed425e..333035e 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -230,7 +230,12 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { // If --autoconfigure is specified or the loaded configuration has autoconfigure enabled, // trigger the autoconfiguration mechanism. if context.root().options().autoconfigure || config.options.autoconfigure { - autoconfigure::autoconfigure(&mut config).context("unable to autoconfigure")?; + autoconfigure::autoconfigure( + &mut config, + context.root().options().bls_strict_mode, + context.root().loaded_image_path()?, + ) + .context("unable to autoconfigure")?; } // Unload the context so that it can be modified. From 172fc6f7cee33103f2bbe0ccae6b27bfe839ab77 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:28:31 -0700 Subject: [PATCH 06/10] fix(boot): show the path of a missing BLS file in the warning --- crates/boot/src/generators/bls.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index ab6fe48..ec08763 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -278,13 +278,13 @@ fn generate_type1( { if strict { warn!( - "hiding bls entry {} as its file {} does not exist", + "hiding bls entry {} as its file '\\{}' does not exist", name, target ); continue; } warn!( - "bls entry {} refers to the file {}, which does not exist (strict mode hides it)", + "bls entry {} refers to the file '\\{}', which does not exist (strict mode hides it)", name, target ); } From 1e421813762d578252264aa7bf8542d185ef2d1f Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:28:31 -0700 Subject: [PATCH 07/10] docs(readme): document strict mode and the new precedence --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index be215bf..802cc3a 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ We recommend running Sprout without Secure Boot for development, and with Secure - [x] Load Linux initrd from disk - [x] Devicetree support - [x] Basic, simple, and graphical boot menus +- [x] A strict mode that follows the BLS specification and systemd-boot exactly - [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 @@ -137,6 +138,8 @@ $ sprout.efi --menu-timeout=10 $ sprout.efi --force-menu # Keep the boot console as it is when an entry is booted. $ sprout.efi --retain-boot-console +# Follow the BLS specification and systemd-boot exactly. +$ sprout.efi --bls-strict-mode ``` ### Boot Linux from ESP @@ -196,7 +199,7 @@ bls.path = "\\loader" # 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 +# which is sorted with the other entries. strict mode always does. 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 @@ -209,7 +212,8 @@ 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. +# an entry can have up to 32 initrds, as $initrd-0 to $initrd-31. unused ones are skipped. +# only the first eight are listed here, so add the others to boot an entry with more. chainload.linux-initrd-chain = [ "$entry-root\\$initrd-0", "$entry-root\\$initrd-1", @@ -224,6 +228,7 @@ chainload.linux-initrd-chain = [ 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. +If it can't be installed, Sprout warns and boots without it, unless strict mode is on. #### Boot counting @@ -262,14 +267,41 @@ Sprout uses the same variables as systemd-boot, so `bootctl`, `systemctl reboot `LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout`, and `LoaderConfigTimeoutOneShot`. -The default entry comes from the first of these that matches an entry: +The values that tools like `bootctl` set outrank `sprout.toml`, which outranks `loader.conf`. -1. `default-entry` in `sprout.toml` -2. `LoaderEntryPreferred`, then `preferred` in `loader.conf` -3. `LoaderEntryDefault`, then `default` in `loader.conf` +The default entry comes from the first of these that matches an entry: -The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `menu-timeout` in -`sprout.toml`, `LoaderConfigTimeout`, and `loader.conf`. +1. `LoaderEntryPreferred`, then `preferred` in `loader.conf` +2. `LoaderEntryDefault` +3. `default-entry` in `sprout.toml` +4. `default` in `loader.conf` + +The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `LoaderConfigTimeout`, +`menu-timeout` in `sprout.toml`, and `loader.conf`. + +#### Strict mode + +By default, Sprout differs from systemd-boot in a few places that are friendlier or safer for a bootloader that +reads the same files. Each of them is logged when it applies. `--bls-strict-mode`, or `bls-strict-mode = true` +in the options of `sprout.toml`, removes all of them. + +| Behavior | By default | In strict mode | +|------------------------------------------|-----------------------------------------------------------|-----------------------------------------------------------| +| One-shot entry (`LoaderEntryOneShot`) | Booted at once, without the menu. | Only the default for this boot, so the menu still shows. | +| Menu timeout that nothing sets | The menu is shown for 10 seconds. | The menu is hidden. | +| Default entry with no boot counter tries | Skipped for another entry. | Used, as `default` ignores the tries. | +| Entry with more than one of `linux`, `efi`, `uki` | Boots the first of them. | Hidden. | +| Entry whose file does not exist | Shown, and it fails when booted. | Hidden. | +| Unified kernel image without a name | Named after its file. | Hidden. | +| Version of a unified kernel image | Falls back to the kernel version in `.uname`. | Only the os-release fields. | +| Boot counter of a plain `efi` entry | Counted. | Not counted. | +| The extended boot loader partition | Read when `xbootldr` is set on the generator. | Always read. | +| Autoconfiguration | Reads BLS entries from every disk, one generator each. | Reads Sprout's partition and the XBOOTLDR of its disk, as one generator. | +| Boot entry that returns | Sprout exits to the firmware. | The menu is shown again. | +| Devicetree that can't be installed | Warns and boots without it. | The entry fails. | + +In strict mode, the actions of a hand-written generator have to use `$entry-root` for the files of +an entry, as entries can come from the extended boot loader partition. ### Generators From b598867c803f1875c58278acdc3f39c74813e932 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:41:47 -0700 Subject: [PATCH 08/10] fix(bls): keep the Sprout name and version fields outside strict mode --- crates/bls/src/lib.rs | 22 ++++++++++++++++------ crates/bls/src/uki.rs | 25 +++++++++++++++++++------ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index a1a0727..dbef8d2 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -817,20 +817,30 @@ mod tests { } #[test] - fn uki_title_and_version_use_every_systemd_fallback() { + fn uki_title_and_version_use_every_systemd_fallback_in_strict_mode() { let sections = uki_sections(&[( ".osrel", "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=7.1\nVERSION_ID=7\n", )]); - let entry = BlsEntry::from_uki(§ions, "/a.efi"); + let entry = BlsEntry::from_uki_with(§ions, "/a.efi", true); assert_eq!(entry.title.as_deref(), Some("img")); assert_eq!(entry.version.as_deref(), Some("7.1")); let sections = uki_sections(&[(".osrel", "NAME=N\nID=i\n")]); - assert_eq!( - BlsEntry::from_uki(§ions, "/a.efi").title.as_deref(), - Some("N") - ); + let entry = BlsEntry::from_uki_with(§ions, "/a.efi", true); + assert_eq!(entry.title.as_deref(), Some("N")); + } + + #[test] + fn uki_title_and_version_keep_the_sprout_fallbacks_outside_strict_mode() { + // The long VERSION would be added to the title, so it is not used. + let sections = uki_sections(&[( + ".osrel", + "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=\"24.04 LTS (Noble)\"\nVERSION_ID=7\n", + )]); + let entry = BlsEntry::from_uki(§ions, "/a.efi"); + assert_eq!(entry.title.as_deref(), Some("i")); + assert_eq!(entry.version.as_deref(), Some("7")); } #[test] diff --git a/crates/bls/src/uki.rs b/crates/bls/src/uki.rs index bb2b157..b391957 100644 --- a/crates/bls/src/uki.rs +++ b/crates/bls/src/uki.rs @@ -97,10 +97,11 @@ impl BlsEntry { } /// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`. - /// The title comes from `PRETTY_NAME`, `IMAGE_ID`, `NAME`, then `ID`. The version comes from - /// `IMAGE_VERSION`, `VERSION`, `VERSION_ID`, then `BUILD_ID`, as in systemd-boot, and then - /// from the `.uname` section unless `strict` is set. The sort key comes from `IMAGE_ID`, then - /// `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the image + /// With `strict`, the title comes from `PRETTY_NAME`, `IMAGE_ID`, `NAME`, then `ID`, and the + /// version from `IMAGE_VERSION`, `VERSION`, `VERSION_ID`, then `BUILD_ID`, as in systemd-boot. + /// Otherwise the title comes from `PRETTY_NAME`, then `ID`, and the version 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_with( sections: &BTreeMap>, @@ -117,10 +118,22 @@ impl BlsEntry { .map(ToString::to_string) }; let uname = section_text(sections, ".uname"); - let version = first(&["IMAGE_VERSION", "VERSION", "VERSION_ID", "BUILD_ID"]); + // systemd-boot looks at more fields. Outside of strict mode the version is not taken from + // VERSION, which is often a long name that would be added to the title. + let (title, version) = if strict { + ( + first(&["PRETTY_NAME", "IMAGE_ID", "NAME", "ID"]), + first(&["IMAGE_VERSION", "VERSION", "VERSION_ID", "BUILD_ID"]), + ) + } else { + ( + first(&["PRETTY_NAME", "ID"]), + first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]), + ) + }; Self { - title: first(&["PRETTY_NAME", "IMAGE_ID", "NAME", "ID"]), + title, version: version.or_else(|| uname.clone().filter(|_| !strict)), sort_key: first(&["IMAGE_ID", "ID"]), uki: Some(uki_path.to_string()), From 43a4ba93ee3f9923698f30e5241e6c80ef84e703 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:41:47 -0700 Subject: [PATCH 09/10] fix(boot): count tries without using them, add XBOOTLDR once, and warn about dropped initrds An entry that is not counted in strict mode still has its tries, so it can be bad. Strict mode adds the extended boot loader partition to the first BLS generator only. A chainload chain that does not list an initrd that the entry has now warns, as the kernel may not find its root without it, and strict autoconfigure says when it can't find the partition of Sprout. --- crates/boot/src/actions/chainload.rs | 19 +++++++++++++++++++ crates/boot/src/autoconfigure.rs | 18 ++++++++++++------ crates/boot/src/boot_counter.rs | 3 +++ crates/boot/src/generators/bls.rs | 15 +++++++++++---- crates/boot/src/main.rs | 8 ++++++-- 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/boot/src/actions/chainload.rs b/crates/boot/src/actions/chainload.rs index 2f00106..f147e13 100644 --- a/crates/boot/src/actions/chainload.rs +++ b/crates/boot/src/actions/chainload.rs @@ -1,4 +1,5 @@ use crate::context::SproutContext; +use crate::generators::bls::BLS_INITRD_SLOTS; use crate::phases::before_handoff; use alloc::boxed::Box; use alloc::format; @@ -160,6 +161,24 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat .chain(configuration.linux_initrd_chain.iter()) .map(|item| context.stamp(item)); + // A BLS entry can have more initrds than the chain lists. Those would be dropped without a + // word, and then the kernel may not find its root, so say so. + let chain_length = configuration.linux_initrd_chain.len(); + if chain_length > 0 { + for slot in chain_length..BLS_INITRD_SLOTS { + let placeholder = format!("$initrd-{}", slot); + let value = context.stamp(&placeholder); + if !value.is_empty() && value != placeholder { + warn!( + "the entry has an initrd for slot {}, but linux-initrd-chain only lists {}, \ + so it is not loaded", + slot, chain_length + ); + break; + } + } + } + // Read each initrd and concatenate the contents in order. // Paths that are empty after stamping are skipped. let mut initrd: Option> = None; diff --git a/crates/boot/src/autoconfigure.rs b/crates/boot/src/autoconfigure.rs index 68b48e3..9abab2d 100644 --- a/crates/boot/src/autoconfigure.rs +++ b/crates/boot/src/autoconfigure.rs @@ -140,12 +140,18 @@ pub fn autoconfigure( } // In strict mode, one generator reads both partitions, with the root of Sprout's partition. - if strict - && strict_found - && let Some(root) = own_root - && let Err(error) = bls::add_generator(config, &root, true) - { - warn!("unable to add the bls generator: {:#}", error); + if strict && strict_found { + match own_root { + Some(root) => { + if let Err(error) = bls::add_generator(config, &root, true) { + warn!("unable to add the bls generator: {:#}", error); + } + } + None => warn!( + "found BLS entries, but not the partition that Sprout was loaded from, \ + so none are used in strict mode" + ), + } } Ok(()) diff --git a/crates/boot/src/boot_counter.rs b/crates/boot/src/boot_counter.rs index 3d8f646..bbb232a 100644 --- a/crates/boot/src/boot_counter.rs +++ b/crates/boot/src/boot_counter.rs @@ -29,6 +29,9 @@ pub struct BootCounterTarget { pub file_name: String, /// The extension of the file name, such as `.conf`, in its original case. pub extension: String, + /// Whether booting the entry uses up a try. An entry whose tries are not used up still + /// has them, so it can be bad. + pub counting: bool, } impl BootCounterTarget { diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index ec08763..979b6db 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -8,6 +8,7 @@ use alloc::{ vec::Vec, }; use anyhow::{Context, Result}; +use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use core::{cmp::Ordering, str::FromStr}; use edera_sprout_bls::{ BlsEntry, BootCounter, is_reserved_entry_name, profile_id_suffix, sort_bls, strip_extension, @@ -37,6 +38,10 @@ const ARCHITECTURE: &str = "x64"; #[cfg(target_arch = "aarch64")] const ARCHITECTURE: &str = "aa64"; +/// Whether the extended boot loader partition was added by a generator in strict mode, which +/// does it once, so that more than one generator does not list its entries twice. +static STRICT_XBOOTLDR_ADDED: AtomicBool = AtomicBool::new(false); + /// The number of initrd slots set on each BLS entry. /// Entries set `initrd-0` through `initrd-31`, and slots without an initrd are empty. pub const BLS_INITRD_SLOTS: usize = 32; @@ -311,9 +316,7 @@ fn generate_type1( // systemd-boot only counts the tries of the entries it boots as a kernel or as a unified // kernel image, and not those of a plain EFI program. let counted = !(strict && entry.linux.is_none() && entry.uki.is_none()); - if let Some(counter) = boot_counter - && counted - { + if let Some(counter) = boot_counter { boot.set_boot_counter(BootCounterTarget { counter, filesystem: bls_resolved.filesystem_handle, @@ -321,6 +324,7 @@ fn generate_type1( id: name.clone(), file_name, extension, + counting: counted, }); } @@ -446,6 +450,7 @@ fn generate_type2( id: id.clone(), file_name: file_name.clone(), extension: extension.clone(), + counting: true, }); } found.push((entry, boot)); @@ -532,7 +537,9 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result { let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root)); diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 333035e..8184c3e 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -372,7 +372,7 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { ); // Acquire the entry that was saved by the previous boot. - let last_booted_entry = advisory( + let mut last_booted_entry = advisory( BootloaderInterface::get_last_booted_entry() .context("unable to get last booted entry from bootloader interface"), ); @@ -692,12 +692,14 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { BootloaderInterface::set_last_booted_entry(&id) .context("unable to save last booted entry in bootloader interface"), ); + last_booted_entry = Some(id.clone()); } } else if last_booted_entry.is_some() { advisory( BootloaderInterface::remove_last_booted_entry() .context("unable to remove last booted entry in bootloader interface"), ); + last_booted_entry = None; } } @@ -710,7 +712,9 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { // 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() { + if let Some(target) = entry.boot_counter() + && target.counting + { match target.consume() { Ok(path) => { info!("updated boot counter of entry {}: {}", entry.name(), path); From 8c726c0766c138e98a31f71901ada27f50ea2816 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:41:47 -0700 Subject: [PATCH 10/10] docs(readme): correct the strict mode table --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 802cc3a..7949fe9 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,8 @@ chainload.path = "$entry-root\\$chainload" chainload.options = ["$options"] chainload.devicetree = "$entry-root\\$devicetree" # an entry can have up to 32 initrds, as $initrd-0 to $initrd-31. unused ones are skipped. -# only the first eight are listed here, so add the others to boot an entry with more. +# only the first eight are listed here. add the others to boot an entry with more, +# as Sprout warns when an entry has an initrd that the chain does not list. chainload.linux-initrd-chain = [ "$entry-root\\$initrd-0", "$entry-root\\$initrd-1", @@ -267,7 +268,8 @@ Sprout uses the same variables as systemd-boot, so `bootctl`, `systemctl reboot `LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout`, and `LoaderConfigTimeoutOneShot`. -The values that tools like `bootctl` set outrank `sprout.toml`, which outranks `loader.conf`. +The values that tools like `bootctl` set in the bootloader interface outrank `sprout.toml`, which outranks +`loader.conf`. In strict mode, `LoaderEntryOneShot` is tried before everything else for the default entry. The default entry comes from the first of these that matches an entry: @@ -287,16 +289,16 @@ in the options of `sprout.toml`, removes all of them. | Behavior | By default | In strict mode | |------------------------------------------|-----------------------------------------------------------|-----------------------------------------------------------| -| One-shot entry (`LoaderEntryOneShot`) | Booted at once, without the menu. | Only the default for this boot, so the menu still shows. | +| One-shot entry (`LoaderEntryOneShot`) | Booted at once, without the menu. | Only the default for this boot. The menu and its timeout still apply. | | Menu timeout that nothing sets | The menu is shown for 10 seconds. | The menu is hidden. | | Default entry with no boot counter tries | Skipped for another entry. | Used, as `default` ignores the tries. | | Entry with more than one of `linux`, `efi`, `uki` | Boots the first of them. | Hidden. | | Entry whose file does not exist | Shown, and it fails when booted. | Hidden. | | Unified kernel image without a name | Named after its file. | Hidden. | -| Version of a unified kernel image | Falls back to the kernel version in `.uname`. | Only the os-release fields. | -| Boot counter of a plain `efi` entry | Counted. | Not counted. | +| Name and version of a unified kernel image | The name is `PRETTY_NAME` or `ID`, and the version is `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then `.uname`. | The fields systemd-boot uses: the name is `PRETTY_NAME`, `IMAGE_ID`, `NAME` or `ID`, and the version is `IMAGE_VERSION`, `VERSION`, `VERSION_ID` or `BUILD_ID`. | +| Boot counter of a plain `efi` entry | Counted. | Not counted, but its tries still make it bad. | | The extended boot loader partition | Read when `xbootldr` is set on the generator. | Always read. | -| Autoconfiguration | Reads BLS entries from every disk, one generator each. | Reads Sprout's partition and the XBOOTLDR of its disk, as one generator. | +| Autoconfiguration | Reads BLS entries from every disk, one generator each. | Reads BLS entries from Sprout's partition and the XBOOTLDR of its disk, as one generator. Windows and Linux entries are found as before. | | Boot entry that returns | Sprout exits to the firmware. | The menu is shown again. | | Devicetree that can't be installed | Warns and boots without it. | The entry fails. |