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); + // 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 - ); + // 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")?; + // 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"), - ); + // 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); + // 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))?; + // 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(())