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.
This commit is contained in:
alex authored and Alex Zenla committed 2026-10-04 10:55:07 -07:00
1 parent bd37e9058c
commit ca01353df4
4 files changed
+169 -31

No files matched your search

+27 -8
View File
@@ -62,6 +62,22 @@ fn resolve_optional_file(
Ok(Some(resolved)) 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<SproutContext>, path: &str) -> Result<Option<DeviceTree>> {
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. /// 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. /// This ensures that an image that is loaded but never started is not left in memory.
struct UnloadGuard(Option<Handle>); struct UnloadGuard(Option<Handle>);
@@ -180,14 +196,17 @@ pub fn chainload(context: Rc<SproutContext>, configuration: &ChainloadConfigurat
.devicetree .devicetree
.as_ref() .as_ref()
.map(|path| context.stamp(path)), .map(|path| context.stamp(path)),
) && let Some(resolved) = resolve_optional_file(&context, &path, "devicetree")? ) {
{ match install_devicetree(&context, &path) {
if SecureBoot::enabled().unwrap_or(true) { Ok(installed) => devicetree = installed,
warn!("ignoring the devicetree, as Secure Boot is enabled"); // systemd-boot does not boot an entry whose devicetree can't be installed. Outside
} else { // of strict mode it is booted without it, as before the devicetree was supported.
let content = resolved.read_file().context("unable to read devicetree")?; Err(error) if !context.root().options().bls_strict_mode => warn!(
devicetree = "booting without the devicetree {}, as it can't be installed: {:#} \
Some(DeviceTree::install(&content).context("unable to install the devicetree")?); (systemd-boot and strict mode do not boot the entry)",
path, error
),
Err(error) => return Err(error),
} }
} }
+60 -7
View File
@@ -15,7 +15,7 @@ use edera_sprout_bls::{
use edera_sprout_config::generators::bls::BlsConfiguration; use edera_sprout_config::generators::bls::BlsConfiguration;
use log::{info, warn}; use log::{info, warn};
use uefi::{ use uefi::{
cstr16, CString16, cstr16,
fs::{FileSystem, PathBuf}, fs::{FileSystem, PathBuf},
proto::device_path::text::{AllowShortcuts, DisplayOnly}, proto::device_path::text::{AllowShortcuts, DisplayOnly},
proto::media::fs::SimpleFileSystem, proto::media::fs::SimpleFileSystem,
@@ -38,8 +38,8 @@ const ARCHITECTURE: &str = "x64";
const ARCHITECTURE: &str = "aa64"; const ARCHITECTURE: &str = "aa64";
/// The number of initrd slots set on each BLS entry. /// The number of initrd slots set on each BLS entry.
/// Entries set `initrd-0` through `initrd-7`, and slots without an initrd are empty. /// Entries set `initrd-0` through `initrd-31`, and slots without an initrd are empty.
pub const BLS_INITRD_SLOTS: usize = 8; pub const BLS_INITRD_SLOTS: usize = 32;
// TODO(azenla): remove this once variable substitution is implemented. // TODO(azenla): remove this once variable substitution is implemented.
/// This function is used to remove the `tuned_initrd` variable from the initrd paths. /// This function is used to remove the `tuned_initrd` variable from the initrd paths.
@@ -138,6 +138,7 @@ fn generate_type1(
path: &str, path: &str,
entry_root: &str, entry_root: &str,
) -> Result<Vec<(BlsEntry, BootableEntry)>> { ) -> Result<Vec<(BlsEntry, BootableEntry)>> {
let strict = context.root().options().bls_strict_mode;
let mut entries = Vec::new(); let mut entries = Vec::new();
// Resolve the path to the BLS directory. // Resolve the path to the BLS directory.
@@ -252,6 +253,42 @@ fn generate_type1(
continue; 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. // Put the initrds through a quirk modifier to support Fedora.
let initrds = quirk_initrd_remove_tuned(entry.initrd_paths()); 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. // 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 { boot.set_boot_counter(BootCounterTarget {
counter, counter,
filesystem: bls_resolved.filesystem_handle, filesystem: bls_resolved.filesystem_handle,
@@ -307,7 +349,8 @@ fn generate_type2(
let directory_name = directory.to_string(); let directory_name = directory.to_string();
let mut found: Vec<(BlsEntry, BootableEntry)> = Vec::new(); 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. // Only unified kernel images that are built for this machine are entries.
if image.machine != PE_MACHINE { if image.machine != PE_MACHINE {
info!( info!(
@@ -362,6 +405,16 @@ fn generate_type2(
None => id.clone(), 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; let mut entry = profile.entry;
entry.boot_counter = boot_counter; entry.boot_counter = boot_counter;
@@ -470,7 +523,7 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
BlsConfiguration::root_of(&path), BlsConfiguration::root_of(&path),
) { ) {
Ok(entries) => entries, Ok(entries) => entries,
Err(error) if bls.xbootldr => { Err(error) if bls.xbootldr || context.root().options().bls_strict_mode => {
warn!("unable to generate bls entries: {:#}", error); warn!("unable to generate bls entries: {:#}", error);
Vec::new() Vec::new()
} }
@@ -479,7 +532,7 @@ pub fn generate(context: Rc<SproutContext>, bls: &BlsConfiguration) -> Result<Ve
// Add the entries of the Extended Boot Loader Partition, which are sorted together with the // 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. // others. A problem with it should not prevent booting from the entries that are found.
if bls.xbootldr { if bls.xbootldr || context.root().options().bls_strict_mode {
match xbootldr_root(&context) { match xbootldr_root(&context) {
Ok(Some(root)) => { Ok(Some(root)) => {
let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root)); let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root));
+2 -2
View File
@@ -62,7 +62,7 @@ impl ReadAt for FileReader {
/// Finds the `.efi` files in `directory` on `filesystem` and reads their sections. /// 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, /// 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. /// as one broken file should not prevent booting any of the other entries.
pub fn scan(filesystem: Handle, directory: &str) -> Result<Vec<UkiFile>> { pub fn scan(filesystem: Handle, directory: &str, strict: bool) -> Result<Vec<UkiFile>> {
let mut fs = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(filesystem) let mut fs = uefi::boot::open_protocol_exclusive::<SimpleFileSystem>(filesystem)
.context("unable to open the unified kernel image filesystem")?; .context("unable to open the unified kernel image filesystem")?;
let mut root = fs let mut root = fs
@@ -112,7 +112,7 @@ pub fn scan(filesystem: Handle, directory: &str) -> Result<Vec<UkiFile>> {
.map(|profile| UkiProfileEntry { .map(|profile| UkiProfileEntry {
index: profile.index, index: profile.index,
has_osrel: profile.sections.contains_key(".osrel"), 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, info: profile.info,
}) })
.collect(), .collect(),
+80 -14
View File
@@ -372,6 +372,9 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
.context("unable to get last booted entry from bootloader interface"), .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. // Acquire the oneshot entry from the bootloader interface.
let bootloader_interface_oneshot_entry = advisory( let bootloader_interface_oneshot_entry = advisory(
BootloaderInterface::get_oneshot_entry() BootloaderInterface::get_oneshot_entry()
@@ -412,8 +415,13 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
.find(|timeout| !matches!(timeout, BootloaderInterfaceTimeout::Unspecified)) .find(|timeout| !matches!(timeout, BootloaderInterfaceTimeout::Unspecified))
.unwrap_or_default(); .unwrap_or_default();
// The menu timeout in seconds, if no source specified one. // The menu timeout in seconds, if no source specified one. systemd-boot does not show the
let mut menu_timeout = DEFAULT_MENU_TIMEOUT_SECONDS; // 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. // Whether the boot menu should wait for the user, instead of counting down.
let mut wait_for_user = false; let mut wait_for_user = false;
@@ -452,7 +460,13 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
} }
BootloaderInterfaceTimeout::Unspecified => { 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. // 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. // 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. // 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 [ for (source, value, by_id, skip_bad) in [
("bootloader interface one-shot", oneshot_source, true, false),
( (
"bootloader interface preferred", "bootloader interface preferred",
bootloader_interface_preferred_entry.clone(), bootloader_interface_preferred_entry.clone(),
@@ -523,20 +545,22 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
let Some(pattern) = pattern else { let Some(pattern) = pattern else {
continue; continue;
}; };
let matches = |entry: &BootableEntry| { let pattern_matches = |entry: &BootableEntry| {
let matched = if by_id { if by_id {
entry.is_match_id(&pattern) entry.is_match_id(&pattern)
} else { } else {
entry.is_match(&pattern) 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 // 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()); let any_usable = entries.iter().any(|entry| !entry.is_bad());
if entries if entries
.iter() .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. // Mark the matching entries as the default and unmark all the others.
for entry in &mut entries { for entry in &mut entries {
@@ -548,6 +572,18 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
} }
break; 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); 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( let default_flags = edera_sprout_bls::resolve_default_flags(
&entries &entries
.iter() .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::<Vec<_>>(), .collect::<Vec<_>>(),
); );
for (entry, default) in entries.iter_mut().zip(default_flags) { for (entry, default) in entries.iter_mut().zip(default_flags) {
@@ -567,6 +607,7 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
} }
} }
loop {
// Convert the menu timeout to a duration. // Convert the menu timeout to a duration.
// A zero timeout boots without showing the menu, so a forced menu with a zero timeout // A zero timeout boots without showing the menu, so a forced menu with a zero timeout
// waits for the user instead. // waits for the user instead.
@@ -590,10 +631,15 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
// the boot menu is used instead. // the boot menu is used instead.
let forced_entry = if force_boot_menu { let forced_entry = if force_boot_menu {
None None
} else if let Some(ref oneshot) = bootloader_interface_oneshot_entry { } else if !strict && let Some(ref oneshot) = bootloader_interface_oneshot_entry {
let entry = BootableEntry::find_id(oneshot, entries.iter()); let entry = BootableEntry::find_id(oneshot, entries.iter());
if entry.is_none() { match entry {
warn!("unable to find entry '{}', using the boot menu", oneshot); 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 entry
} else { } else {
@@ -626,7 +672,11 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
// Save the selected entry for the next boot when `@saved` asks for it, and forget a saved // 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, // entry that is no longer used. A forced boot, such as a one-shot entry, changes nothing,
// and neither does an unchanged value. // and neither does an unchanged value.
if forced_entry.is_none() { let oneshot_selected = strict
&& bootloader_interface_oneshot_entry
.as_deref()
.is_some_and(|oneshot| entry.is_match_id(oneshot));
if forced_entry.is_none() && !oneshot_selected {
let id = entry.id(); let id = entry.id();
if use_saved_entry { if use_saved_entry {
if !last_booted_entry if !last_booted_entry
@@ -688,6 +738,22 @@ fn run(reboot_on_error: &mut bool) -> Result<()> {
.context(format!("unable to execute action '{}'", 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(()) Ok(())
} }