diff --git a/README.md b/README.md index be215bf..7949fe9 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ We recommend running Sprout without Secure Boot for development, and with Secure - [x] Load Linux initrd from disk - [x] Devicetree support - [x] Basic, simple, and graphical boot menus +- [x] A strict mode that follows the BLS specification and systemd-boot exactly - [x] Generators for BLS entries, lists, and matrices, with variants - [x] BLS autoconfiguration support - [x] [Secure Boot support](https://github.com/edera-dev/sprout/issues/20): beta @@ -137,6 +138,8 @@ $ sprout.efi --menu-timeout=10 $ sprout.efi --force-menu # Keep the boot console as it is when an entry is booted. $ sprout.efi --retain-boot-console +# Follow the BLS specification and systemd-boot exactly. +$ sprout.efi --bls-strict-mode ``` ### Boot Linux from ESP @@ -196,7 +199,7 @@ bls.path = "\\loader" # the device of the path. an empty path turns unified kernel images off. bls.uki-path = "\\EFI\\Linux" # also read the extended boot loader partition (XBOOTLDR) of the same disk, -# which is sorted with the other entries. its files are on that partition, so +# which is sorted with the other entries. strict mode always does. its files are on that partition, so # the action has to use $entry-root, which is empty for Sprout's own partition. bls.xbootldr = false # keep the name of the entry file as the name of the entry, so it matches @@ -209,7 +212,9 @@ bls.entry.actions = ["boot-bls"] chainload.path = "$entry-root\\$chainload" chainload.options = ["$options"] chainload.devicetree = "$entry-root\\$devicetree" -# an entry can have up to eight initrds. unused ones are skipped. +# an entry can have up to 32 initrds, as $initrd-0 to $initrd-31. unused ones are skipped. +# only the first eight are listed here. add the others to boot an entry with more, +# as Sprout warns when an entry has an initrd that the chain does not list. chainload.linux-initrd-chain = [ "$entry-root\\$initrd-0", "$entry-root\\$initrd-1", @@ -224,6 +229,7 @@ chainload.linux-initrd-chain = [ An entry that names a `devicetree` boots with that devicetree installed for the image, which is put back when the image returns. It is not used when Secure Boot is enabled, as it can't be verified. +If it can't be installed, Sprout warns and boots without it, unless strict mode is on. #### Boot counting @@ -262,14 +268,42 @@ Sprout uses the same variables as systemd-boot, so `bootctl`, `systemctl reboot `LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout`, and `LoaderConfigTimeoutOneShot`. -The default entry comes from the first of these that matches an entry: +The values that tools like `bootctl` set in the bootloader interface outrank `sprout.toml`, which outranks +`loader.conf`. In strict mode, `LoaderEntryOneShot` is tried before everything else for the default entry. -1. `default-entry` in `sprout.toml` -2. `LoaderEntryPreferred`, then `preferred` in `loader.conf` -3. `LoaderEntryDefault`, then `default` in `loader.conf` +The default entry comes from the first of these that matches an entry: -The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `menu-timeout` in -`sprout.toml`, `LoaderConfigTimeout`, and `loader.conf`. +1. `LoaderEntryPreferred`, then `preferred` in `loader.conf` +2. `LoaderEntryDefault` +3. `default-entry` in `sprout.toml` +4. `default` in `loader.conf` + +The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `LoaderConfigTimeout`, +`menu-timeout` in `sprout.toml`, and `loader.conf`. + +#### Strict mode + +By default, Sprout differs from systemd-boot in a few places that are friendlier or safer for a bootloader that +reads the same files. Each of them is logged when it applies. `--bls-strict-mode`, or `bls-strict-mode = true` +in the options of `sprout.toml`, removes all of them. + +| Behavior | By default | In strict mode | +|------------------------------------------|-----------------------------------------------------------|-----------------------------------------------------------| +| One-shot entry (`LoaderEntryOneShot`) | Booted at once, without the menu. | Only the default for this boot. The menu and its timeout still apply. | +| Menu timeout that nothing sets | The menu is shown for 10 seconds. | The menu is hidden. | +| Default entry with no boot counter tries | Skipped for another entry. | Used, as `default` ignores the tries. | +| Entry with more than one of `linux`, `efi`, `uki` | Boots the first of them. | Hidden. | +| Entry whose file does not exist | Shown, and it fails when booted. | Hidden. | +| Unified kernel image without a name | Named after its file. | Hidden. | +| Name and version of a unified kernel image | The name is `PRETTY_NAME` or `ID`, and the version is `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then `.uname`. | The fields systemd-boot uses: the name is `PRETTY_NAME`, `IMAGE_ID`, `NAME` or `ID`, and the version is `IMAGE_VERSION`, `VERSION`, `VERSION_ID` or `BUILD_ID`. | +| Boot counter of a plain `efi` entry | Counted. | Not counted, but its tries still make it bad. | +| The extended boot loader partition | Read when `xbootldr` is set on the generator. | Always read. | +| Autoconfiguration | Reads BLS entries from every disk, one generator each. | Reads BLS entries from Sprout's partition and the XBOOTLDR of its disk, as one generator. Windows and Linux entries are found as before. | +| Boot entry that returns | Sprout exits to the firmware. | The menu is shown again. | +| Devicetree that can't be installed | Warns and boots without it. | The entry fails. | + +In strict mode, the actions of a hand-written generator have to use `$entry-root` for the files of +an entry, as entries can come from the extended boot loader partition. ### Generators diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index bbdd3b5..dbef8d2 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -199,6 +199,16 @@ impl BlsEntry { self.linux.is_some() || self.efi.is_some() || self.uki.is_some() } + /// Whether the entry names more than one of `linux`, `efi` and `uki` to boot. + /// systemd-boot considers such an entry broken and does not show it. + pub fn mixes_boot_targets(&self) -> bool { + [&self.linux, &self.efi, &self.uki] + .iter() + .filter(|target| target.is_some()) + .count() + > 1 + } + /// Whether the entry only boots a unified kernel image, which carries its own initrd. fn is_uki_only(&self) -> bool { self.linux.is_none() && self.efi.is_none() && self.uki.is_some() @@ -806,6 +816,59 @@ mod tests { assert!(entry.is_valid()); } + #[test] + fn uki_title_and_version_use_every_systemd_fallback_in_strict_mode() { + let sections = uki_sections(&[( + ".osrel", + "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=7.1\nVERSION_ID=7\n", + )]); + let entry = BlsEntry::from_uki_with(§ions, "/a.efi", true); + assert_eq!(entry.title.as_deref(), Some("img")); + assert_eq!(entry.version.as_deref(), Some("7.1")); + + let sections = uki_sections(&[(".osrel", "NAME=N\nID=i\n")]); + let entry = BlsEntry::from_uki_with(§ions, "/a.efi", true); + assert_eq!(entry.title.as_deref(), Some("N")); + } + + #[test] + fn uki_title_and_version_keep_the_sprout_fallbacks_outside_strict_mode() { + // The long VERSION would be added to the title, so it is not used. + let sections = uki_sections(&[( + ".osrel", + "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=\"24.04 LTS (Noble)\"\nVERSION_ID=7\n", + )]); + let entry = BlsEntry::from_uki(§ions, "/a.efi"); + assert_eq!(entry.title.as_deref(), Some("i")); + assert_eq!(entry.version.as_deref(), Some("7")); + } + + #[test] + fn uki_version_only_falls_back_to_uname_outside_strict_mode() { + let sections = uki_sections(&[(".osrel", "ID=a\n"), (".uname", "6.1.2\n")]); + let loose = BlsEntry::from_uki_with(§ions, "/a.efi", false); + assert_eq!(loose.version.as_deref(), Some("6.1.2")); + let strict = BlsEntry::from_uki_with(§ions, "/a.efi", true); + assert_eq!(strict.version, None); + // The kernel version is still there, it is just not the version of the entry. + assert_eq!(strict.uname.as_deref(), Some("6.1.2")); + } + + #[test] + fn entries_that_mix_boot_targets_are_found() { + for (input, mixed) in [ + ("linux /v\n", false), + ("efi /e.efi\n", false), + ("uki /u.efi\n", false), + ("linux /v\nefi /e.efi\n", true), + ("linux /v\nuki /u.efi\n", true), + ("efi /e.efi\nuki /u.efi\n", true), + ] { + let entry: BlsEntry = input.parse().unwrap(); + assert_eq!(entry.mixes_boot_targets(), mixed, "{input}"); + } + } + #[test] fn uki_entry_falls_back_through_the_fields() { let sections = uki_sections(&[(".osrel", "ID=arch\nBUILD_ID=rolling\n")]); diff --git a/crates/bls/src/uki.rs b/crates/bls/src/uki.rs index acf4b8a..b391957 100644 --- a/crates/bls/src/uki.rs +++ b/crates/bls/src/uki.rs @@ -75,7 +75,12 @@ impl BlsEntry { /// Produces an entry for one profile of the unified kernel image at `uki_path`. /// A profile after the first is booted with `@` in its load options. pub fn from_uki_profile(profile: &UkiProfile, uki_path: &str) -> Self { - let mut entry = Self::from_uki(&profile.sections, uki_path); + Self::from_uki_profile_with(profile, uki_path, false) + } + + /// Like [BlsEntry::from_uki_profile], and `strict` follows systemd-boot exactly. + pub fn from_uki_profile_with(profile: &UkiProfile, uki_path: &str, strict: bool) -> Self { + let mut entry = Self::from_uki_with(&profile.sections, uki_path, strict); if let Some(index) = profile.index { entry.profile = (index > 0).then(|| index.to_string()); entry.title = entry @@ -87,11 +92,22 @@ impl BlsEntry { } /// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`. - /// The title comes from `PRETTY_NAME`, then `ID`. The version comes from `IMAGE_VERSION`, - /// `VERSION_ID`, `BUILD_ID`, then the `.uname` section. The sort key comes from `IMAGE_ID`, - /// then `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the - /// image reads its own command line. pub fn from_uki(sections: &BTreeMap>, uki_path: &str) -> Self { + Self::from_uki_with(sections, uki_path, false) + } + + /// Produces an entry for the unified kernel image at `uki_path`, from its PE `sections`. + /// With `strict`, the title comes from `PRETTY_NAME`, `IMAGE_ID`, `NAME`, then `ID`, and the + /// version from `IMAGE_VERSION`, `VERSION`, `VERSION_ID`, then `BUILD_ID`, as in systemd-boot. + /// Otherwise the title comes from `PRETTY_NAME`, then `ID`, and the version from + /// `IMAGE_VERSION`, `VERSION_ID`, `BUILD_ID`, then the `.uname` section. The sort key comes + /// from `IMAGE_ID`, then `ID`. The embedded command line is kept in `cmdline` and not in `options`, as the image + /// reads its own command line. + pub fn from_uki_with( + sections: &BTreeMap>, + uki_path: &str, + strict: bool, + ) -> Self { let os_release = section_text(sections, ".osrel") .map(|text| OsRelease::parse(&text)) .unwrap_or_default(); @@ -102,10 +118,23 @@ impl BlsEntry { .map(ToString::to_string) }; let uname = section_text(sections, ".uname"); + // systemd-boot looks at more fields. Outside of strict mode the version is not taken from + // VERSION, which is often a long name that would be added to the title. + let (title, version) = if strict { + ( + first(&["PRETTY_NAME", "IMAGE_ID", "NAME", "ID"]), + first(&["IMAGE_VERSION", "VERSION", "VERSION_ID", "BUILD_ID"]), + ) + } else { + ( + first(&["PRETTY_NAME", "ID"]), + first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]), + ) + }; Self { - title: first(&["PRETTY_NAME", "ID"]), - version: first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]).or_else(|| uname.clone()), + title, + version: version.or_else(|| uname.clone().filter(|_| !strict)), sort_key: first(&["IMAGE_ID", "ID"]), uki: Some(uki_path.to_string()), cmdline: section_text(sections, ".cmdline"), diff --git a/crates/boot/src/actions/chainload.rs b/crates/boot/src/actions/chainload.rs index ca551f4..f147e13 100644 --- a/crates/boot/src/actions/chainload.rs +++ b/crates/boot/src/actions/chainload.rs @@ -1,4 +1,5 @@ use crate::context::SproutContext; +use crate::generators::bls::BLS_INITRD_SLOTS; use crate::phases::before_handoff; use alloc::boxed::Box; use alloc::format; @@ -62,6 +63,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); @@ -144,6 +161,24 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat .chain(configuration.linux_initrd_chain.iter()) .map(|item| context.stamp(item)); + // A BLS entry can have more initrds than the chain lists. Those would be dropped without a + // word, and then the kernel may not find its root, so say so. + let chain_length = configuration.linux_initrd_chain.len(); + if chain_length > 0 { + for slot in chain_length..BLS_INITRD_SLOTS { + let placeholder = format!("$initrd-{}", slot); + let value = context.stamp(&placeholder); + if !value.is_empty() && value != placeholder { + warn!( + "the entry has an initrd for slot {}, but linux-initrd-chain only lists {}, \ + so it is not loaded", + slot, chain_length + ); + break; + } + } + } + // Read each initrd and concatenate the contents in order. // Paths that are empty after stamping are skipped. let mut initrd: Option> = None; @@ -180,14 +215,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/autoconfigure.rs b/crates/boot/src/autoconfigure.rs index 28e69cb..9abab2d 100644 --- a/crates/boot/src/autoconfigure.rs +++ b/crates/boot/src/autoconfigure.rs @@ -1,8 +1,11 @@ +use alloc::boxed::Box; use anyhow::{Context, Result}; use edera_sprout_config::RootConfiguration; use log::warn; +use uefi::Handle; use uefi::fs::FileSystem; use uefi::proto::device_path::DevicePath; +use uefi::proto::loaded_image::LoadedImage; use uefi::proto::media::fs::SimpleFileSystem; /// bls: autodetect and configure BLS-enabled filesystems. @@ -16,13 +19,47 @@ pub mod linux; /// windows: autodetect and configure Windows boot configurations. pub mod windows; +/// Find the filesystems that systemd-boot reads BLS entries from: the partition that Sprout +/// was loaded from, and the extended boot loader partition of the same disk. +fn strict_bls_sources(loaded_image_path: &DevicePath) -> (Option, Option) { + let own = eficore::handle::open_shared::(uefi::boot::image_handle()) + .ok() + .and_then(|image| image.device()); + let xbootldr = match eficore::xbootldr::find_xbootldr(loaded_image_path) { + Ok(Some(path)) => uefi::boot::locate_device_path::(&mut &*path).ok(), + Ok(None) => None, + Err(error) => { + warn!("unable to find the xbootldr partition: {:#}", error); + None + } + }; + (own, xbootldr) +} + /// Generate a [RootConfiguration] based on the environment. /// Intakes a `config` to use as the basis of the autoconfiguration. -pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> { +/// With `strict`, only the partition Sprout was loaded from and the extended boot loader +/// partition of its disk are read for BLS entries, as one generator, like systemd-boot does. +pub fn autoconfigure( + config: &mut RootConfiguration, + strict: bool, + loaded_image_path: &DevicePath, +) -> Result<()> { // Find all the filesystems that are on the system. let filesystem_handles = uefi::boot::find_handles::().context("unable to scan filesystems")?; + // The partitions that BLS entries are read from in strict mode. + let (own_handle, xbootldr_handle) = if strict { + strict_bls_sources(loaded_image_path) + } else { + (None, None) + }; + // The device path of the partition of Sprout, which names the strict generator. + let mut own_root: Option> = None; + // Whether either of the partitions has BLS entries, in strict mode. + let mut strict_found = false; + // For each filesystem that was detected, scan it for supported autoconfig mechanisms. for handle in filesystem_handles { // Acquire the device path root for the filesystem. @@ -51,15 +88,37 @@ pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> { // A scan that fails is skipped, as a filesystem that can't be read should not // prevent autoconfiguring the other filesystems. - // Scan the filesystem for BLS supported configurations. - let bls_found = match bls::scan(&mut filesystem, &root, config) { - Ok(found) => found, - Err(error) => { - warn!( - "unable to scan filesystem for bls configurations: {:#}", - error - ); - false + // Scan the filesystem for BLS supported configurations. In strict mode, only the + // partitions that systemd-boot reads get entries, and they get a single generator. + // The other filesystems that have BLS are still not scanned for Linux configurations. + let is_source = Some(handle) == own_handle || Some(handle) == xbootldr_handle; + if strict && Some(handle) == own_handle { + own_root = Some(root.to_boxed()); + } + let bls_found = if strict { + match bls::detect(&mut filesystem) { + Ok(found) => { + strict_found |= found && is_source; + found + } + Err(error) => { + warn!( + "unable to scan filesystem for bls configurations: {:#}", + error + ); + false + } + } + } else { + match bls::scan(&mut filesystem, &root, config) { + Ok(found) => found, + Err(error) => { + warn!( + "unable to scan filesystem for bls configurations: {:#}", + error + ); + false + } } }; @@ -80,5 +139,20 @@ pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> { } } + // In strict mode, one generator reads both partitions, with the root of Sprout's partition. + if strict && strict_found { + match own_root { + Some(root) => { + if let Err(error) = bls::add_generator(config, &root, true) { + warn!("unable to add the bls generator: {:#}", error); + } + } + None => warn!( + "found BLS entries, but not the partition that Sprout was loaded from, \ + so none are used in strict mode" + ), + } + } + Ok(()) } diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index 3ee9de3..c6bcc33 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -18,12 +18,8 @@ use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly}; /// by the BLS generator to chainload entries. const BLS_CHAINLOAD_ACTION_PREFIX: &str = "bls-chainload-"; -/// Scan the specified `filesystem` for BLS configurations. -pub fn scan( - filesystem: &mut FileSystem, - root: &DevicePath, - config: &mut RootConfiguration, -) -> Result { +/// Detect whether the `filesystem` has a BLS supported configuration. +pub fn detect(filesystem: &mut FileSystem) -> Result { // BLS has a loader.conf file that can specify its own auto-entries mechanism. let bls_loader_conf_path = Path::new(cstr16!("\\loader\\loader.conf")); // BLS also has an entries directory that can specify explicit entries. @@ -31,17 +27,6 @@ pub fn scan( // BLS Type #2 entries are unified kernel images in the EFI/Linux directory. let bls_uki_path = Path::new(cstr16!("\\EFI\\Linux")); - // Convert the device path root to a string we can use in the configuration. - let mut root = root - .to_string16(DisplayOnly(false), AllowShortcuts(false)) - .context("unable to convert device root to string")? - .to_string(); - // Add a trailing forward-slash to the root to ensure the device root is completed. - root.push('/'); - - // Generate a unique hash of the root path. - let root_unique_hash = unique_hash(&root); - // Whether we have a loader.conf file. let has_loader_conf = filesystem .try_exists(bls_loader_conf_path) @@ -86,9 +71,26 @@ pub fn scan( // Detect if a BLS supported configuration is on this filesystem. // We check loader.conf, the entries directory and unified kernel images, as only one of // them is required. - if !(has_loader_conf || has_entries_dir || has_ukis) { - return Ok(false); - } + Ok(has_loader_conf || has_entries_dir || has_ukis) +} + +/// Add the BLS generator and its action for the partition with the device path `root`. +/// In `strict` mode, the generator also reads the extended boot loader partition of the disk. +pub fn add_generator( + config: &mut RootConfiguration, + root: &DevicePath, + strict: bool, +) -> Result<()> { + // Convert the device path root to a string we can use in the configuration. + let mut root = root + .to_string16(DisplayOnly(false), AllowShortcuts(false)) + .context("unable to convert device root to string")? + .to_string(); + // Add a trailing forward-slash to the root to ensure the device root is completed. + root.push('/'); + + // Generate a unique hash of the root path. + let root_unique_hash = unique_hash(&root); // Generate a unique name for the BLS chainload action. let chainload_action_name = format!("{}{}", BLS_CHAINLOAD_ACTION_PREFIX, root_unique_hash,); @@ -103,8 +105,9 @@ pub fn scan( path: format!("{}\\loader", root), uki_path: None, // Every filesystem is scanned, including the Extended Boot Loader Partition, which - // gets a generator of its own. - xbootldr: false, + // gets a generator of its own, unless strict mode reads it with the generator of the + // partition that Sprout was loaded from. + xbootldr: strict, pin_names: true, }; @@ -121,15 +124,18 @@ pub fn scan( // BLS will provide these values to us. // Every initrd slot is chained in order. Unused slots stamp to the root of the // filesystem, which the chainload action skips. + // The files of an entry are on the partition of the entry, which is the root of the + // generator for most entries, and the extended boot loader partition for some in strict mode. + let entry_root = if strict { "$entry-root" } else { &root }; let chainload = ChainloadConfiguration { - path: format!("{}\\$chainload", root), + path: format!("{}\\$chainload", entry_root), options: vec!["$options".to_string()], linux_initrd: None, linux_initrd_chain: (0..BLS_INITRD_SLOTS) - .map(|slot| format!("{}\\$initrd-{}", root, slot)) + .map(|slot| format!("{}\\$initrd-{}", entry_root, slot)) .collect(), // An unset devicetree stamps to the root of the filesystem, which the action skips. - devicetree: Some(format!("{}\\$devicetree", root)), + devicetree: Some(format!("{}\\$devicetree", entry_root)), }; // Insert the chainload action into the configuration. @@ -141,6 +147,18 @@ pub fn scan( }, ); - // We had a BLS supported configuration, so return true. + Ok(()) +} + +/// Scan the specified `filesystem` for BLS configurations. +pub fn scan( + filesystem: &mut FileSystem, + root: &DevicePath, + config: &mut RootConfiguration, +) -> Result { + if !detect(filesystem)? { + return Ok(false); + } + add_generator(config, root, false)?; Ok(true) } diff --git a/crates/boot/src/boot_counter.rs b/crates/boot/src/boot_counter.rs index 3d8f646..bbb232a 100644 --- a/crates/boot/src/boot_counter.rs +++ b/crates/boot/src/boot_counter.rs @@ -29,6 +29,9 @@ pub struct BootCounterTarget { pub file_name: String, /// The extension of the file name, such as `.conf`, in its original case. pub extension: String, + /// Whether booting the entry uses up a try. An entry whose tries are not used up still + /// has them, so it can be bad. + pub counting: bool, } impl BootCounterTarget { diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 64d85a5..979b6db 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -8,6 +8,7 @@ use alloc::{ vec::Vec, }; use anyhow::{Context, Result}; +use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use core::{cmp::Ordering, str::FromStr}; use edera_sprout_bls::{ BlsEntry, BootCounter, is_reserved_entry_name, profile_id_suffix, sort_bls, strip_extension, @@ -15,7 +16,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, @@ -37,9 +38,13 @@ const ARCHITECTURE: &str = "x64"; #[cfg(target_arch = "aarch64")] const ARCHITECTURE: &str = "aa64"; +/// Whether the extended boot loader partition was added by a generator in strict mode, which +/// does it once, so that more than one generator does not list its entries twice. +static STRICT_XBOOTLDR_ADDED: AtomicBool = AtomicBool::new(false); + /// 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 +143,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 +258,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,6 +313,9 @@ fn generate_type1( } // Record where the boot counter lives so a try can be consumed when this entry boots. + // 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 { boot.set_boot_counter(BootCounterTarget { counter, @@ -279,6 +324,7 @@ fn generate_type1( id: name.clone(), file_name, extension, + counting: counted, }); } @@ -307,7 +353,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 +409,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; @@ -393,6 +450,7 @@ fn generate_type2( id: id.clone(), file_name: file_name.clone(), extension: extension.clone(), + counting: true, }); } found.push((entry, boot)); @@ -470,7 +528,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 +537,9 @@ 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 586bb09..8184c3e 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -199,6 +199,13 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { .context("unable to set loader path in bootloader interface"), ); + // Strict mode can be asked for on the command line or in the configuration. + let mut options = options; + options.bls_strict_mode |= config.options.bls_strict_mode; + if options.bls_strict_mode { + info!("BLS strict mode is enabled"); + } + // Create the root context. let mut root = RootContext::new(loaded_image_path, timer, options); @@ -223,7 +230,12 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { // If --autoconfigure is specified or the loaded configuration has autoconfigure enabled, // trigger the autoconfiguration mechanism. if context.root().options().autoconfigure || config.options.autoconfigure { - autoconfigure::autoconfigure(&mut config).context("unable to autoconfigure")?; + autoconfigure::autoconfigure( + &mut config, + context.root().options().bls_strict_mode, + context.root().loaded_image_path()?, + ) + .context("unable to autoconfigure")?; } // Unload the context so that it can be modified. @@ -360,11 +372,14 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { ); // Acquire the entry that was saved by the previous boot. - let last_booted_entry = advisory( + let mut last_booted_entry = advisory( BootloaderInterface::get_last_booted_entry() .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() @@ -377,7 +392,8 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { let mut force_boot_menu = context.root().options().force_menu; // Pick the menu timeout from the first source that specifies one, in this order: - // the one-shot timeout, --menu-timeout, the configuration, LoaderConfigTimeout, loader.conf. + // the one-shot timeout, --menu-timeout, LoaderConfigTimeout, the configuration, loader.conf. + // The bootloader interface is what tools like bootctl set, so it outranks the configuration. let seconds = |seconds: Option| { seconds.map_or( BootloaderInterfaceTimeout::Unspecified, @@ -396,16 +412,21 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { let timeout = [ bootloader_interface_timeouts.oneshot, seconds(context.root().options().menu_timeout), - seconds(config.options.menu_timeout), bootloader_interface_timeouts.direct, + seconds(config.options.menu_timeout), 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; + // 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; @@ -444,7 +465,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 + ); + } } } @@ -473,17 +500,20 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { Some(value) }; - // Pick the default entry from the first source that matches an entry. A source that matches + // Pick the default entry from the first source that matches an entry, where the bootloader + // interface, which is what tools like bootctl set, outranks the configuration. 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. + // 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 [ - ( - "configuration", - config.options.default_entry.clone(), - false, - false, - ), + ("bootloader interface one-shot", oneshot_source, true, false), ( "bootloader interface preferred", bootloader_interface_preferred_entry.clone(), @@ -502,6 +532,12 @@ fn run(reboot_on_error: &mut bool) -> Result<()> { true, false, ), + ( + "configuration", + config.options.default_entry.clone(), + false, + false, + ), ( "loader.conf default", loader_conf.default.clone(), @@ -514,20 +550,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 { @@ -539,6 +577,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); } @@ -547,7 +597,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) { @@ -558,125 +612,155 @@ 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); - - // 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 - ); + // 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 !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"), + ); + last_booted_entry = Some(id.clone()); + } + } 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"), ); + last_booted_entry = None; } - } 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. - // 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"), - ); + // 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() + && target.counting + { + 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); - - // 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))?; + // 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))?; + } + + // 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(()) diff --git a/crates/boot/src/options.rs b/crates/boot/src/options.rs index 794c71c..74c6889 100644 --- a/crates/boot/src/options.rs +++ b/crates/boot/src/options.rs @@ -29,6 +29,8 @@ pub struct SproutOptions { pub menu_style: Option, /// Retains the boot console before boot. pub retain_boot_console: bool, + /// Makes Sprout follow the Boot Loader Specification and systemd-boot exactly. + pub bls_strict_mode: bool, } /// The default Sprout options. @@ -42,6 +44,7 @@ impl Default for SproutOptions { menu_timeout: None, menu_style: None, retain_boot_console: false, + bls_strict_mode: false, } } } @@ -60,6 +63,7 @@ impl SproutOptions { MenuTimeout, MenuStyle, RetainBootConsole, + BlsStrictMode, } // All the options for the Sprout executable. @@ -78,6 +82,8 @@ impl SproutOptions { .help_text("Boot menu style, basic, simple or graphical"), Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"]) .help_text("Retain boot console before boot"), + Opt::flag(ArgID::BlsStrictMode, &["--bls-strict-mode"]) + .help_text("Follow the BLS specification and systemd-boot exactly"), ]); // Acquire the arguments as determined by the UEFI core. @@ -141,6 +147,10 @@ impl SproutOptions { // Retain the boot console before booting. result.retain_boot_console = true; } + ArgID::BlsStrictMode => { + // Follow the BLS specification and systemd-boot exactly. + result.bls_strict_mode = true; + } ArgID::Help => { let ctx = HelpWriterContext { options: &OPTIONS, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4dafce4..ee1c180 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -91,6 +91,12 @@ pub struct OptionsConfiguration { /// Enables autoconfiguration of Sprout based on the environment. #[serde(default)] pub autoconfigure: bool, + /// Makes Sprout follow the Boot Loader Specification and systemd-boot exactly where it + /// otherwise differs for convenience or safety. For example, the extended boot loader + /// partition is always read, a one-shot entry only sets the default entry, and entries with + /// missing files are hidden. See the README for everything it changes. + #[serde(rename = "bls-strict-mode", default)] + pub bls_strict_mode: bool, } /// The default configuration, which matches an empty configuration file. @@ -118,6 +124,7 @@ impl Default for OptionsConfiguration { menu_timeout: None, menu_style: Default::default(), autoconfigure: false, + bls_strict_mode: false, } } } @@ -164,6 +171,12 @@ mod tests { assert_eq!(default.options.autoconfigure, empty.options.autoconfigure); } + #[test] + fn bls_strict_mode_is_off_by_default() { + assert!(!RootConfiguration::default().options.bls_strict_mode); + assert!(!empty().options.bls_strict_mode); + } + #[test] fn menu_timeout_is_unset_by_default() { assert_eq!(RootConfiguration::default().options.menu_timeout, None);