mirror of
https://github.com/edera-dev/sprout.git
synced 2026-11-04 19:02:20 +00:00
fix(boot): match and publish BLS entry ids like systemd-boot
Entries now have an id, the file name without the boot counter and with its .conf or .efi extension in lower case, which is what systemd-boot publishes in LoaderEntries and LoaderEntrySelected and what bootctl expects. LoaderEntryDefault, LoaderEntryOneShot and loader.conf match by id, ignoring case, with *, ? and [a-z], and no longer match titles or numbers. A type 1 and a type 2 entry with the same name no longer clash. @saved now works from LoaderEntryDefault as well, stops saving when it is not asked for, and removes a stale saved entry. LoaderEntryPreferred and the loader.conf preferred key select an entry that skips bad boot counter entries. An entry with no tries left that is booted by hand is counted too, and a read-only entry file is left alone. Type 1 entries can name an architecture, which hides them on another architecture, and a uki with an optional profile. Files that start with auto- are ignored. loader.conf is read lossily, and a failed measurement no longer stops the boot.
This commit is contained in:
1 parent
a26a5f1639
commit
590eeead5e
4 files changed
+178
-70
No files matched your search
@@ -1,5 +1,5 @@
|
||||
use alloc::{format, string::String};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use edera_sprout_bls::BootCounter;
|
||||
use uefi::{
|
||||
CString16, Handle,
|
||||
@@ -69,6 +69,10 @@ impl BootCounterTarget {
|
||||
let info = file
|
||||
.get_boxed_info::<FileInfo>()
|
||||
.context("unable to get the entry file info")?;
|
||||
// A read-only file is left as it is, as the counter is not meant to be changed.
|
||||
if info.attribute().contains(FileAttribute::READ_ONLY) {
|
||||
bail!("the entry file is read-only");
|
||||
}
|
||||
// The info is a fixed header followed by the null-terminated UCS-2 name.
|
||||
let size =
|
||||
FILE_INFO_HEADER_SIZE + 2 * (new_name.len() + 1) + <FileInfo as Align>::alignment();
|
||||
|
||||
@@ -4,7 +4,7 @@ use alloc::format;
|
||||
use alloc::rc::Rc;
|
||||
use alloc::string::{String, ToString};
|
||||
use edera_sprout_config::entries::EntryDeclaration;
|
||||
use edera_sprout_parsing::glob_match;
|
||||
use edera_sprout_parsing::{fnmatch_ignore_case, glob_match};
|
||||
|
||||
/// Represents an entry that is stamped and ready to be booted.
|
||||
#[derive(Clone)]
|
||||
@@ -17,6 +17,7 @@ pub struct BootableEntry {
|
||||
pin_name: bool,
|
||||
sort_key: Option<String>,
|
||||
boot_counter: Option<BootCounterTarget>,
|
||||
id_suffix: Option<String>,
|
||||
}
|
||||
|
||||
impl BootableEntry {
|
||||
@@ -36,6 +37,7 @@ impl BootableEntry {
|
||||
pin_name: false,
|
||||
sort_key: None,
|
||||
boot_counter: None,
|
||||
id_suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +46,21 @@ impl BootableEntry {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Fetch the id of the entry, which is how the bootloader interface refers to it.
|
||||
/// A BLS entry has the id systemd-boot gives it: the name with the file extension, such as
|
||||
/// `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(),
|
||||
None => self.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
/// Fetch the title of the entry. This is usually a human-readable key.
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
@@ -133,13 +150,12 @@ impl BootableEntry {
|
||||
glob_match(needle, &self.name) || glob_match(needle, &self.title)
|
||||
}
|
||||
|
||||
/// Determine if this entry matches `needle` by comparing to the id of the entry, which is
|
||||
/// the name with or without the `.conf` or `.efi` suffix. This is how loader.conf selects entries.
|
||||
/// The `needle` is a glob pattern, where `*` matches any sequence of characters.
|
||||
/// Determine if this entry matches `needle` by comparing to the id or the name of the entry,
|
||||
/// so that both `fedora.conf` and `fedora` select the entry. This is how loader.conf and the
|
||||
/// bootloader interface select entries, so the `needle` is a pattern that is matched ignoring
|
||||
/// case and supports `*`, `?` and `[a-z]`.
|
||||
pub fn is_match_id(&self, needle: &str) -> bool {
|
||||
glob_match(needle, &self.name)
|
||||
|| glob_match(needle, &format!("{}.conf", self.name))
|
||||
|| glob_match(needle, &format!("{}.efi", self.name))
|
||||
fnmatch_ignore_case(needle, &self.id()) || fnmatch_ignore_case(needle, &self.name)
|
||||
}
|
||||
|
||||
/// Create a variant of this entry, with the name suffixed by `suffix` and using `context`.
|
||||
@@ -177,6 +193,14 @@ impl BootableEntry {
|
||||
.unwrap_or(&self.name)
|
||||
}
|
||||
|
||||
/// Find the first entry that matches `needle` by id inside the entry iterator `haystack`.
|
||||
pub fn find_id<'a>(
|
||||
needle: &str,
|
||||
haystack: impl Iterator<Item = &'a BootableEntry>,
|
||||
) -> Option<&'a BootableEntry> {
|
||||
haystack.into_iter().find(|entry| entry.is_match_id(needle))
|
||||
}
|
||||
|
||||
/// Find an entry by `needle` inside the entry iterator `haystack`.
|
||||
/// This will search for an entry by name, title, or index.
|
||||
pub fn find<'a>(
|
||||
|
||||
@@ -28,6 +28,13 @@ const PE_MACHINE: u16 = edera_sprout_bls::PE_MACHINE_X86_64;
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
const PE_MACHINE: u16 = edera_sprout_bls::PE_MACHINE_AARCH64;
|
||||
|
||||
/// The name of the architecture of this machine in BLS entries.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
const ARCHITECTURE: &str = "x64";
|
||||
/// The name of the architecture of this machine in BLS entries.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
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;
|
||||
@@ -63,7 +70,7 @@ fn bootable_entry(
|
||||
|
||||
let title_base = entry.title().unwrap_or_else(|| name.to_string());
|
||||
let chainload = entry.chainload_path().unwrap_or_default();
|
||||
let options = entry.options().unwrap_or_default();
|
||||
let options = entry.chainload_options().unwrap_or_default();
|
||||
let version = entry.version().unwrap_or_default();
|
||||
let machine_id = entry.machine_id().unwrap_or_default();
|
||||
|
||||
@@ -179,6 +186,10 @@ fn generate_type1(
|
||||
if file_name.starts_with('.') || stem.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Names that start with auto- are reserved for entries the boot loader makes itself.
|
||||
if file_name.starts_with("auto-") {
|
||||
continue;
|
||||
}
|
||||
let extension = extension.to_string();
|
||||
|
||||
// Split the boot counter off the file name. The entry id never includes the counter, so
|
||||
@@ -228,6 +239,11 @@ fn generate_type1(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore entries that are for another architecture.
|
||||
if !entry.matches_architecture(ARCHITECTURE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Put the initrds through a quirk modifier to support Fedora.
|
||||
let initrds = quirk_initrd_remove_tuned(entry.initrd_paths());
|
||||
|
||||
@@ -241,6 +257,7 @@ fn generate_type1(
|
||||
}
|
||||
|
||||
let mut boot = bootable_entry(context, bls, &name, &entry, &initrds);
|
||||
boot.set_id_suffix(&extension);
|
||||
|
||||
// Record where the boot counter lives so a try can be consumed when this entry boots.
|
||||
if let Some(counter) = boot_counter {
|
||||
@@ -308,10 +325,18 @@ fn generate_type2(
|
||||
}
|
||||
let id = id.to_string();
|
||||
|
||||
let mut entry = image.entry;
|
||||
entry.boot_counter = boot_counter;
|
||||
|
||||
let mut boot = bootable_entry(context, bls, &id, &entry, &[]);
|
||||
boot.set_id_suffix(&extension);
|
||||
|
||||
// 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(|(_, boot)| boot.name() == id)
|
||||
.any(|(_, other)| other.id() == boot.id())
|
||||
{
|
||||
warn!(
|
||||
"unified kernel image {} has the same id as another entry, skipping",
|
||||
@@ -320,10 +345,6 @@ fn generate_type2(
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut entry = image.entry;
|
||||
entry.boot_counter = boot_counter;
|
||||
|
||||
let mut boot = bootable_entry(context, bls, &id, &entry, &[]);
|
||||
if let Some(counter) = boot_counter {
|
||||
boot.set_boot_counter(BootCounterTarget {
|
||||
counter,
|
||||
|
||||
+115
-56
@@ -94,17 +94,20 @@ fn load_loader_conf(context: &SproutContext) -> Result<LoaderConf> {
|
||||
};
|
||||
|
||||
// Measure the loader.conf into the TPM, as it changes how Sprout boots.
|
||||
PlatformTpm::log_event(
|
||||
// A failure to measure is only a warning, as it was not Sprout's own configuration.
|
||||
if let Err(error) = PlatformTpm::log_event(
|
||||
PlatformTpm::PCR_BOOT_LOADER_CONFIG,
|
||||
&content,
|
||||
"sprout: loader.conf",
|
||||
)
|
||||
.context("unable to measure the loader.conf file into the TPM")?;
|
||||
) {
|
||||
warn!(
|
||||
"unable to measure the loader.conf file into the TPM: {:#}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let Ok(content) = String::from_utf8(content) else {
|
||||
warn!("ignoring loader.conf that is not valid UTF-8");
|
||||
return Ok(LoaderConf::default());
|
||||
};
|
||||
// Read what is valid, as a stray byte should not discard the whole file.
|
||||
let content = String::from_utf8_lossy(&content);
|
||||
Ok(LoaderConf::parse(&content))
|
||||
}
|
||||
|
||||
@@ -329,7 +332,7 @@ fn run() -> Result<()> {
|
||||
|
||||
// Tell the bootloader interface what entries are available.
|
||||
advisory(
|
||||
BootloaderInterface::set_entries(entries.iter().map(|entry| entry.name()))
|
||||
BootloaderInterface::set_entries(entries.iter().map(|entry| entry.id()))
|
||||
.context("unable to set entries in bootloader interface"),
|
||||
);
|
||||
|
||||
@@ -350,6 +353,18 @@ fn run() -> Result<()> {
|
||||
.context("unable to get bootloader interface default entry"),
|
||||
);
|
||||
|
||||
// Acquire the preferred entry from the bootloader interface.
|
||||
let bootloader_interface_preferred_entry = advisory(
|
||||
BootloaderInterface::get_preferred_entry()
|
||||
.context("unable to get bootloader interface preferred entry"),
|
||||
);
|
||||
|
||||
// Acquire the entry that was saved by the previous boot.
|
||||
let last_booted_entry = advisory(
|
||||
BootloaderInterface::get_last_booted_entry()
|
||||
.context("unable to get last booted entry from bootloader interface"),
|
||||
);
|
||||
|
||||
// Acquire the oneshot entry from the bootloader interface.
|
||||
let bootloader_interface_oneshot_entry = advisory(
|
||||
BootloaderInterface::get_oneshot_entry()
|
||||
@@ -357,7 +372,7 @@ fn run() -> Result<()> {
|
||||
);
|
||||
|
||||
// If --boot is specified, boot that entry immediately.
|
||||
let mut force_boot_entry = context.root().options().boot.clone();
|
||||
let force_boot_entry = context.root().options().boot.clone();
|
||||
// If --force-menu is specified, show the boot menu regardless of the value of --boot.
|
||||
let mut force_boot_menu = context.root().options().force_menu;
|
||||
|
||||
@@ -424,43 +439,77 @@ fn run() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// The loader.conf default entry, where @saved selects the entry of the previous boot.
|
||||
let use_saved_entry = loader_conf.default.as_deref() == Some("@saved");
|
||||
let saved_entry = if use_saved_entry {
|
||||
advisory(
|
||||
BootloaderInterface::get_last_booted_entry()
|
||||
.context("unable to get last booted entry from bootloader interface"),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
// Whether the default entry is saved on every boot, which is what `@saved` asks for.
|
||||
// The saved entry is selected by `@saved` from the bootloader interface, or from loader.conf
|
||||
// when the bootloader interface does not set an entry.
|
||||
let is_saved = |value: &Option<String>| {
|
||||
value
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("@saved"))
|
||||
};
|
||||
let loader_conf_default_entry = if use_saved_entry {
|
||||
saved_entry.clone()
|
||||
} else {
|
||||
loader_conf.default.clone()
|
||||
let use_saved_entry = is_saved(&bootloader_interface_default_entry)
|
||||
|| (bootloader_interface_default_entry.is_none() && is_saved(&loader_conf.default));
|
||||
|
||||
// Turns the value of an entry setting into a pattern. `@saved` is the entry that was saved
|
||||
// by the previous boot, and any other value that starts with `@` is not supported.
|
||||
let entry_pattern = |value: Option<String>| -> Option<String> {
|
||||
let value = value?;
|
||||
if value.eq_ignore_ascii_case("@saved") {
|
||||
return last_booted_entry.clone();
|
||||
}
|
||||
if value.starts_with('@') {
|
||||
warn!("ignoring unsupported entry setting '{}'", value);
|
||||
return None;
|
||||
}
|
||||
Some(value)
|
||||
};
|
||||
|
||||
// Pick the default entry from the first source that matches an entry, in this order:
|
||||
// the configuration, LoaderEntryDefault, loader.conf. A source that matches no entry,
|
||||
// such as when the entry was removed, is ignored so that the next source is used.
|
||||
for (source, pattern, id_only) in [
|
||||
("configuration", config.options.default_entry.clone(), false),
|
||||
// Pick the default entry from the first source that matches an entry. 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, pattern, by_id, skip_bad) in [
|
||||
(
|
||||
"bootloader interface",
|
||||
bootloader_interface_default_entry,
|
||||
"configuration",
|
||||
config.options.default_entry.clone(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"bootloader interface preferred",
|
||||
entry_pattern(bootloader_interface_preferred_entry),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"loader.conf preferred",
|
||||
entry_pattern(loader_conf.preferred.clone()),
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"bootloader interface default",
|
||||
entry_pattern(bootloader_interface_default_entry.clone()),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"loader.conf default",
|
||||
entry_pattern(loader_conf.default.clone()),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
("loader.conf", loader_conf_default_entry, true),
|
||||
] {
|
||||
let Some(pattern) = pattern else {
|
||||
continue;
|
||||
};
|
||||
let matches = |entry: &BootableEntry| {
|
||||
if id_only {
|
||||
let matched = if by_id {
|
||||
entry.is_match_id(&pattern)
|
||||
} else {
|
||||
entry.is_match(&pattern)
|
||||
}
|
||||
};
|
||||
matched && !(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.
|
||||
@@ -479,16 +528,7 @@ fn run() -> Result<()> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
warn!(
|
||||
"ignoring {} default entry '{}': no matching entry",
|
||||
source, pattern
|
||||
);
|
||||
}
|
||||
|
||||
// Apply bootloader interface oneshot entry settings.
|
||||
// If set, we will force booting the oneshot entry.
|
||||
if let Some(ref bootloader_interface_oneshot_entry) = bootloader_interface_oneshot_entry {
|
||||
force_boot_entry = Some(bootloader_interface_oneshot_entry.clone());
|
||||
warn!("ignoring {} entry '{}': no matching entry", source, pattern);
|
||||
}
|
||||
|
||||
// Entries that have no boot counter tries left are never picked automatically.
|
||||
@@ -525,12 +565,19 @@ fn run() -> Result<()> {
|
||||
.unwrap_or(config.options.menu_style);
|
||||
|
||||
// Find the forced boot entry, unless the boot menu is forced.
|
||||
// If the forced boot entry can't be found, such as when it was removed,
|
||||
// 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 = force_boot_entry
|
||||
.as_ref()
|
||||
.filter(|_| !force_boot_menu)
|
||||
.and_then(|force_boot_entry| {
|
||||
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!(
|
||||
@@ -539,7 +586,8 @@ fn run() -> Result<()> {
|
||||
);
|
||||
}
|
||||
entry
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Use the forced boot entry if possible, otherwise pick an entry using a boot menu.
|
||||
let entry = match forced_entry {
|
||||
@@ -551,27 +599,38 @@ fn run() -> Result<()> {
|
||||
|
||||
// Tell the bootloader interface what the selected entry is.
|
||||
advisory(
|
||||
BootloaderInterface::set_selected_entry(entry.name().to_string())
|
||||
BootloaderInterface::set_selected_entry(entry.id())
|
||||
.context("unable to set selected entry in bootloader interface"),
|
||||
);
|
||||
|
||||
// Save the selected entry for the next boot when loader.conf asks for it.
|
||||
// A forced boot, such as a one-shot entry, is not saved, and neither is an unchanged value.
|
||||
if use_saved_entry && forced_entry.is_none() && saved_entry.as_deref() != Some(entry.name()) {
|
||||
// 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.as_deref() != Some(id.as_str()) {
|
||||
advisory(
|
||||
BootloaderInterface::set_last_booted_entry(entry.name())
|
||||
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::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.
|
||||
if let Some(target) = entry.boot_counter()
|
||||
&& !target.counter.is_bad()
|
||||
{
|
||||
// An entry with no tries left, which was picked by hand, is counted too, so that it can
|
||||
// still be marked as good.
|
||||
if let Some(target) = entry.boot_counter() {
|
||||
match target.consume() {
|
||||
Ok(path) => {
|
||||
info!("updated boot counter of entry {}: {}", entry.name(), path);
|
||||
|
||||
Reference in new issue
Block a user