feat(boot): use loader.conf, @saved and the boot count path

Read \loader\loader.conf from the partition Sprout was loaded from and
use its default and timeout as the lowest priority source. Sprout now
picks the timeout in this order: LoaderConfigTimeoutOneShot,
--menu-timeout, the configuration, LoaderConfigTimeout, loader.conf. The
default entry order is the configuration, LoaderEntryDefault, loader.conf,
and a source that matches no entry falls through to the next one.

To tell an explicit menu-timeout from the built-in default, the
configuration option is now optional. Existing files are unaffected.

A loader.conf default of @saved selects the entry in
LoaderEntryLastBooted and saves the booted entry there on every boot.

After consuming a boot counter try, publish the renamed entry file in
LoaderBootCountPath and advertise the boot counting and saved entry
features.
This commit is contained in:
alex authored and Alex Zenla committed 2026-10-03 17:39:38 -07:00
1 parent 5c517473e3
commit eac6215a76
5 files changed
+210 -77

No files matched your search

+8
View File
@@ -1,5 +1,6 @@
use crate::boot_counter::BootCounterTarget; use crate::boot_counter::BootCounterTarget;
use crate::context::SproutContext; use crate::context::SproutContext;
use alloc::format;
use alloc::rc::Rc; use alloc::rc::Rc;
use alloc::string::{String, ToString}; use alloc::string::{String, ToString};
use edera_sprout_config::entries::EntryDeclaration; use edera_sprout_config::entries::EntryDeclaration;
@@ -132,6 +133,13 @@ impl BootableEntry {
glob_match(needle, &self.name) || glob_match(needle, &self.title) 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` suffix. This is how loader.conf selects entries.
/// The `needle` is a glob pattern, where `*` matches any sequence of characters.
pub fn is_match_id(&self, needle: &str) -> bool {
glob_match(needle, &self.name) || glob_match(needle, &format!("{}.conf", self.name))
}
/// Create a variant of this entry, with the name suffixed by `suffix` and using `context`. /// Create a variant of this entry, with the name suffixed by `suffix` and using `context`.
/// The actions of the entry are stamped with `context`, the same as the list generator. /// The actions of the entry are stamped with `context`, the same as the list generator.
/// Variants of the same entry keep the sort key of this entry, so they stay grouped /// Variants of the same entry keep the sort key of this entry, so they stay grouped
+130 -41
View File
@@ -12,13 +12,13 @@ use crate::{
use alloc::{ use alloc::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
format, format,
string::ToString, string::{String, ToString},
vec::Vec, vec::Vec,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use core::{ops::Deref, time::Duration}; use core::{ops::Deref, time::Duration};
use edera_sprout_bls::compare_versions; use edera_sprout_bls::{LoaderConf, LoaderTimeout, compare_versions};
use edera_sprout_config::RootConfiguration; use edera_sprout_config::{DEFAULT_MENU_TIMEOUT_SECONDS, RootConfiguration};
use eficore::{ use eficore::{
bootloader_interface::{BootloaderInterface, BootloaderInterfaceTimeout}, bootloader_interface::{BootloaderInterface, BootloaderInterfaceTimeout},
partition::PartitionGuidForm, partition::PartitionGuidForm,
@@ -83,6 +83,31 @@ fn advisory<T: Default>(result: Result<T>) -> T {
}) })
} }
/// Loads the loader.conf of the partition Sprout was loaded from.
/// A missing loader.conf has no settings.
fn load_loader_conf(context: &SproutContext) -> Result<LoaderConf> {
let Ok(content) = eficore::path::read_file_contents(
Some(context.root().loaded_image_path()?),
"\\loader\\loader.conf",
) else {
return Ok(LoaderConf::default());
};
// Measure the loader.conf into the TPM, as it changes how Sprout boots.
PlatformTpm::log_event(
PlatformTpm::PCR_BOOT_LOADER_CONFIG,
&content,
"sprout: loader.conf",
)
.context("unable to measure the loader.conf file into the TPM")?;
let Ok(content) = String::from_utf8(content) else {
warn!("ignoring loader.conf that is not valid UTF-8");
return Ok(LoaderConf::default());
};
Ok(LoaderConf::parse(&content))
}
/// Run Sprout, returning an error if one occurs. /// Run Sprout, returning an error if one occurs.
fn run() -> Result<()> { fn run() -> Result<()> {
// For safety reasons, we will note that Secure Boot is in beta on Sprout. // For safety reasons, we will note that Secure Boot is in beta on Sprout.
@@ -295,14 +320,6 @@ fn run() -> Result<()> {
entry.restamp_title(); entry.restamp_title();
entry.restamp_sort_key(); entry.restamp_sort_key();
// Mark this entry as the default entry if it is declared as such.
if let Some(ref default_entry) = config.options.default_entry {
// If the entry matches the default entry, mark it as the default entry.
if entry.is_match(default_entry) {
entry.mark_default();
}
}
true true
}); });
@@ -316,9 +333,9 @@ fn run() -> Result<()> {
.context("unable to set entries in bootloader interface"), .context("unable to set entries in bootloader interface"),
); );
// Acquire the timeout setting from the bootloader interface. // Acquire the timeouts from the bootloader interface.
let bootloader_interface_timeout = advisory( let bootloader_interface_timeouts = advisory(
BootloaderInterface::get_timeout().context("unable to get bootloader interface timeout"), BootloaderInterface::get_timeouts().context("unable to get bootloader interface timeouts"),
); );
// Acquire the default entry from the bootloader interface. // Acquire the default entry from the bootloader interface.
@@ -333,24 +350,53 @@ fn run() -> Result<()> {
.context("unable to get bootloader interface oneshot entry"), .context("unable to get bootloader interface oneshot entry"),
); );
// Load the loader.conf of the partition Sprout was loaded from.
let loader_conf = load_loader_conf(&context).context("unable to load loader.conf")?;
for warning in &loader_conf.warnings {
warn!("{}", warning);
}
// If --boot is specified, boot that entry immediately. // If --boot is specified, boot that entry immediately.
let mut force_boot_entry = context.root().options().boot.clone(); let mut force_boot_entry = context.root().options().boot.clone();
// If --force-menu is specified, show the boot menu regardless of the value of --boot. // 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; let mut force_boot_menu = context.root().options().force_menu;
// Determine the menu timeout in seconds based on the options or configuration. // Pick the menu timeout from the first source that specifies one, in this order:
// We prefer the options over the configuration to allow for overriding. // the one-shot timeout, --menu-timeout, the configuration, LoaderConfigTimeout, loader.conf.
let mut menu_timeout = context let seconds = |seconds: Option<u64>| {
.root() seconds.map_or(
.options() BootloaderInterfaceTimeout::Unspecified,
.menu_timeout BootloaderInterfaceTimeout::Timeout,
.unwrap_or(config.options.menu_timeout); )
};
let loader_conf_timeout = match loader_conf.timeout {
None => BootloaderInterfaceTimeout::Unspecified,
Some(LoaderTimeout::Seconds(0)) | Some(LoaderTimeout::Hidden) => {
BootloaderInterfaceTimeout::MenuHidden
}
Some(LoaderTimeout::Seconds(timeout)) => BootloaderInterfaceTimeout::Timeout(timeout),
Some(LoaderTimeout::Disabled) => BootloaderInterfaceTimeout::MenuDisabled,
Some(LoaderTimeout::Force) => BootloaderInterfaceTimeout::MenuForce,
};
let timeout = [
bootloader_interface_timeouts.oneshot,
seconds(context.root().options().menu_timeout),
seconds(config.options.menu_timeout),
bootloader_interface_timeouts.direct,
loader_conf_timeout,
]
.into_iter()
.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;
// 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;
// Apply bootloader interface timeout settings. // Apply the chosen timeout.
match bootloader_interface_timeout { match timeout {
BootloaderInterfaceTimeout::MenuForce => { BootloaderInterfaceTimeout::MenuForce => {
// Force the boot menu, and wait for the user to choose an entry. // Force the boot menu, and wait for the user to choose an entry.
force_boot_menu = true; force_boot_menu = true;
@@ -378,30 +424,54 @@ fn run() -> Result<()> {
} }
} }
// Apply bootloader interface default entry settings. // The loader.conf default entry, where @saved selects the entry of the previous boot.
if let Some(ref bootloader_interface_default_entry) = bootloader_interface_default_entry { let use_saved_entry = loader_conf.default.as_deref() == Some("@saved");
if entries let loader_conf_default_entry = if use_saved_entry {
.iter() advisory(
.any(|entry| entry.is_match(bootloader_interface_default_entry)) BootloaderInterface::get_last_booted_entry()
{ .context("unable to get last booted entry from bootloader interface"),
// Iterate over all the entries and mark the default entry as the one specified. )
} else {
loader_conf.default.clone()
};
// 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),
(
"bootloader interface",
bootloader_interface_default_entry,
false,
),
("loader.conf", loader_conf_default_entry, true),
] {
let Some(pattern) = pattern else {
continue;
};
let matches = |entry: &BootableEntry| {
if id_only {
entry.is_match_id(&pattern)
} else {
entry.is_match(&pattern)
}
};
if entries.iter().any(matches) {
// Mark the matching entries as the default and unmark all the others.
for entry in &mut entries { for entry in &mut entries {
// Mark the entry as the default entry if it matches the specified entry. if matches(entry) {
// If the entry does not match the specified entry, unmark it as the default entry.
if entry.is_match(bootloader_interface_default_entry) {
entry.mark_default(); entry.mark_default();
} else { } else {
entry.unmark_default(); entry.unmark_default();
} }
} }
} else { break;
// If no entry matches, such as when the entry was removed, the setting is ignored
// so that the configured default entry is still used.
warn!(
"ignoring bootloader interface default entry '{}': no matching entry",
bootloader_interface_default_entry
);
} }
warn!(
"ignoring {} default entry '{}': no matching entry",
source, pattern
);
} }
// Apply bootloader interface oneshot entry settings. // Apply bootloader interface oneshot entry settings.
@@ -474,6 +544,14 @@ fn run() -> Result<()> {
.context("unable to set selected entry in bootloader interface"), .context("unable to set selected entry in bootloader interface"),
); );
// Save the selected entry for the next boot when loader.conf asks for it.
if use_saved_entry {
advisory(
BootloaderInterface::set_last_booted_entry(entry.name())
.context("unable to save last booted entry in bootloader interface"),
);
}
// Execute the late phase, now that the entry is chosen but before its actions are executed. // 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")?; phase(context.clone(), &config.phases.late).context("unable to execute late phase")?;
@@ -483,7 +561,18 @@ fn run() -> Result<()> {
&& !target.counter.is_bad() && !target.counter.is_bad()
{ {
match target.consume() { match target.consume() {
Ok(path) => info!("updated boot counter of entry {}: {}", entry.name(), path), Ok(path) => {
info!("updated boot counter of entry {}: {}", entry.name(), path);
// Tell the system where the counter is, so it can mark the boot as good.
let path = format!(
"/{}",
path.to_string().replace('\\', "/").trim_start_matches('/')
);
advisory(
BootloaderInterface::set_boot_count_path(&path)
.context("unable to set boot count path in bootloader interface"),
);
}
Err(error) => warn!( Err(error) => warn!(
"unable to update boot counter of entry {}: {:#}", "unable to update boot counter of entry {}: {:#}",
entry.name(), entry.name(),
+12 -12
View File
@@ -81,9 +81,10 @@ pub struct OptionsConfiguration {
/// The entry to mark as the default entry, instead of the first entry. /// The entry to mark as the default entry, instead of the first entry.
#[serde(rename = "default-entry", default)] #[serde(rename = "default-entry", default)]
pub default_entry: Option<String>, pub default_entry: Option<String>,
/// The timeout of the boot menu. /// The timeout of the boot menu in seconds. When unset, the timeout comes from the
#[serde(rename = "menu-timeout", default = "default_menu_timeout")] /// bootloader interface or `loader.conf`, falling back to [DEFAULT_MENU_TIMEOUT_SECONDS].
pub menu_timeout: u64, #[serde(rename = "menu-timeout", default)]
pub menu_timeout: Option<u64>,
/// The style of boot menu to display. /// The style of boot menu to display.
#[serde(rename = "menu-style", default)] #[serde(rename = "menu-style", default)]
pub menu_style: MenuStyle, pub menu_style: MenuStyle,
@@ -114,7 +115,7 @@ impl Default for OptionsConfiguration {
fn default() -> Self { fn default() -> Self {
Self { Self {
default_entry: None, default_entry: None,
menu_timeout: default_menu_timeout(), menu_timeout: None,
menu_style: Default::default(), menu_style: Default::default(),
autoconfigure: false, autoconfigure: false,
} }
@@ -141,10 +142,6 @@ pub fn latest_version() -> u32 {
LATEST_VERSION LATEST_VERSION
} }
fn default_menu_timeout() -> u64 {
DEFAULT_MENU_TIMEOUT_SECONDS
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -167,11 +164,14 @@ mod tests {
assert_eq!(default.options.autoconfigure, empty.options.autoconfigure); assert_eq!(default.options.autoconfigure, empty.options.autoconfigure);
} }
#[test]
fn menu_timeout_is_unset_by_default() {
assert_eq!(RootConfiguration::default().options.menu_timeout, None);
assert_eq!(empty().options.menu_timeout, None);
}
#[test] #[test]
fn default_menu_timeout_is_not_zero() { fn default_menu_timeout_is_not_zero() {
assert_eq!( assert_ne!(DEFAULT_MENU_TIMEOUT_SECONDS, 0);
RootConfiguration::default().options.menu_timeout,
DEFAULT_MENU_TIMEOUT_SECONDS
);
} }
} }
+53 -24
View File
@@ -34,6 +34,16 @@ pub enum BootloaderInterfaceTimeout {
Unspecified, Unspecified,
} }
/// The timeouts set in the bootloader interface.
/// The one-shot timeout only applies to this boot, so it ranks above everything else.
#[derive(Default)]
pub struct BootloaderInterfaceTimeouts {
/// The timeout from LoaderConfigTimeoutOneShot.
pub oneshot: BootloaderInterfaceTimeout,
/// The timeout from LoaderConfigTimeout.
pub direct: BootloaderInterfaceTimeout,
}
/// Bootloader Interface support. /// Bootloader Interface support.
pub struct BootloaderInterface; pub struct BootloaderInterface;
@@ -54,6 +64,8 @@ impl BootloaderInterface {
| LoaderFeatures::MenuDisable | LoaderFeatures::MenuDisable
| LoaderFeatures::EntryDefault | LoaderFeatures::EntryDefault
| LoaderFeatures::EntryOneShot | LoaderFeatures::EntryOneShot
| LoaderFeatures::BootCounting
| LoaderFeatures::SavedEntry
} }
/// Tell the system that Sprout was initialized at the current time. /// Tell the system that Sprout was initialized at the current time.
@@ -263,45 +275,62 @@ impl BootloaderInterface {
Ok(Some(BootloaderInterfaceTimeout::Timeout(value))) Ok(Some(BootloaderInterfaceTimeout::Timeout(value)))
} }
/// Get the timeout from the bootloader interface. /// Get the timeouts from the bootloader interface.
/// This indicates how the menu should behave. /// This indicates how the menu should behave.
/// If no values are set, Unspecified is returned. /// A timeout that is not set is Unspecified.
pub fn get_timeout() -> Result<BootloaderInterfaceTimeout> { pub fn get_timeouts() -> Result<BootloaderInterfaceTimeouts> {
// Attempt to acquire the value of the LoaderConfigTimeoutOneShot variable. // Attempt to acquire the value of the LoaderConfigTimeoutOneShot variable.
// This should take precedence over the LoaderConfigTimeout variable.
let oneshot = Self::get_timeout_value("LoaderConfigTimeoutOneShot", true)
.context("unable to check for LoaderConfigTimeoutOneShot variable")?;
// If oneshot was found, return it.
// The specification says the one-shot timeout shows the menu on this boot, // The specification says the one-shot timeout shows the menu on this boot,
// and a value of 0 means the menu waits for the user. // and a value of 0 means the menu waits for the user.
if let Some(oneshot) = oneshot { let oneshot = Self::get_timeout_value("LoaderConfigTimeoutOneShot", true)
return Ok(match oneshot { .context("unable to check for LoaderConfigTimeoutOneShot variable")?
.map(|oneshot| match oneshot {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuForce, BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuForce,
BootloaderInterfaceTimeout::Timeout(value) => { BootloaderInterfaceTimeout::Timeout(value) => {
BootloaderInterfaceTimeout::MenuForceTimeout(value) BootloaderInterfaceTimeout::MenuForceTimeout(value)
} }
other => other, other => other,
}); })
} .unwrap_or_default();
// Attempt to acquire the value of the LoaderConfigTimeout variable. // Attempt to acquire the value of the LoaderConfigTimeout variable.
// This will be used if the LoaderConfigTimeoutOneShot variable is not set.
let direct = Self::get_timeout_value("LoaderConfigTimeout", false)
.context("unable to check for LoaderConfigTimeout variable")?;
// If direct was found, return it.
// The specification says that a value of 0 means that the menu should be hidden. // The specification says that a value of 0 means that the menu should be hidden.
if let Some(direct) = direct { let direct = Self::get_timeout_value("LoaderConfigTimeout", false)
return Ok(match direct { .context("unable to check for LoaderConfigTimeout variable")?
.map(|direct| match direct {
BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuHidden, BootloaderInterfaceTimeout::Timeout(0) => BootloaderInterfaceTimeout::MenuHidden,
other => other, other => other,
}); })
} .unwrap_or_default();
// If we reach here, we know that neither variable was set. Ok(BootloaderInterfaceTimeouts { oneshot, direct })
// We provide the unspecified value instead. }
Ok(BootloaderInterfaceTimeout::Unspecified)
/// Tell the system the path of the entry file whose boot counter was updated, relative
/// to the partition root, so that it can be marked as good once the boot succeeds.
pub fn set_boot_count_path(path: &str) -> Result<()> {
Self::VENDOR.set_cstr16(
"LoaderBootCountPath",
path,
VariableClass::BootAndRuntimeTemporary,
)
}
/// Get the entry that was saved by the previous boot, if any.
pub fn get_last_booted_entry() -> Result<Option<String>> {
Ok(Self::VENDOR
.get_cstr16("LoaderEntryLastBooted")
.context("unable to get last booted entry from bootloader interface")?
.filter(|value| !value.is_empty()))
}
/// Save the entry that is booting, so a later boot can select it again.
pub fn set_last_booted_entry(entry: &str) -> Result<()> {
Self::VENDOR.set_cstr16(
"LoaderEntryLastBooted",
entry,
VariableClass::BootAndRuntimePersistent,
)
} }
/// Get the default entry set by the bootloader interface. /// Get the default entry set by the bootloader interface.
+7
View File
@@ -14,6 +14,8 @@ use uefi_raw::table::runtime::{VariableAttributes, VariableVendor};
pub enum VariableClass { pub enum VariableClass {
/// The variable is available in Boot Services and Runtime Services and is not persistent. /// The variable is available in Boot Services and Runtime Services and is not persistent.
BootAndRuntimeTemporary, BootAndRuntimeTemporary,
/// The variable is available in Boot Services and Runtime Services and is persistent.
BootAndRuntimePersistent,
} }
impl VariableClass { impl VariableClass {
@@ -23,6 +25,11 @@ impl VariableClass {
VariableClass::BootAndRuntimeTemporary => { VariableClass::BootAndRuntimeTemporary => {
VariableAttributes::BOOTSERVICE_ACCESS | VariableAttributes::RUNTIME_ACCESS VariableAttributes::BOOTSERVICE_ACCESS | VariableAttributes::RUNTIME_ACCESS
} }
VariableClass::BootAndRuntimePersistent => {
VariableAttributes::BOOTSERVICE_ACCESS
| VariableAttributes::RUNTIME_ACCESS
| VariableAttributes::NON_VOLATILE
}
} }
} }
} }