diff --git a/crates/boot/src/boot_counter.rs b/crates/boot/src/boot_counter.rs index 3ffd6e6..3d8f646 100644 --- a/crates/boot/src/boot_counter.rs +++ b/crates/boot/src/boot_counter.rs @@ -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::() .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) + ::alignment(); diff --git a/crates/boot/src/entries.rs b/crates/boot/src/entries.rs index c3583cc..56d0e08 100644 --- a/crates/boot/src/entries.rs +++ b/crates/boot/src/entries.rs @@ -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, boot_counter: Option, + id_suffix: Option, } 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, + ) -> 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>( diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index b38d5bf..635344b 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -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, diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 5ffbbb9..35ee57b 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -94,17 +94,20 @@ fn load_loader_conf(context: &SproutContext) -> Result { }; // 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| { + 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| -> Option { + 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,17 +599,28 @@ 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()) { - advisory( - BootloaderInterface::set_last_booted_entry(entry.name()) - .context("unable to save last booted 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.as_deref() != Some(id.as_str()) { + 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::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. @@ -569,9 +628,9 @@ fn run() -> Result<()> { // 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);