diff --git a/README.md b/README.md index 17c71bf..be215bf 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,18 @@ We recommend running Sprout without Secure Boot for development, and with Secure ### Current - [x] Loadable driver support -- [x] Basic [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support +- [x] [Bootloader specification (BLS)](https://uapi-group.org/specifications/specs/boot_loader_specification/) support: + Type #1 entries, Type #2 unified kernel images, and the extended boot loader partition +- [x] [UKI support](https://github.com/edera-dev/sprout/issues/6): beta, including images with multiple profiles +- [x] Boot counting, so `systemd-bless-boot` can assess a boot +- [x] `loader.conf` support - [x] Chainload support - [x] Linux boot support via EFI stub - [x] Windows boot support via chainload - [x] Load Linux initrd from disk -- [x] Basic boot menu +- [x] Devicetree support +- [x] Basic, simple, and graphical boot menus +- [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 - [x] [Bootloader interface support](https://github.com/edera-dev/sprout/issues/21): beta @@ -83,7 +89,8 @@ We recommend running Sprout without Secure Boot for development, and with Secure ### Roadmap - [ ] [Full-featured boot menu](https://github.com/edera-dev/sprout/issues/1) -- [ ] [UKI support](https://github.com/edera-dev/sprout/issues/6): partial +- [ ] Network boot of unified kernel images (`uki-url`) and devicetree overlays +- [ ] A random seed for the Linux kernel - [ ] [multiboot2 support](https://github.com/edera-dev/sprout/issues/7) - [ ] [Linux boot protocol (boot without EFI stub)](https://github.com/edera-dev/sprout/issues/8) @@ -124,6 +131,12 @@ $ sprout.efi --autoconfigure $ sprout.efi --menu-style=basic # Use the graphical boot menu, which can be used with the mouse. $ sprout.efi --menu-style=graphical +# Show the boot menu for 10 seconds before booting the default entry. +$ sprout.efi --menu-timeout=10 +# Show the boot menu even if an entry was chosen with --boot. +$ sprout.efi --force-menu +# Keep the boot console as it is when an entry is booted. +$ sprout.efi --retain-boot-console ``` ### Boot Linux from ESP @@ -165,6 +178,121 @@ path = "\\sprout\\drivers\\ext4.efi" autoconfigure = true ``` +Sprout reads Type #1 entries from `\loader\entries` and Type #2 unified kernel images (UKIs) from +`\EFI\Linux`, and sorts them as the specification says. A unified kernel image with several profiles +is one entry for each profile. Entries that are not for the architecture of the machine are hidden. +Type #1 entries can use `linux`, `efi`, `uki`, `initrd`, `options`, `devicetree`, `architecture`, +`profile`, `sort-key`, `version`, `machine-id`, and `title`. + +To set up the generator by hand instead of using autoconfiguration, add a generator and an action +that boots the entries. The entry values `$chainload`, `$options`, `$initrd-0` to `$initrd-7`, +`$devicetree`, `$cmdline`, `$title`, `$version`, and `$entry-root` are available. + +```toml +[generators.bls] +# the directory that has the entries directory. this is the default. +bls.path = "\\loader" +# the directory of unified kernel images. by default, this is \EFI\Linux on +# 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 +# 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 +# the ids that bootctl uses. +bls.pin-names = true +bls.entry.title = "$title" +bls.entry.actions = ["boot-bls"] + +[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. +chainload.linux-initrd-chain = [ + "$entry-root\\$initrd-0", + "$entry-root\\$initrd-1", + "$entry-root\\$initrd-2", + "$entry-root\\$initrd-3", + "$entry-root\\$initrd-4", + "$entry-root\\$initrd-5", + "$entry-root\\$initrd-6", + "$entry-root\\$initrd-7", +] +``` + +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. + +#### Boot counting + +An entry file named like `fedora+3.conf` or `fedora+3.efi` has three tries. Each time Sprout boots it, the +file is renamed, such as to `fedora+2-1.conf`, and the new path is given to the system in +`LoaderBootCountPath` so `systemd-bless-boot` can remove the counter once the boot works. +Entries that have no tries left are sorted last and are not picked as the default entry, but they can still be +booted by hand. By default, if an entry fails to start after a try was used up and it had tries left, the machine +resets, so the next boot can use the next try or another entry. This is the `reboot-on-error` setting +in `loader.conf`. Without boot counting, a failure to start returns to the firmware. + +#### loader.conf + +Sprout reads `\loader\loader.conf` from the partition it was loaded from, as systemd-boot does. + +| Key | Value | +|-------------------|--------------------------------------------------------------------| +| `default` | A pattern for the id of the default entry, or `@saved`. | +| `preferred` | Like `default`, but entries with no boot counter tries are skipped. | +| `timeout` | Seconds, `menu-hidden`, `menu-disabled`, or `menu-force`. | +| `reboot-on-error` | `auto` (the default), `yes`, or `no`. | + +The id of an entry is the name of its file without the boot counter, such as `fedora.conf` or `fedora.efi`. +Patterns ignore case and can use `*`, `?`, and `[a-z]`. With `@saved`, the entry that was booted last is the +default. With `reboot-on-error`, `yes` always resets after an entry fails to start, which can loop forever, +and `auto` only does when a boot counter try was used up and there were tries left. +Other keys are ignored with a warning. + +A hidden menu, from a timeout of zero or `menu-hidden`, still opens when a key is pressed. `menu-disabled` does not. + +#### Bootloader interface + +Sprout uses the same variables as systemd-boot, so `bootctl`, `systemctl reboot --boot-loader-entry`, and +`systemd-bless-boot` can work with it. It publishes `LoaderEntries`, `LoaderEntrySelected`, +`LoaderBootCountPath`, `LoaderFeatures`, and `LoaderInfo`, and reads `LoaderEntryDefault`, +`LoaderEntryPreferred`, `LoaderEntryOneShot`, `LoaderEntryLastBooted`, `LoaderConfigTimeout`, +and `LoaderConfigTimeoutOneShot`. + +The default entry comes from the first of these that matches an entry: + +1. `default-entry` in `sprout.toml` +2. `LoaderEntryPreferred`, then `preferred` in `loader.conf` +3. `LoaderEntryDefault`, then `default` in `loader.conf` + +The menu timeout comes from the first of the one-shot timeout, `--menu-timeout`, `menu-timeout` in +`sprout.toml`, `LoaderConfigTimeout`, and `loader.conf`. + +### Generators + +Generators make entries when Sprout starts. The `matrix` generator makes an entry for every combination of +its values, the `list` generator makes an entry for each item of a list, and the `bls` generator makes +entries from BLS files. Variants multiply the entries of any generator, and `exclude` removes some of them. + +```toml +# makes an entry for each kernel and each console, such as "Boot \vmlinuz (serial)". +[generators.kernels] +matrix.entry.title = "Boot $kernel ($console)" +matrix.entry.actions = ["boot-kernel"] +matrix.values.kernel = ["\\vmlinuz", "\\vmlinuz-lts"] +variants.console = [ + { name = "serial", values.console-options = "console=ttyS0" }, + { name = "graphics", values.console-options = "console=tty0" }, +] + +[actions.boot-kernel] +chainload.path = "$kernel" +chainload.options = ["$console-options"] +``` + [Edera]: https://edera.dev [Development Guide]: ./DEVELOPMENT.md [Contributing Guide]: ./CONTRIBUTING.md diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 94148f0..bbdd3b5 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -11,12 +11,15 @@ mod loader_conf; mod pe; mod uki; -pub use loader_conf::{LoaderConf, LoaderTimeout}; +pub use loader_conf::{LoaderConf, LoaderTimeout, RebootOnError}; pub use pe::{ - MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, ReadAt, read_pe, + MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, PeSection, ReadAt, read_pe, read_sections, }; -pub use uki::{OsRelease, UKI_SECTIONS}; +pub use uki::{ + MAX_PROFILES, OsRelease, ProfileInfo, UKI_SECTIONS, UkiProfile, profile_entry_id, + profile_id_suffix, profile_title, uki_profiles, +}; /// Represents a parsed BLS entry. /// Fields unrelated to Sprout are not included. @@ -34,6 +37,8 @@ pub struct BlsEntry { pub efi: Option, /// The path to a unified kernel image. pub uki: Option, + /// The path to the flattened devicetree to boot with. + pub devicetree: Option, /// The profile of the unified kernel image to boot. pub profile: Option, /// The architecture the entry is for, such as `x64` or `aa64`. @@ -67,6 +72,7 @@ impl FromStr for BlsEntry { let mut initrd: Vec = Vec::new(); let mut efi: Option = None; let mut uki: Option = None; + let mut devicetree: Option = None; let mut profile: Option = None; let mut architecture: Option = None; let mut sort_key: Option = None; @@ -127,6 +133,11 @@ impl FromStr for BlsEntry { efi = Some(value.trim().to_string()); } + // The path to the flattened devicetree to boot with. + "devicetree" => { + devicetree = Some(value.trim().to_string()); + } + // The path to a unified kernel image. "uki" => { uki = Some(value.trim().to_string()); @@ -169,6 +180,7 @@ impl FromStr for BlsEntry { initrd, efi, uki, + devicetree, profile, architecture, sort_key, @@ -227,6 +239,14 @@ impl BlsEntry { .map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string()) } + /// Fetches the path to the devicetree to boot with, if any. + /// It also converts / to \\ to match EFI path style. + pub fn devicetree_path(&self) -> Option { + self.devicetree + .as_ref() + .map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string()) + } + /// Fetches the paths to the initrds to pass to the kernel, in order. /// It also converts / to \\ to match EFI path style. pub fn initrd_paths(&self) -> Vec { @@ -265,6 +285,12 @@ impl BlsEntry { self.machine_id.clone() } + /// The number of the profile of a unified kernel image that the entry boots, which is zero + /// for the first profile and for entries that don't have one. + pub fn profile_number(&self) -> u32 { + self.profile.as_deref().and_then(parse_digits).unwrap_or(0) + } + /// Whether the entry has a boot counter with no tries left. pub fn is_bad(&self) -> bool { self.boot_counter.is_some_and(|counter| counter.is_bad()) @@ -330,21 +356,24 @@ impl BootCounter { } } -/// Decides which entries are the default. Each item of `entries` is `(is_default, is_bad)`, -/// and the result says whether each entry is the default afterwards. +/// Decides which entries are the default. Each item of `entries` is +/// `(is_default, is_bad, is_extra_profile)`, and the result says whether each entry is the +/// default afterwards. /// Bad entries, which have no boot counter tries left, are never the default while a good entry -/// exists. If no entry is a default, the first good entry is picked, or the first entry if every -/// entry is bad, so that there is always something to boot. -pub fn resolve_default_flags(entries: &[(bool, bool)]) -> Vec { - let any_good = entries.iter().any(|(_, bad)| !bad); +/// exists. If no entry is a default, the first good entry is picked, which is not a profile of +/// a unified kernel image after the first, unless there is nothing else. If every entry is bad, +/// the first entry is picked, so that there is always something to boot. +pub fn resolve_default_flags(entries: &[(bool, bool, bool)]) -> Vec { + let any_good = entries.iter().any(|(_, bad, _)| !bad); let mut flags: Vec = entries .iter() - .map(|(default, bad)| *default && !(any_good && *bad)) + .map(|(default, bad, _)| *default && !(any_good && *bad)) .collect(); if !flags.iter().any(|default| *default) { let pick = entries .iter() - .position(|(_, bad)| !bad) + .position(|(_, bad, extra)| !bad && !extra) + .or_else(|| entries.iter().position(|(_, bad, _)| !bad)) .or_else(|| (!entries.is_empty()).then_some(0)); if let Some(index) = pick { flags[index] = true; @@ -412,7 +441,9 @@ pub fn sort_bls(a_bls: &BlsEntry, a_name: &str, b_bls: &BlsEntry, b_name: &str) .then_with(|| { compare_versions_optional(a_bls.version().as_deref(), b_bls.version().as_deref()) .reverse() - }), + }) + // The profiles of one image are in the order they are in the image. + .then_with(|| a_bls.profile_number().cmp(&b_bls.profile_number())), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => Ordering::Equal, @@ -426,6 +457,7 @@ pub fn sort_bls(a_bls: &BlsEntry, a_name: &str, b_bls: &BlsEntry, b_name: &str) // the boot counter: more tries left first, then fewer tries done. ordering .then_with(|| compare_versions(a_name, b_name).reverse()) + .then_with(|| a_bls.profile_number().cmp(&b_bls.profile_number())) .then_with(|| { let tries = |entry: &BlsEntry| { entry @@ -769,7 +801,7 @@ mod tests { assert_eq!(entry.version.as_deref(), Some("40.1")); assert_eq!(entry.sort_key.as_deref(), Some("coreos")); assert_eq!(entry.uname.as_deref(), Some("6.5.0")); - assert_eq!(entry.efi.as_deref(), Some("/EFI/Linux/fedora.efi")); + assert_eq!(entry.uki.as_deref(), Some("/EFI/Linux/fedora.efi")); assert!(entry.options.is_none()); assert!(entry.is_valid()); } @@ -858,12 +890,284 @@ mod tests { assert_eq!(strip_extension(".efi", ".efi"), Some(("", ".efi"))); } + fn text_section(name: &str, text: &str) -> PeSection { + PeSection { + name: name.to_string(), + data: Some(text.as_bytes().to_vec()), + } + } + + fn cmdline(profile: &UkiProfile) -> Option<&[u8]> { + profile.sections.get(".cmdline").map(|data| data.as_slice()) + } + + #[test] + fn profiles_without_a_profile_section_are_a_single_base() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".cmdline", "q"), + ]; + let profiles = uki_profiles(§ions); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].index, None); + assert_eq!(cmdline(&profiles[0]), Some(&b"q"[..])); + assert!(profiles[0].sections.contains_key(".osrel")); + } + + #[test] + fn profiles_follow_the_spec_layout() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".cmdline", "base"), + text_section(".profile", "ID=p0"), + text_section(".profile", "ID=p1"), + text_section(".cmdline", "one"), + text_section(".profile", "ID=p2\nTITLE=Two"), + text_section(".cmdline", "two"), + ]; + let profiles = uki_profiles(§ions); + assert_eq!(profiles.len(), 3); + assert_eq!( + profiles.iter().map(|p| p.index).collect::>(), + [Some(0), Some(1), Some(2)] + ); + // The first profile has no sections of its own, so it uses the base. + assert_eq!(cmdline(&profiles[0]), Some(&b"base"[..])); + assert_eq!(cmdline(&profiles[1]), Some(&b"one"[..])); + assert_eq!(cmdline(&profiles[2]), Some(&b"two"[..])); + assert!(profiles.iter().all(|p| p.sections.contains_key(".osrel"))); + assert_eq!(profiles[1].info.id.as_deref(), Some("p1")); + assert_eq!(profiles[2].info.title.as_deref(), Some("Two")); + } + + #[test] + fn the_first_section_of_a_name_wins_within_a_range() { + let sections = [ + text_section(".profile", "ID=p"), + text_section(".cmdline", "a"), + text_section(".cmdline", "b"), + ]; + assert_eq!(cmdline(&uki_profiles(§ions)[0]), Some(&b"a"[..])); + } + + #[test] + fn an_empty_or_oversized_profile_section_masks_the_base() { + let sections = [ + text_section(".cmdline", "base"), + text_section(".profile", "ID=empty"), + text_section(".cmdline", ""), + text_section(".profile", "ID=big"), + PeSection { + name: ".cmdline".to_string(), + data: None, + }, + ]; + let profiles = uki_profiles(§ions); + assert_eq!(cmdline(&profiles[0]), None); + assert_eq!(cmdline(&profiles[1]), None); + } + + #[test] + fn a_profile_can_override_the_os_release() { + let sections = [ + text_section(".osrel", "ID=base\nPRETTY_NAME=Base"), + text_section(".profile", "ID=p"), + text_section(".osrel", "ID=other\nPRETTY_NAME=Other"), + ]; + let profiles = uki_profiles(§ions); + let entry = BlsEntry::from_uki_profile(&profiles[0], "/a.efi"); + assert_eq!(entry.title.as_deref(), Some("Other")); + } + + #[test] + fn a_leading_profile_section_means_an_empty_base() { + let sections = [ + text_section(".profile", "ID=p"), + text_section(".osrel", "ID=a"), + ]; + let profiles = uki_profiles(§ions); + assert_eq!(profiles.len(), 1); + assert!(profiles[0].sections.contains_key(".osrel")); + } + + #[test] + fn profiles_are_capped() { + let sections: Vec = (0..300) + .map(|index| text_section(".profile", &format!("ID=p{index}"))) + .collect(); + assert_eq!(uki_profiles(§ions).len(), MAX_PROFILES); + } + + #[test] + fn profile_info_parses_like_os_release() { + let info = ProfileInfo::parse("# c\nID=\"one\"\nTITLE='Hello World'\nID=two\n"); + assert_eq!(info.id.as_deref(), Some("two")); + assert_eq!(info.title.as_deref(), Some("Hello World")); + let info = ProfileInfo::parse("ID=\nTITLE=\n"); + assert_eq!(info, ProfileInfo::default()); + } + + #[test] + fn profile_ids_and_titles_follow_systemd_boot() { + let named = ProfileInfo { + id: Some("factory".to_string()), + title: None, + }; + let titled = ProfileInfo { + id: Some("factory".to_string()), + title: Some("Factory Reset".to_string()), + }; + let bare = ProfileInfo::default(); + + // The first profile has no suffix, the others have the id or their number. + assert_eq!(profile_id_suffix(0, &named), None); + assert_eq!(profile_id_suffix(1, &named).as_deref(), Some("factory")); + assert_eq!(profile_id_suffix(2, &bare).as_deref(), Some("2")); + + assert_eq!(profile_title("Fedora", 0, &bare), "Fedora"); + assert_eq!( + profile_title("Fedora", 0, &titled), + "Fedora (Factory Reset)" + ); + assert_eq!(profile_title("Fedora", 1, &named), "Fedora (factory)"); + assert_eq!(profile_title("Fedora", 1, &bare), "Fedora (Profile #2)"); + assert_eq!( + profile_title("Fedora", 3, &titled), + "Fedora (Factory Reset)" + ); + } + + #[test] + fn profile_entry_ids_only_keep_the_case_of_the_profile() { + assert_eq!(profile_entry_id("Fedora.EFI", None), "fedora.efi"); + assert_eq!( + profile_entry_id("Fedora.EFI", Some("Factory")), + "fedora.efi@Factory" + ); + } + + #[test] + fn profile_entries_select_the_profile_with_load_options() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".profile", "ID=p0"), + text_section(".profile", "ID=p1"), + ]; + let profiles = uki_profiles(§ions); + let first = BlsEntry::from_uki_profile(&profiles[0], "/a.efi"); + assert_eq!(first.chainload_options(), None); + let second = BlsEntry::from_uki_profile(&profiles[1], "/a.efi"); + assert_eq!(second.chainload_options().as_deref(), Some("@1")); + assert_eq!(second.chainload_path().as_deref(), Some("a.efi")); + assert!(second.initrd_paths().is_empty()); + } + + #[test] + fn profiles_of_one_image_sort_by_number() { + let sections = [ + text_section(".osrel", "ID=a"), + text_section(".profile", "ID=p0"), + text_section(".profile", "ID=p1"), + ]; + let profiles = uki_profiles(§ions); + let first = BlsEntry::from_uki_profile(&profiles[0], "/a.efi"); + let second = BlsEntry::from_uki_profile(&profiles[1], "/a.efi"); + assert_eq!(sort_bls(&first, "a.efi", &second, "a.efi"), Ordering::Less); + assert_eq!( + sort_bls(&second, "a.efi", &first, "a.efi"), + Ordering::Greater + ); + } + + #[test] + fn a_newer_version_sorts_before_the_profiles_of_an_older_one() { + let new = BlsEntry { + version: Some("2".to_string()), + profile: Some("1".to_string()), + ..sort_entry(Some("linux"), None, Some("2")) + }; + let old = BlsEntry { + version: Some("1".to_string()), + ..sort_entry(Some("linux"), None, Some("1")) + }; + assert_eq!(sort_bls(&new, "a", &old, "a"), Ordering::Less); + } + + #[test] + fn default_flags_never_fall_back_to_extra_profiles() { + // Each item is (is_default, is_bad, is_extra_profile). + let flags = resolve_default_flags(&[(false, false, true), (false, false, false)]); + assert_eq!(flags, [false, true]); + // An extra profile can still be a default that was asked for. + let flags = resolve_default_flags(&[(true, false, true), (false, false, false)]); + assert_eq!(flags, [true, false]); + // With nothing else, an extra profile is better than nothing. + let flags = resolve_default_flags(&[(false, false, true)]); + assert_eq!(flags, [true]); + } + + #[test] + fn pe_keeps_duplicate_sections_in_order() { + let image = pe_image(&[ + (".osrel", b"ID=a"), + (".profile", b"ID=one"), + (".cmdline", b"x"), + (".profile", b"ID=two"), + (".cmdline", b"y"), + (".text", b"code"), + ]); + let pe = read_pe(&mut image.as_slice(), &[".osrel", ".profile", ".cmdline"]).unwrap(); + let names: Vec<_> = pe + .sections + .iter() + .map(|section| section.name.as_str()) + .collect(); + assert_eq!( + names, + [".osrel", ".profile", ".cmdline", ".profile", ".cmdline"] + ); + assert_eq!(pe.sections[3].data.as_deref(), Some(&b"ID=two"[..])); + } + + #[test] + fn pe_keeps_oversized_sections_without_data() { + let big = vec![b'a'; MAX_SECTION_SIZE as usize + 1]; + let image = pe_image(&[(".cmdline", &big), (".osrel", b"ID=x")]); + let pe = read_pe(&mut image.as_slice(), &[".cmdline", ".osrel"]).unwrap(); + assert_eq!(pe.sections[0].name, ".cmdline"); + assert_eq!(pe.sections[0].data, None); + assert_eq!(pe.sections[1].data.as_deref(), Some(&b"ID=x"[..])); + } + + #[test] + fn pe_accepts_many_sections_and_rejects_too_many() { + let many: Vec<(&str, &[u8])> = (0..300).map(|_| (".dtbauto", &b"d"[..])).collect(); + let image = pe_image(&many); + let pe = read_pe(&mut image.as_slice(), &[".dtbauto"]).unwrap(); + assert_eq!(pe.sections.len(), 300); + + let mut image = pe_image(&[(".osrel", b"ID=x")]); + image[0x86..0x88].copy_from_slice(&5000u16.to_le_bytes()); + assert!(read_pe(&mut image.as_slice(), &[".osrel"]).is_err()); + } + + #[test] + fn pe_zero_virtual_size_means_an_empty_section() { + let mut image = pe_image(&[(".cmdline", b"quiet")]); + // The virtual size is at offset 8 of the first section table entry. + let entry = 0x80 + 24; + image[entry + 8..entry + 12].copy_from_slice(&0u32.to_le_bytes()); + let pe = read_pe(&mut image.as_slice(), &[".cmdline"]).unwrap(); + assert_eq!(pe.sections[0].data.as_deref(), Some(&b""[..])); + } + #[test] fn pe_reports_the_machine_type() { let image = pe_image(&[(".osrel", b"ID=x")]); let pe = read_pe(&mut image.as_slice(), &[".osrel"]).unwrap(); assert_eq!(pe.machine, PE_MACHINE_X86_64); - assert!(pe.sections.contains_key(".osrel")); + assert_eq!(pe.sections.len(), 1); + assert_eq!(pe.sections[0].name, ".osrel"); } #[test] @@ -958,6 +1262,75 @@ mod tests { assert_eq!(conf.default.as_deref(), Some("\"foo")); } + #[test] + fn reboot_on_error_defaults_to_auto() { + assert_eq!(LoaderConf::parse("").reboot_on_error, RebootOnError::Auto); + } + + #[test] + fn reboot_on_error_reads_booleans_and_auto() { + for (value, expected) in [ + ("auto", RebootOnError::Auto), + ("yes", RebootOnError::Yes), + ("true", RebootOnError::Yes), + ("1", RebootOnError::Yes), + ("on", RebootOnError::Yes), + ("y", RebootOnError::Yes), + ("t", RebootOnError::Yes), + ("no", RebootOnError::No), + ("off", RebootOnError::No), + ("0", RebootOnError::No), + ("f", RebootOnError::No), + ("false", RebootOnError::No), + ("n", RebootOnError::No), + ("\"no\"", RebootOnError::No), + ] { + let conf = LoaderConf::parse(&format!("reboot-on-error {value}\n")); + assert_eq!(conf.reboot_on_error, expected, "{value}"); + assert!(conf.warnings.is_empty(), "{value}"); + } + } + + #[test] + fn reboot_on_error_ignores_invalid_values_with_a_warning() { + // The values are case sensitive, as they are in systemd-boot. + for value in ["YES", "Auto", "maybe", ""] { + let conf = LoaderConf::parse(&format!("reboot-on-error {value}\n")); + assert_eq!(conf.reboot_on_error, RebootOnError::Auto, "{value}"); + assert_eq!(conf.warnings.len(), 1, "{value}"); + } + } + + #[test] + fn reboot_on_error_last_value_wins() { + let conf = LoaderConf::parse("reboot-on-error no\nreboot-on-error yes\n"); + assert_eq!(conf.reboot_on_error, RebootOnError::Yes); + } + + #[test] + fn reboot_on_error_decides_from_the_tries_that_were_left() { + assert!(!RebootOnError::Auto.should_reboot(None)); + assert!(!RebootOnError::Auto.should_reboot(Some(0))); + assert!(RebootOnError::Auto.should_reboot(Some(1))); + assert!(RebootOnError::Auto.should_reboot(Some(3))); + assert!(RebootOnError::Yes.should_reboot(None)); + assert!(!RebootOnError::No.should_reboot(Some(3))); + } + + #[test] + fn auto_reboots_are_bounded_by_the_boot_counter() { + // An entry with two tries that keeps failing is rebooted twice, and then stays put. + let mut counter = BootCounter::new(2, 0); + let mut reboots = 0; + for _ in 0..10 { + if RebootOnError::Auto.should_reboot(Some(counter.tries_left)) { + reboots += 1; + } + counter = counter.decremented(); + } + assert_eq!(reboots, 2); + } + #[test] fn loader_conf_empty_has_no_settings() { let conf = LoaderConf::parse(""); @@ -1036,19 +1409,27 @@ mod tests { #[test] fn default_flags_keep_a_good_default() { // The second entry is bad and also matched the default pattern. - let flags = resolve_default_flags(&[(true, false), (true, true), (false, false)]); + let flags = resolve_default_flags(&[ + (true, false, false), + (true, true, false), + (false, false, false), + ]); assert_eq!(flags, [true, false, false]); } #[test] fn default_flags_replace_a_bad_default_with_the_first_good_entry() { - let flags = resolve_default_flags(&[(false, false), (true, true), (false, false)]); + let flags = resolve_default_flags(&[ + (false, false, false), + (true, true, false), + (false, false, false), + ]); assert_eq!(flags, [true, false, false]); } #[test] fn default_flags_fallback_skips_bad_entries() { - let flags = resolve_default_flags(&[(false, true), (false, false)]); + let flags = resolve_default_flags(&[(false, true, false), (false, false, false)]); assert_eq!(flags, [false, true]); } @@ -1056,11 +1437,11 @@ mod tests { fn default_flags_when_every_entry_is_bad() { // A configured default stays, otherwise the first entry is the default. assert_eq!( - resolve_default_flags(&[(false, true), (true, true)]), + resolve_default_flags(&[(false, true, false), (true, true, false)]), [false, true] ); assert_eq!( - resolve_default_flags(&[(false, true), (false, true)]), + resolve_default_flags(&[(false, true, false), (false, true, false)]), [true, false] ); } @@ -1070,6 +1451,17 @@ mod tests { assert!(resolve_default_flags(&[]).is_empty()); } + #[test] + fn parse_devicetree_and_convert_the_path() { + let entry: BlsEntry = "linux /vmlinuz\ndevicetree /dtb/board.dtb\n" + .parse() + .unwrap(); + assert_eq!(entry.devicetree.as_deref(), Some("/dtb/board.dtb")); + assert_eq!(entry.devicetree_path().as_deref(), Some("dtb\\board.dtb")); + let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap(); + assert_eq!(entry.devicetree_path(), None); + } + #[test] fn parse_architecture_uki_and_profile() { let entry: BlsEntry = "uki /EFI/Linux/a.efi\narchitecture x64\nprofile 2\n" diff --git a/crates/bls/src/loader_conf.rs b/crates/bls/src/loader_conf.rs index 27a5001..9244f73 100644 --- a/crates/bls/src/loader_conf.rs +++ b/crates/bls/src/loader_conf.rs @@ -30,6 +30,43 @@ impl LoaderTimeout { } } +/// What to do when the selected entry fails to start, from the `reboot-on-error` setting. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum RebootOnError { + /// Never reboot. + No, + /// Always reboot, which can loop forever if the entry never starts. + Yes, + /// Reboot only if a boot counter try was just used up and tries were left, so that the + /// entry eventually runs out of tries and is not booted any more. + #[default] + Auto, +} + +impl RebootOnError { + /// Parses a `reboot-on-error` value, which is `auto` or a boolean. The words are case + /// sensitive, as in systemd-boot. + fn parse(value: &str) -> Option { + match value { + "auto" => Some(Self::Auto), + "1" | "yes" | "y" | "true" | "t" | "on" => Some(Self::Yes), + "0" | "no" | "n" | "false" | "f" | "off" => Some(Self::No), + _ => None, + } + } + + /// Whether to reboot after the entry failed to start. `consumed_tries_left` is the number of + /// tries that were left before this boot used one up, or None if the entry has no boot + /// counter or the try could not be used up, as then a reboot would not make progress. + pub fn should_reboot(self, consumed_tries_left: Option) -> bool { + match self { + Self::Yes => true, + Self::No => false, + Self::Auto => consumed_tries_left.is_some_and(|left| left > 0), + } + } +} + /// The settings Sprout understands from a `loader.conf` file. /// Reference: #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -41,6 +78,8 @@ pub struct LoaderConf { pub preferred: Option, /// How long to show the boot menu. pub timeout: Option, + /// What to do when the selected entry fails to start. + pub reboot_on_error: RebootOnError, /// Problems found while parsing, such as keys that are not supported. pub warnings: Vec, } @@ -88,6 +127,13 @@ impl LoaderConf { .warnings .push(format!("ignoring invalid loader.conf timeout '{}'", value)), }, + "reboot-on-error" => match RebootOnError::parse(value) { + Some(reboot_on_error) => conf.reboot_on_error = reboot_on_error, + None => conf.warnings.push(format!( + "ignoring invalid loader.conf reboot-on-error '{}'", + value + )), + }, "default" | "preferred" => conf .warnings .push(format!("ignoring loader.conf {} without a value", key)), diff --git a/crates/bls/src/pe.rs b/crates/bls/src/pe.rs index a9f89d6..3766c21 100644 --- a/crates/bls/src/pe.rs +++ b/crates/bls/src/pe.rs @@ -14,8 +14,9 @@ pub const PE_MACHINE_X86_64: u16 = 0x8664; /// The machine type of an aarch64 PE image. pub const PE_MACHINE_AARCH64: u16 = 0xaa64; -/// The most sections a PE image can have. -const MAX_SECTIONS: usize = 96; +/// The most sections that are read from a PE image. A unified kernel image with many profiles +/// has many more sections than the 96 that Windows allows, and the table is still small. +const MAX_SECTIONS: usize = 1024; /// The size of a section table entry. const SECTION_ENTRY_SIZE: usize = 40; @@ -59,22 +60,40 @@ fn u32_at(bytes: &[u8], offset: usize) -> u32 { pub struct PeImage { /// The machine type from the COFF header, such as [PE_MACHINE_X86_64]. pub machine: u16, - /// The contents of the wanted sections that were found. - pub sections: BTreeMap>, + /// The wanted sections, in the order of the section table, including repeated names. + pub sections: Vec, } -/// Reads the sections named in `wanted` from the PE image in `reader`. -/// See [read_pe] for the details. +/// A wanted section of a PE image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeSection { + /// The name of the section, such as `.osrel`. + pub name: String, + /// The contents of the section, or None if it is larger than [MAX_SECTION_SIZE]. A section + /// without contents is still there, as it takes the place of a section with the same name. + pub data: Option>, +} + +/// Reads the sections named in `wanted` from the PE image in `reader`, keeping the first +/// section of each name. See [read_pe] for the details. pub fn read_sections( reader: &mut R, wanted: &[&str], ) -> Result>> { - Ok(read_pe(reader, wanted)?.sections) + let mut sections = BTreeMap::new(); + for section in read_pe(reader, wanted)?.sections { + sections.entry(section.name).or_insert(section.data); + } + // A section that was too large is not available. + Ok(sections + .into_iter() + .filter_map(|(name, data)| data.map(|data| (name, data))) + .collect()) } /// Reads the machine type and the sections named in `wanted` from the PE image in `reader`. /// Only the headers and the wanted sections are read, so this is cheap on large images. -/// A wanted section that is missing, or larger than [MAX_SECTION_SIZE], is left out. +/// A wanted section that is larger than [MAX_SECTION_SIZE] is listed without its contents. /// An image that isn't a valid PE file is an error. pub fn read_pe(reader: &mut R, wanted: &[&str]) -> Result { // The DOS header starts with "MZ" and holds the offset of the PE header at 0x3c. @@ -102,14 +121,14 @@ pub fn read_pe(reader: &mut R, wanted: &[&str]) -> Result { let mut table = vec![0u8; section_count * SECTION_ENTRY_SIZE]; reader.read_at(pe_offset + 24 + optional_header_size, &mut table)?; - let mut sections = BTreeMap::new(); + let mut sections = Vec::new(); for entry in table.as_chunks::().0 { // The name is up to eight bytes, padded with NUL. let name_len = entry[..8].iter().position(|b| *b == 0).unwrap_or(8); let Ok(name) = core::str::from_utf8(&entry[..name_len]) else { continue; }; - if !wanted.contains(&name) || sections.contains_key(name) { + if !wanted.contains(&name) { continue; } @@ -117,18 +136,23 @@ pub fn read_pe(reader: &mut R, wanted: &[&str]) -> Result { let virtual_size = u64::from(u32_at(entry, 8)); let raw_size = u64::from(u32_at(entry, 16)); let raw_offset = u64::from(u32_at(entry, 20)); - let size = if virtual_size == 0 { - raw_size - } else { - virtual_size.min(raw_size) - }; + // A virtual size of zero makes the section empty, which is how a profile removes a section + // of the base image. + let size = virtual_size.min(raw_size); if size > MAX_SECTION_SIZE { + sections.push(PeSection { + name: name.to_string(), + data: None, + }); continue; } let mut data = vec![0u8; size as usize]; reader.read_at(raw_offset, &mut data)?; - sections.insert(name.to_string(), data); + sections.push(PeSection { + name: name.to_string(), + data: Some(data), + }); } Ok(PeImage { machine, sections }) diff --git a/crates/bls/src/uki.rs b/crates/bls/src/uki.rs index e8ec04d..acf4b8a 100644 --- a/crates/bls/src/uki.rs +++ b/crates/bls/src/uki.rs @@ -1,10 +1,14 @@ -use crate::BlsEntry; +use crate::{BlsEntry, PeSection}; use alloc::collections::BTreeMap; +use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; /// The PE sections of a unified kernel image that Sprout reads. -pub const UKI_SECTIONS: [&str; 3] = [".osrel", ".cmdline", ".uname"]; +pub const UKI_SECTIONS: [&str; 4] = [".osrel", ".cmdline", ".uname", ".profile"]; + +/// The most profiles that are read from a unified kernel image. +pub const MAX_PROFILES: usize = 256; /// A parsed os-release file, such as the `.osrel` section of a unified kernel image. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -68,12 +72,26 @@ fn section_text(sections: &BTreeMap>, name: &str) -> Option` 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); + if let Some(index) = profile.index { + entry.profile = (index > 0).then(|| index.to_string()); + entry.title = entry + .title + .take() + .map(|title| profile_title(&title, index, &profile.info)); + } + entry + } + + /// 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>, efi_path: &str) -> Self { + pub fn from_uki(sections: &BTreeMap>, uki_path: &str) -> Self { let os_release = section_text(sections, ".osrel") .map(|text| OsRelease::parse(&text)) .unwrap_or_default(); @@ -89,10 +107,138 @@ impl BlsEntry { title: first(&["PRETTY_NAME", "ID"]), version: first(&["IMAGE_VERSION", "VERSION_ID", "BUILD_ID"]).or_else(|| uname.clone()), sort_key: first(&["IMAGE_ID", "ID"]), - efi: Some(efi_path.to_string()), + uki: Some(uki_path.to_string()), cmdline: section_text(sections, ".cmdline"), uname, ..Self::default() } } } + +/// The metadata in the `.profile` section of a profile of a unified kernel image. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ProfileInfo { + /// A short identifier of the profile. + pub id: Option, + /// A title for people to read. + pub title: Option, +} + +impl ProfileInfo { + /// Parses the text of a `.profile` section, which is in the format of an os-release file. + /// A key without a value is treated as missing. + pub fn parse(text: &str) -> Self { + let os_release = OsRelease::parse(text); + let get = |key: &str| { + os_release + .get(key) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }; + Self { + id: get("ID"), + title: get("TITLE"), + } + } +} + +/// The sections that one profile of a unified kernel image boots with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UkiProfile { + /// The number of the profile, or None if the image has no `.profile` sections. + pub index: Option, + /// The metadata of the profile. + pub info: ProfileInfo, + /// The sections of the profile on top of those of the base image. A section that is empty + /// or too large to read, which is how a profile removes a section, is not here. + pub sections: BTreeMap>, +} + +/// Keeps the first section of each name in `range`. +fn first_of_each(range: &[PeSection]) -> BTreeMap>> { + let mut sections = BTreeMap::new(); + for section in range { + sections + .entry(section.name.clone()) + .or_insert_with(|| section.data.clone()); + } + sections +} + +/// Splits the `sections` of a unified kernel image, in the order of the section table, into the +/// profiles of the image. Each `.profile` section starts a profile that goes on until the next +/// one, and the sections before the first are the base that every profile starts from. +/// A section of a profile takes the place of the section of the same name in the base. +/// An image without `.profile` sections has one profile, which is the base. +pub fn uki_profiles(sections: &[PeSection]) -> Vec { + // Sections that are empty or too large are not available. + let usable = |map: BTreeMap>>| -> BTreeMap> { + map.into_iter() + .filter_map(|(name, data)| { + data.filter(|data| !data.is_empty()) + .map(|data| (name, data)) + }) + .collect() + }; + + let starts: Vec = sections + .iter() + .enumerate() + .filter(|(_, section)| section.name == ".profile") + .map(|(index, _)| index) + .collect(); + let Some(&first) = starts.first() else { + return alloc::vec![UkiProfile { + index: None, + info: ProfileInfo::default(), + sections: usable(first_of_each(sections)), + }]; + }; + + let base = first_of_each(§ions[..first]); + starts + .iter() + .take(MAX_PROFILES) + .enumerate() + .map(|(index, start)| { + let end = starts.get(index + 1).copied().unwrap_or(sections.len()); + let range = §ions[*start..end]; + let mut merged = base.clone(); + merged.extend(first_of_each(range)); + UkiProfile { + index: Some(index as u32), + info: range[0] + .data + .as_ref() + .map(|data| ProfileInfo::parse(&String::from_utf8_lossy(data))) + .unwrap_or_default(), + sections: usable(merged), + } + }) + .collect() +} + +/// The part of the id of an entry that comes after the `@`, which names the profile. +/// The first profile has none, and the others have their identifier or their number. +pub fn profile_id_suffix(index: u32, info: &ProfileInfo) -> Option { + (index > 0).then(|| info.id.clone().unwrap_or_else(|| index.to_string())) +} + +/// The title of the entry for a profile, given the `name` of the image. +pub fn profile_title(name: &str, index: u32, info: &ProfileInfo) -> String { + match (&info.title, &info.id) { + (Some(title), _) => format!("{} ({})", name, title), + (None, Some(id)) if index > 0 => format!("{} ({})", name, id), + (None, None) if index > 0 => format!("{} (Profile #{})", name, index + 1), + _ => name.to_string(), + } +} + +/// The id of the entry for a profile of the image with the file name `file_id`, such as +/// `fedora.efi`. The file name is in lower case, as in systemd-boot, and the profile is not. +pub fn profile_entry_id(file_id: &str, suffix: Option<&str>) -> String { + match suffix { + Some(suffix) => format!("{}@{}", file_id.to_lowercase(), suffix), + None => file_id.to_lowercase(), + } +} diff --git a/crates/boot/src/actions/chainload.rs b/crates/boot/src/actions/chainload.rs index 87cf877..ca551f4 100644 --- a/crates/boot/src/actions/chainload.rs +++ b/crates/boot/src/actions/chainload.rs @@ -1,16 +1,20 @@ use crate::context::SproutContext; use crate::phases::before_handoff; use alloc::boxed::Box; +use alloc::format; use alloc::rc::Rc; use alloc::vec::Vec; use anyhow::{Context, Result, bail}; use edera_sprout_config::actions::chainload::ChainloadConfiguration; use edera_sprout_parsing::{append_initrd, combine_options, empty_is_none}; use eficore::bootloader_interface::BootloaderInterface; +use eficore::devicetree::DeviceTree; use eficore::loader::source::ImageSource; use eficore::loader::{ImageLoadRequest, ImageLoader}; use eficore::media_loader::MediaLoaderHandle; use eficore::media_loader::constants::linux::LINUX_EFI_INITRD_MEDIA_GUID; +use eficore::path::ResolvedPath; +use eficore::secure::SecureBoot; use log::warn; use uefi::proto::loaded_image::LoadedImage; use uefi::{CString16, Handle}; @@ -18,20 +22,44 @@ use uefi::{CString16, Handle}; /// Read the initrd at the stamped `path` relative to the sprout image. /// Provides [None] if the path refers to the root of a filesystem rather than a file. pub fn read_initrd(context: &Rc, path: &str) -> Result>> { + read_optional_file(context, path, "initrd") +} + +/// Read the `what` at the stamped `path` relative to the sprout image. +/// Provides [None] if the path refers to the root of a filesystem rather than a file. +fn read_optional_file( + context: &Rc, + path: &str, + what: &str, +) -> Result>> { + let Some(resolved) = resolve_optional_file(context, path, what)? else { + return Ok(None); + }; + let content = resolved + .read_file() + .with_context(|| format!("unable to read {}", what))?; + Ok(Some(content)) +} + +/// Resolve the `what` at the stamped `path` relative to the sprout image. +/// Provides [None] if the path refers to the root of a filesystem rather than a file. +fn resolve_optional_file( + context: &Rc, + path: &str, + what: &str, +) -> Result> { let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path) - .context("unable to resolve initrd path")?; + .with_context(|| format!("unable to resolve {} path", what))?; - // A path without a file component refers to the root of the filesystem, not an initrd. + // A path without a file component refers to the root of the filesystem, not a file. // This happens when a path template like "$root\\$initrd-0" is stamped with an empty - // initrd value, such as a BLS entry without an initrd or an unused BLS initrd slot. + // value, such as a BLS entry without an initrd or an unused BLS initrd slot. let subpath = eficore::path::device_path_subpath(&resolved.full_path) - .context("unable to get initrd subpath")?; + .with_context(|| format!("unable to get {} subpath", what))?; if subpath.trim_matches('\\').is_empty() { return Ok(None); } - - let content = resolved.read_file().context("unable to read initrd")?; - Ok(Some(content)) + Ok(Some(resolved)) } /// Unloads the image with the contained handle when dropped, unless the handle is taken. @@ -143,6 +171,26 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat initrd_handle = Some(handle); } + // Install the devicetree, if there is one, for the image to find. It is restored when it is + // dropped, which is once the image has returned or failed to start. A devicetree is not + // verified, so it is not used when Secure Boot is enabled, or when that can't be told. + let mut devicetree = None; + if let Some(path) = empty_is_none( + configuration + .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")?); + } + } + // Mark execution of an entry in the bootloader interface. // This is only informational, so it should not prevent booting. if let Err(error) = BootloaderInterface::mark_exec(context.root().timer()) { @@ -175,6 +223,9 @@ pub fn chainload(context: Rc, configuration: &ChainloadConfigurat // Explicitly drop the initrd handle to clarify when it should be unregistered. drop(initrd_handle); + // Explicitly drop the devicetree to clarify when the original is restored. + drop(devicetree); + // Return control to sprout. Ok(()) } diff --git a/crates/boot/src/actions/edera.rs b/crates/boot/src/actions/edera.rs index 641eb9d..0aad623 100644 --- a/crates/boot/src/actions/edera.rs +++ b/crates/boot/src/actions/edera.rs @@ -132,6 +132,7 @@ pub fn edera(context: Rc, configuration: &EderaConfiguration) -> options: vec![], linux_initrd: None, linux_initrd_chain: vec![], + devicetree: None, }, ) .context("unable to chainload to xen"); diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index d19ad52..3ee9de3 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -102,6 +102,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, pin_names: true, }; @@ -125,6 +128,8 @@ pub fn scan( linux_initrd_chain: (0..BLS_INITRD_SLOTS) .map(|slot| format!("{}\\$initrd-{}", root, slot)) .collect(), + // An unset devicetree stamps to the root of the filesystem, which the action skips. + devicetree: Some(format!("{}\\$devicetree", root)), }; // Insert the chainload action into the configuration. diff --git a/crates/boot/src/autoconfigure/linux.rs b/crates/boot/src/autoconfigure/linux.rs index 3b5c7f0..efdbad1 100644 --- a/crates/boot/src/autoconfigure/linux.rs +++ b/crates/boot/src/autoconfigure/linux.rs @@ -237,6 +237,7 @@ pub fn scan( options: vec!["$linux-options".to_string()], linux_initrd: Some("$initrd".to_string()), linux_initrd_chain: vec![], + devicetree: None, }; // Insert the chainload action into the configuration. diff --git a/crates/boot/src/entries.rs b/crates/boot/src/entries.rs index 56d0e08..b1ca4c6 100644 --- a/crates/boot/src/entries.rs +++ b/crates/boot/src/entries.rs @@ -3,6 +3,7 @@ use crate::context::SproutContext; use alloc::format; use alloc::rc::Rc; use alloc::string::{String, ToString}; +use edera_sprout_bls::profile_entry_id; use edera_sprout_config::entries::EntryDeclaration; use edera_sprout_parsing::{fnmatch_ignore_case, glob_match}; @@ -18,6 +19,8 @@ pub struct BootableEntry { sort_key: Option, boot_counter: Option, id_suffix: Option, + id_profile: Option, + extra_profile: bool, } impl BootableEntry { @@ -38,6 +41,8 @@ impl BootableEntry { sort_key: None, boot_counter: None, id_suffix: None, + id_profile: None, + extra_profile: false, } } @@ -51,11 +56,43 @@ impl BootableEntry { /// `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(), + Some(ref suffix) => { + let file = format!("{}{}", self.sort_name(), suffix); + profile_entry_id(&file, self.id_profile.as_deref()) + } None => self.name.clone(), } } + /// Fetch the name of the entry without the profile of a unified kernel image, which is + /// the same for every profile of the same image. + pub fn sort_name(&self) -> &str { + self.id_profile + .as_deref() + .and_then(|profile| { + self.name + .strip_suffix(profile) + .and_then(|name| name.strip_suffix('@')) + }) + .unwrap_or(&self.name) + } + + /// Fetch whether the entry is a profile of a unified kernel image after the first, which + /// is never picked as the default entry unless it was asked for. + pub fn is_extra_profile(&self) -> bool { + self.id_profile.is_some() || self.extra_profile + } + + /// Mark this entry as booting a profile of a unified kernel image after the first. + pub fn mark_extra_profile(&mut self) { + self.extra_profile = true; + } + + /// Set the profile of a unified kernel image that is part of the id of this entry. + pub fn set_id_profile(&mut self, profile: &str) { + self.id_profile = Some(profile.to_string()); + } + /// 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()); diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 1dad3b5..64d85a5 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -9,7 +9,9 @@ use alloc::{ }; use anyhow::{Context, Result}; use core::{cmp::Ordering, str::FromStr}; -use edera_sprout_bls::{BlsEntry, BootCounter, is_reserved_entry_name, sort_bls, strip_extension}; +use edera_sprout_bls::{ + BlsEntry, BootCounter, is_reserved_entry_name, profile_id_suffix, sort_bls, strip_extension, +}; use edera_sprout_config::generators::bls::BlsConfiguration; use log::{info, warn}; use uefi::{ @@ -54,7 +56,7 @@ fn quirk_initrd_remove_tuned(paths: Vec) -> Vec { fn sort_entries(a: &(BlsEntry, BootableEntry), b: &(BlsEntry, BootableEntry)) -> Ordering { let (a_bls, a_boot) = a; let (b_bls, b_boot) = b; - sort_bls(a_bls, a_boot.name(), b_bls, b_boot.name()) + sort_bls(a_bls, a_boot.sort_name(), b_bls, b_boot.sort_name()) } /// Produces the bootable entry for the BLS `entry` with the id `name` and the `initrds`. @@ -64,6 +66,7 @@ fn bootable_entry( name: &str, entry: &BlsEntry, initrds: &[String], + entry_root: &str, ) -> BootableEntry { // Produce a new sprout context for the entry with the extracted values. let mut context = context.fork(); @@ -82,12 +85,16 @@ fn bootable_entry( title_base.clone() }; + // The device that the files of the entry are on, for a path like "$entry-root\\$chainload". + // It is empty for the partition that Sprout was loaded from. + context.set("entry-root", entry_root.to_string()); context.set("title-base", title_base); context.set("title", title_full); context.set("chainload", chainload); context.set("options", options); // The command line and kernel version embedded in a unified kernel image. context.set("cmdline", entry.cmdline.clone().unwrap_or_default()); + context.set("devicetree", entry.devicetree_path().unwrap_or_default()); context.set("uname", entry.uname.clone().unwrap_or_default()); // The initrd value keeps the last initrd, which is what it held before // multiple initrds were supported. @@ -129,6 +136,7 @@ fn generate_type1( context: &Rc, bls: &BlsConfiguration, path: &str, + entry_root: &str, ) -> Result> { let mut entries = Vec::new(); @@ -256,8 +264,11 @@ fn generate_type1( continue; } - let mut boot = bootable_entry(context, bls, &name, &entry, &initrds); + let mut boot = bootable_entry(context, bls, &name, &entry, &initrds, entry_root); boot.set_id_suffix(&extension); + if entry.profile_number() > 0 { + boot.mark_extra_profile(); + } // Record where the boot counter lives so a try can be consumed when this entry boots. if let Some(counter) = boot_counter { @@ -284,6 +295,7 @@ fn generate_type2( context: &Rc, bls: &BlsConfiguration, uki_path: &str, + entry_root: &str, entries: &[(BlsEntry, BootableEntry)], ) -> Result> { let resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), uki_path) @@ -296,8 +308,7 @@ fn generate_type2( let mut found: Vec<(BlsEntry, BootableEntry)> = Vec::new(); for image in uki::scan(resolved.filesystem_handle, &directory_name)? { - // Only unified kernel images are entries. Images built for another architecture, and - // other EFI programs that have no os-release section, are skipped. + // Only unified kernel images that are built for this machine are entries. if image.machine != PE_MACHINE { info!( "skipping {} as it is built for another architecture", @@ -305,13 +316,6 @@ fn generate_type2( ); continue; } - if !image.has_osrel { - info!( - "skipping {} as it has no .osrel section, so it is not a unified kernel image", - image.file_name - ); - continue; - } // The id is the file name without the extension and the boot counter. let file_name = image.file_name; @@ -328,53 +332,88 @@ fn generate_type2( } let id = id.to_string(); - let mut entry = image.entry; - entry.boot_counter = boot_counter; + // Every profile of the image is an entry. They share the file, and so the boot counter. + let mut suffixes: Vec = Vec::new(); + for profile in image.profiles { + // A profile without an os-release section is not a unified kernel image, such as + // other EFI programs. + if !profile.has_osrel { + info!( + "skipping {} as it has no .osrel section, so it is not a unified kernel image", + file_name + ); + continue; + } - let mut boot = bootable_entry(context, bls, &id, &entry, &[]); - boot.set_id_suffix(&extension); + // The first profile has the id of the file, and the others name their profile. + // A profile that repeats the identifier of an earlier one is told apart by its number. + let index = profile.index.unwrap_or(0); + let mut suffix = profile_id_suffix(index, &profile.info); + if let Some(ref name) = suffix + && suffixes.contains(name) + { + suffix = Some(index.to_string()); + } + if let Some(ref name) = suffix { + suffixes.push(name.clone()); + } + let name = match suffix { + Some(ref suffix) => format!("{}@{}", id, suffix), + None => id.clone(), + }; + + let mut entry = profile.entry; + entry.boot_counter = boot_counter; + + let mut boot = bootable_entry(context, bls, &name, &entry, &[], entry_root); + boot.set_id_suffix(&extension); + if let Some(ref suffix) = suffix { + boot.set_id_profile(suffix); + } - // 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(|(_, other)| other.id() == boot.id()) - { - warn!( - "unified kernel image {} has the same id as another entry, skipping", - file_name - ); - continue; - } + // 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(|(_, other)| other.id() == boot.id()) + { + warn!( + "unified kernel image {} has the same id as another entry, skipping", + file_name + ); + continue; + } - if let Some(counter) = boot_counter { - boot.set_boot_counter(BootCounterTarget { - counter, - filesystem: resolved.filesystem_handle, - directory: PathBuf::from(directory.clone()), - id, - file_name, - extension, - }); + if let Some(counter) = boot_counter { + boot.set_boot_counter(BootCounterTarget { + counter, + filesystem: resolved.filesystem_handle, + directory: PathBuf::from(directory.clone()), + id: id.clone(), + file_name: file_name.clone(), + extension: extension.clone(), + }); + } + found.push((entry, boot)); } - found.push((entry, boot)); } Ok(found) } -/// Generates entries from the BLS entries directory and the unified kernel image directory -/// using the specified `bls` configuration and `context`. The BLS conversion is best-effort -/// and will ignore any unsupported entries. -pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result> { - // Stamp the path to the BLS directory. - let path = context.stamp(&bls.path); - - let uki_path = bls.uki_path_for(&path); - +/// Generates the entries of one partition, from the BLS entries directory at `bls_path` and the +/// unified kernel image directory at `uki_path`, if there is one. The files of the entries are on +/// the device `entry_root`. +fn generate_partition( + context: &Rc, + bls: &BlsConfiguration, + bls_path: &str, + uki_path: Option<&str>, + entry_root: &str, +) -> Result> { // A problem reading the Type #1 entries only stops the unified kernel images when there // are none to add, so that either kind of entry can still boot. - let mut entries = match generate_type1(&context, bls, &path) { + let mut entries = match generate_type1(context, bls, bls_path, entry_root) { Ok(entries) => entries, Err(error) if uki_path.is_some() => { warn!("unable to generate bls entries: {:#}", error); @@ -386,8 +425,8 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result entries.extend(found), Err(error) => warn!( "unable to generate unified kernel image entries: {:#}", @@ -395,6 +434,82 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result) -> Result> { + let Some(path) = eficore::xbootldr::find_xbootldr(context.root().loaded_image_path()?)? else { + return Ok(None); + }; + let mut root = path + .to_string16(DisplayOnly(false), AllowShortcuts(false)) + .context("unable to convert the xbootldr device path to a string")? + .to_string(); + // Add a trailing forward-slash to the root to ensure the device root is completed. + root.push('/'); + Ok(Some(root)) +} + +/// Generates entries from the BLS entries directory and the unified kernel image directory +/// using the specified `bls` configuration and `context`, and from the Extended Boot Loader +/// Partition if that is enabled. The BLS conversion is best-effort and will ignore any +/// unsupported entries. +pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result> { + // Stamp the path to the BLS directory. + let path = context.stamp(&bls.path); + + let uki_path = bls.uki_path_for(&path); + // A problem with this partition only stops the generator when there is no other partition. + let mut entries = match generate_partition( + &context, + bls, + &path, + uki_path.as_deref(), + BlsConfiguration::root_of(&path), + ) { + Ok(entries) => entries, + Err(error) if bls.xbootldr => { + warn!("unable to generate bls entries: {:#}", error); + Vec::new() + } + Err(error) => return Err(error), + }; + + // Add the entries of the Extended Boot Loader Partition, which are sorted together with the + // others. A problem with it should not prevent booting from the entries that are found. + if bls.xbootldr { + match xbootldr_root(&context) { + Ok(Some(root)) => { + let xbootldr_uki = uki_path.as_ref().map(|_| format!("{}\\EFI\\Linux", root)); + match generate_partition( + &context, + bls, + &format!("{}\\loader", root), + xbootldr_uki.as_deref(), + &root, + ) { + Ok(found) => { + for (entry, boot) in found { + // An entry that is on both partitions would be ambiguous. + if entries.iter().any(|(_, other)| other.id() == boot.id()) { + warn!( + "xbootldr entry {} has the same id as another entry, skipping", + boot.id() + ); + } else { + entries.push((entry, boot)); + } + } + } + Err(error) => warn!("unable to generate xbootldr entries: {:#}", error), + } + } + Ok(None) => info!("no xbootldr partition was found"), + Err(error) => warn!("unable to find the xbootldr partition: {:#}", error), + } + } // Sort all the entries according to the BLS sort system. entries.sort_by(sort_entries); diff --git a/crates/boot/src/generators/bls/uki.rs b/crates/boot/src/generators/bls/uki.rs index 40091e6..942fd46 100644 --- a/crates/boot/src/generators/bls/uki.rs +++ b/crates/boot/src/generators/bls/uki.rs @@ -4,7 +4,9 @@ use alloc::{ vec::Vec, }; use anyhow::{Context, Result, anyhow, bail}; -use edera_sprout_bls::{BlsEntry, PeImage, ReadAt, UKI_SECTIONS, read_pe, strip_extension}; +use edera_sprout_bls::{ + BlsEntry, PeImage, ProfileInfo, ReadAt, UKI_SECTIONS, read_pe, strip_extension, uki_profiles, +}; use log::warn; use uefi::{ CString16, Handle, Status, @@ -18,9 +20,19 @@ pub struct UkiFile { pub file_name: String, /// The machine type of the image. pub machine: u16, - /// Whether the image has an `.osrel` section, which unified kernel images always have. + /// The profiles of the image. An image without profiles has one. + pub profiles: Vec, +} + +/// One profile of a unified kernel image. +pub struct UkiProfileEntry { + /// The number of the profile, or None if the image has no profiles. + pub index: Option, + /// The metadata of the profile. + pub info: ProfileInfo, + /// Whether the profile has an `.osrel` section, which unified kernel images always have. pub has_osrel: bool, - /// The entry made from the sections of the image. + /// The entry made from the sections of the profile. pub entry: BlsEntry, } @@ -94,9 +106,16 @@ pub fn scan(filesystem: Handle, directory: &str) -> Result> { Ok(image) => found.push(UkiFile { file_name, machine: image.machine, - has_osrel: image.sections.contains_key(".osrel"), - // The sections are dropped here, so only the small entry is kept per image. - entry: BlsEntry::from_uki(&image.sections, &path), + // The sections are dropped here, so only the small entries are kept per image. + profiles: uki_profiles(&image.sections) + .into_iter() + .map(|profile| UkiProfileEntry { + index: profile.index, + has_osrel: profile.sections.contains_key(".osrel"), + entry: BlsEntry::from_uki_profile(&profile, &path), + info: profile.info, + }) + .collect(), }), Err(error) => warn!("unable to read unified kernel image {}: {:#}", path, error), } diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 26973cf..586bb09 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -27,7 +27,7 @@ use eficore::{ setup, }; use log::{error, info, warn}; -use uefi::{entry, proto::device_path::LoadedImageDevicePath}; +use uefi::{entry, proto::device_path::LoadedImageDevicePath, runtime::ResetType}; use uefi_raw::Status; /// actions: Code that can be configured and executed by Sprout. @@ -112,7 +112,7 @@ fn load_loader_conf(context: &SproutContext) -> Result { } /// Run Sprout, returning an error if one occurs. -fn run() -> Result<()> { +fn run(reboot_on_error: &mut bool) -> Result<()> { // For safety reasons, we will note that Secure Boot is in beta on Sprout. if SecureBoot::enabled().context("unable to determine Secure Boot status")? { warn!("Sprout Secure Boot is in beta. Some functionality may not work as expected."); @@ -547,7 +547,7 @@ fn run() -> Result<()> { let default_flags = edera_sprout_bls::resolve_default_flags( &entries .iter() - .map(|entry| (entry.is_default(), entry.is_bad())) + .map(|entry| (entry.is_default(), entry.is_bad(), entry.is_extra_profile())) .collect::>(), ); for (entry, default) in entries.iter_mut().zip(default_flags) { @@ -644,10 +644,13 @@ fn run() -> Result<()> { // 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( @@ -663,6 +666,12 @@ fn run() -> Result<()> { } } + // 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); @@ -689,15 +698,28 @@ fn efi_main() -> Status { } // Run Sprout, then handle the error. - let result = run(); + let mut reboot_on_error = false; + let result = run(&mut reboot_on_error); if let Err(ref error) = result { // Print an error trace. error!("sprout encountered an error: {}", error); for (index, stack) in error.chain().enumerate() { error!("[{}]: {}", index, stack); } - // Sleep to allow the user to read the error. + // Sleep to allow the user to read the error. A reboot is announced first, so it is + // known why the machine is about to reset. + if reboot_on_error { + error!( + "rebooting in {} seconds after a failure to start the boot entry", + DELAY_ON_ERROR.as_secs() + ); + } uefi::boot::stall(DELAY_ON_ERROR); + + // Reboot when asked to, so that the next boot can use the next try or entry. + if reboot_on_error { + uefi::runtime::reset(ResetType::COLD, Status::SUCCESS, None); + } return Status::ABORTED; } diff --git a/crates/boot/src/menu/graphical.rs b/crates/boot/src/menu/graphical.rs index ebee1d1..e0e22ba 100644 --- a/crates/boot/src/menu/graphical.rs +++ b/crates/boot/src/menu/graphical.rs @@ -8,7 +8,7 @@ use alloc::vec::Vec; use anyhow::{Context, Result, bail}; use core::time::Duration; use eficore::framebuffer::Framebuffer; -use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol}; +use uefi::boot::{OpenProtocolParams, ScopedProtocol}; use uefi::proto::ProtocolPointer; use uefi::proto::console::gop::{BltPixel, GraphicsOutput}; use uefi::proto::console::pointer::{AbsolutePointer, Pointer}; @@ -99,24 +99,6 @@ struct Mouse { absolute: Option>, } -/// Open the protocol `P` on `handle` without taking it from the firmware. -/// An exclusive open makes the firmware disconnect every driver that is using the protocol, -/// and on some firmware that never returns. -fn open_shared(handle: Handle) -> uefi::Result> { - // SAFETY: The protocols opened this way are only used from this thread, and the firmware - // keeps them installed for as long as the menu is open. - unsafe { - uefi::boot::open_protocol::

( - OpenProtocolParams { - handle, - agent: uefi::boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - } -} - /// Open the pointer protocol `P`, preferring the one on the console input handle. /// That one belongs to the console splitter, which combines every device and is never removed. /// Otherwise, the first device that has the protocol is used. @@ -125,10 +107,10 @@ fn open_pointer() -> Option> { let console = uefi::table::system_table_raw() .and_then(|table| unsafe { Handle::from_ptr(table.as_ref().stdin_handle) }); console - .and_then(|handle| open_shared::

(handle).ok()) + .and_then(|handle| eficore::handle::open_shared::

(handle).ok()) .or_else(|| { uefi::boot::get_handle_for_protocol::

() - .and_then(open_shared::

) + .and_then(eficore::handle::open_shared::

) .ok() }) } @@ -407,7 +389,7 @@ impl Screen { for handle in handles { // Opening it exclusively would disconnect the firmware's text console from the // display, and anything printed after the menu may not show up. - let Ok(gop) = open_shared::(handle) else { + let Ok(gop) = eficore::handle::open_shared::(handle) else { continue; }; let (width, height) = gop.current_mode_info().resolution(); diff --git a/crates/config/src/actions/chainload.rs b/crates/config/src/actions/chainload.rs index f4984a0..70d89af 100644 --- a/crates/config/src/actions/chainload.rs +++ b/crates/config/src/actions/chainload.rs @@ -23,4 +23,9 @@ pub struct ChainloadConfiguration { /// using the same mechanism as `linux-initrd`. This cannot be used with `linux-initrd`. #[serde(default, rename = "linux-initrd-chain")] pub linux_initrd_chain: Vec, + /// An optional path to a flattened devicetree to give to the image. + /// It is installed as the devicetree of the machine until the image returns. It is not + /// used when Secure Boot is enabled, as it can't be verified. + #[serde(default)] + pub devicetree: Option, } diff --git a/crates/config/src/generators/bls.rs b/crates/config/src/generators/bls.rs index f69f709..a4cbffa 100644 --- a/crates/config/src/generators/bls.rs +++ b/crates/config/src/generators/bls.rs @@ -26,6 +26,13 @@ pub struct BlsConfiguration { /// with the same names, so they should not both scan it. #[serde(default, rename = "uki-path")] pub uki_path: Option, + /// Whether to also read the Extended Boot Loader Partition, which is the partition with the + /// XBOOTLDR type on the same disk as the partition Sprout was loaded from. Its entries are + /// sorted with the others, and their paths are on that partition, so an action has to use + /// the `$entry-root` value to find them, such as `$entry-root\\$chainload`. Its unified kernel + /// images are always in `\\EFI\\Linux`. + #[serde(default)] + pub xbootldr: bool, /// Whether generated entries keep the name of the BLS entry file as-is. /// When enabled, the generator name is not prepended, so the entry names match /// the BLS entry ids used by tools like `bootctl set-default`. Variants still append @@ -38,6 +45,12 @@ pub struct BlsConfiguration { } impl BlsConfiguration { + /// The device of the stamped BLS path `bls_path`, which is everything up to the last slash, + /// and which is empty for a path on the partition Sprout was loaded from. + pub fn root_of(bls_path: &str) -> &str { + bls_path.rfind('/').map_or("", |index| &bls_path[..=index]) + } + /// The directory of unified kernel images for the stamped BLS path `bls_path`, /// or None if unified kernel images are disabled. pub fn uki_path_for(&self, bls_path: &str) -> Option { @@ -45,10 +58,7 @@ impl BlsConfiguration { Some("") => None, Some(path) => Some(path.to_string()), // Keep the device of the BLS path, which is everything up to the last slash. - None => { - let device = bls_path.rfind('/').map_or("", |index| &bls_path[..=index]); - Some(format!("{}{}", device, BLS_UKI_DIRECTORY)) - } + None => Some(format!("{}{}", Self::root_of(bls_path), BLS_UKI_DIRECTORY)), } } } @@ -60,6 +70,7 @@ impl Default for BlsConfiguration { entry: Default::default(), path: default_bls_path(), uki_path: None, + xbootldr: false, pin_names: default_pin_names(), } } @@ -77,6 +88,20 @@ fn default_bls_path() -> String { mod tests { use super::*; + #[test] + fn root_is_the_device_of_the_bls_path() { + assert_eq!(BlsConfiguration::root_of("\\loader"), ""); + assert_eq!( + BlsConfiguration::root_of("PciRoot(0x0)/HD(2,GPT,abc)/\\loader"), + "PciRoot(0x0)/HD(2,GPT,abc)/" + ); + } + + #[test] + fn xbootldr_is_off_by_default() { + assert!(!BlsConfiguration::default().xbootldr); + } + #[test] fn uki_path_is_next_to_the_default_bls_path() { let bls = BlsConfiguration::default(); diff --git a/crates/eficore/src/bootloader_interface.rs b/crates/eficore/src/bootloader_interface.rs index c776105..feafc22 100644 --- a/crates/eficore/src/bootloader_interface.rs +++ b/crates/eficore/src/bootloader_interface.rs @@ -67,6 +67,8 @@ impl BootloaderInterface { | LoaderFeatures::SavedEntry | LoaderFeatures::EntryPreferred | LoaderFeatures::SortKey + | LoaderFeatures::DeviceTree + | LoaderFeatures::MultiProfileUki } /// Tell the system that Sprout was initialized at the current time. diff --git a/crates/eficore/src/devicetree.rs b/crates/eficore/src/devicetree.rs new file mode 100644 index 0000000..d3a2b54 --- /dev/null +++ b/crates/eficore/src/devicetree.rs @@ -0,0 +1,192 @@ +use crate::handle::find_handle; +use anyhow::{Context, Result, anyhow, bail}; +use core::ffi::c_void; +use core::ptr::{self, NonNull}; +use log::{info, warn}; +use uefi::boot::{AllocateType, MemoryType, PAGE_SIZE}; +use uefi::proto::unsafe_protocol; +use uefi_raw::{Guid, Status, guid}; + +/// The configuration table that holds the flattened devicetree. +const DTB_TABLE_GUID: Guid = guid!("b1b621d5-f19c-41a5-830b-d9152c69aae0"); + +/// The protocol that firmware such as U-Boot provides to patch a devicetree for the machine. +const DT_FIXUP_GUID: Guid = guid!("e617d64c-fe08-46da-f4dc-bbd5870c7300"); + +/// The magic number at the start of a flattened devicetree. +const FDT_MAGIC: u32 = 0xd00d_feed; + +/// The size of the fixed header of a flattened devicetree. +const FDT_HEADER_SIZE: usize = 7 * 4; + +/// The largest devicetree that is accepted. The Linux stub copies it into a smaller buffer. +const MAX_DTB_SIZE: usize = 32 * 1024 * 1024; + +/// Apply the fixups for the machine to the devicetree. +const DT_APPLY_FIXUPS: u32 = 0x1; + +/// Reserve the memory that the devicetree says to reserve. +const DT_RESERVE_MEMORY: u32 = 0x2; + +/// The protocol that patches a devicetree for the machine. +#[unsafe_protocol(DT_FIXUP_GUID)] +#[repr(C)] +struct DtFixupProtocol { + revision: u64, + fixup: unsafe extern "efiapi" fn( + this: *mut DtFixupProtocol, + fdt: *mut c_void, + buffer_size: *mut usize, + flags: u32, + ) -> Status, +} + +/// A devicetree that is installed for the image that is about to start. When this is dropped, +/// which is when the image returned or failed to start, the devicetree of the firmware is +/// put back and the memory is freed. +pub struct DeviceTree { + /// The pages that hold the devicetree. + pages: NonNull, + /// The number of pages. + count: usize, + /// The devicetree table of the firmware, which is null if there was none. + original: *const c_void, + /// Whether the table points to the pages. + installed: bool, +} + +impl DeviceTree { + /// Check that `dtb` looks like a flattened devicetree that can be installed. + fn validate(dtb: &[u8]) -> Result<()> { + if dtb.len() < FDT_HEADER_SIZE || dtb.len() > MAX_DTB_SIZE { + bail!("the devicetree is {} bytes, which is not valid", dtb.len()); + } + // The header is big endian: the magic number, then the total size. + let magic = u32::from_be_bytes([dtb[0], dtb[1], dtb[2], dtb[3]]); + let total = u32::from_be_bytes([dtb[4], dtb[5], dtb[6], dtb[7]]) as usize; + if magic != FDT_MAGIC { + bail!("the file is not a flattened devicetree"); + } + if total < FDT_HEADER_SIZE || total > dtb.len() { + bail!("the devicetree has an invalid size"); + } + Ok(()) + } + + /// Allocate `count` pages of memory that belongs to the firmware tables, as a devicetree + /// has to be in memory of the type that ACPI tables are in. + fn allocate(count: usize) -> Result> { + uefi::boot::allocate_pages(AllocateType::AnyPages, MemoryType::ACPI_RECLAIM, count) + .context("unable to allocate memory for the devicetree") + } + + /// Install `dtb` as the devicetree of the machine until the returned value is dropped. + pub fn install(dtb: &[u8]) -> Result { + Self::validate(dtb)?; + + // Remember the table of the firmware so it can be put back. + let original = uefi::system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == DTB_TABLE_GUID) + .map(|entry| entry.address) + .unwrap_or(ptr::null()) + }); + + let count = dtb.len().div_ceil(PAGE_SIZE); + let pages = Self::allocate(count)?; + // SAFETY: The pages are at least as large as the devicetree, and don't overlap it. + unsafe { ptr::copy_nonoverlapping(dtb.as_ptr(), pages.as_ptr(), dtb.len()) }; + let mut tree = Self { + pages, + count, + original, + installed: false, + }; + + tree.fixup(dtb)?; + + // SAFETY: The table points to pages that stay allocated until this is dropped, which + // puts the original table back before the pages are freed. + unsafe { + uefi::boot::install_configuration_table(&DTB_TABLE_GUID, tree.pages.as_ptr().cast()) + } + .context("unable to install the devicetree")?; + tree.installed = true; + + info!( + "installed the devicetree at {:p} ({} bytes)", + tree.pages.as_ptr(), + dtb.len() + ); + Ok(tree) + } + + /// Let the firmware patch the devicetree for the machine, if it can. + fn fixup(&mut self, dtb: &[u8]) -> Result<()> { + let Some(handle) = find_handle(&DT_FIXUP_GUID)? else { + return Ok(()); + }; + // Without the protocol the devicetree is used as it is, as in systemd-boot. + let Ok(mut protocol) = crate::handle::open_shared::(handle) else { + warn!("unable to open the devicetree fixup protocol, skipping the fixup"); + return Ok(()); + }; + let fixup = protocol.fixup; + let this: *mut DtFixupProtocol = &mut *protocol; + let flags = DT_APPLY_FIXUPS | DT_RESERVE_MEMORY; + + // The firmware gets all the memory that was allocated. If that is not enough, it says + // how much it needs, and it is given a copy of the original in a larger allocation. + let mut size = self.count * PAGE_SIZE; + // SAFETY: The pages are valid for the size that is passed. + let mut status = unsafe { fixup(this, self.pages.as_ptr().cast(), &mut size, flags) }; + if status == Status::BUFFER_TOO_SMALL { + let count = size.div_ceil(PAGE_SIZE); + let pages = Self::allocate(count)?; + // SAFETY: The new pages are larger than the devicetree, and don't overlap it. + unsafe { ptr::copy_nonoverlapping(dtb.as_ptr(), pages.as_ptr(), dtb.len()) }; + // SAFETY: The old pages came from the same allocator and nothing refers to them yet. + if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } { + warn!("unable to free the devicetree memory: {}", error); + } + self.pages = pages; + self.count = count; + size = count * PAGE_SIZE; + // SAFETY: The new pages are valid for the size that is passed. + status = unsafe { fixup(this, self.pages.as_ptr().cast(), &mut size, flags) }; + } + if status != Status::SUCCESS { + return Err(anyhow!("the devicetree fixup failed: {:?}", status)); + } + Ok(()) + } +} + +impl Drop for DeviceTree { + fn drop(&mut self) { + if self.installed { + // SAFETY: This puts back the table that the firmware had, which is null if it had + // none, and which removes the table. + let result = + unsafe { uefi::boot::install_configuration_table(&DTB_TABLE_GUID, self.original) }; + if let Err(error) = result { + // The table can still point to the pages, so they must not be freed. + if !(error.status() == Status::NOT_FOUND && self.original.is_null()) { + warn!("unable to restore the devicetree: {}", error); + return; + } + } + } + if self.installed { + info!( + "restored the devicetree table of the firmware ({:p})", + self.original + ); + } + // SAFETY: Nothing refers to the pages any more. + if let Err(error) = unsafe { uefi::boot::free_pages(self.pages, self.count) } { + warn!("unable to free the devicetree memory: {}", error); + } + } +} diff --git a/crates/eficore/src/handle.rs b/crates/eficore/src/handle.rs index ed0ca1e..c69b616 100644 --- a/crates/eficore/src/handle.rs +++ b/crates/eficore/src/handle.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; -use uefi::boot::SearchType; +use uefi::boot::{OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, SearchType}; +use uefi::proto::ProtocolPointer; use uefi::{Guid, Handle}; use uefi_raw::Status; @@ -24,3 +25,21 @@ pub fn find_handle(protocol: &Guid) -> Result> { } } } + +/// Open the protocol `P` on `handle` without taking it from the firmware. +/// An exclusive open makes the firmware disconnect every driver that is using the protocol, +/// which for a disk or a partition would remove the filesystem Sprout is running from. +pub fn open_shared(handle: Handle) -> uefi::Result> { + // SAFETY: The protocols opened this way are only used from this thread, and the firmware + // keeps them installed for as long as they are open. + unsafe { + uefi::boot::open_protocol::

( + OpenProtocolParams { + handle, + agent: uefi::boot::image_handle(), + controller: None, + }, + OpenProtocolAttributes::GetProtocol, + ) + } +} diff --git a/crates/eficore/src/lib.rs b/crates/eficore/src/lib.rs index 3819196..af7b742 100644 --- a/crates/eficore/src/lib.rs +++ b/crates/eficore/src/lib.rs @@ -6,6 +6,9 @@ #![no_std] extern crate alloc; +/// Installing a devicetree for the image that is started. +pub mod devicetree; + /// EFI handle helpers. pub mod handle; @@ -27,6 +30,9 @@ pub mod platform; /// Secure Boot support. pub mod secure; +/// Finding the Extended Boot Loader Partition. +pub mod xbootldr; + /// Support for the shim loader application that enables Secure Boot. pub mod shim; diff --git a/crates/eficore/src/xbootldr.rs b/crates/eficore/src/xbootldr.rs new file mode 100644 index 0000000..04f86bd --- /dev/null +++ b/crates/eficore/src/xbootldr.rs @@ -0,0 +1,82 @@ +use crate::handle::open_shared; +use alloc::boxed::Box; +use alloc::vec::Vec; +use anyhow::{Context, Result}; +use uefi::proto::device_path::{DevicePath, DevicePathNode, DeviceSubType, DeviceType}; +use uefi::proto::media::fs::SimpleFileSystem; +use uefi::proto::media::partition::PartitionInfo; +use uefi::{Guid, guid}; + +/// The GPT partition type of the Extended Boot Loader Partition. +/// Reference: +pub const XBOOTLDR_PARTITION_TYPE: Guid = guid!("bc13c2ff-59e6-4262-a352-b275fd6f7172"); + +/// Whether `node` is the node of a partition on a hard drive. +fn is_hard_drive_node(node: &DevicePathNode) -> bool { + node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_HARD_DRIVE) +} + +/// The device nodes of `path`, without the file path nodes that follow the device. +fn device_nodes(path: &DevicePath) -> Vec<&DevicePathNode> { + path.node_iter() + .filter(|node| node.full_type() != (DeviceType::MEDIA, DeviceSubType::MEDIA_FILE_PATH)) + .collect() +} + +/// Find the Extended Boot Loader Partition on the same disk as the partition at `path`, +/// which is usually the EFI system partition that Sprout was loaded from. Only a partition +/// that has a filesystem the firmware can read is found. Returns the device path of its +/// filesystem, or None if there is no such partition, such as on a disk without a GPT. +pub fn find_xbootldr(path: &DevicePath) -> Result>> { + // The partition is the last hard drive node. The disk is everything before it. + let own = device_nodes(path); + let Some(partition_index) = own.iter().rposition(|node| is_hard_drive_node(node)) else { + // There is no partition, such as when booting from an optical disc or the network. + return Ok(None); + }; + let disk = &own[..partition_index]; + let own_partition = own[partition_index]; + + let handles = + uefi::boot::find_handles::().context("unable to find the filesystems")?; + + let mut found: Option<(u32, Box)> = None; + for handle in handles { + let Ok(candidate_path) = open_shared::(handle) else { + continue; + }; + let nodes = device_nodes(&candidate_path); + + // The candidate must be a partition of the same disk, and not the partition itself. + if nodes.len() != disk.len() + 1 + || nodes[..disk.len()] != *disk + || !is_hard_drive_node(nodes[disk.len()]) + || nodes[disk.len()] == own_partition + { + continue; + } + + // The partition type is only available for a GPT partition. + let Ok(info) = open_shared::(handle) else { + continue; + }; + let Some(entry) = info.gpt_partition_entry() else { + continue; + }; + // The entry is packed, so the field is copied before it is compared. + let partition_type = entry.partition_type_guid.0; + if partition_type != XBOOTLDR_PARTITION_TYPE { + continue; + } + + // There should only be one, but if there are several, use the first partition. + let number = <&uefi::proto::device_path::media::HardDrive>::try_from(nodes[disk.len()]) + .map(|drive| drive.partition_number()) + .unwrap_or(u32::MAX); + if found.as_ref().is_none_or(|(best, _)| number < *best) { + found = Some((number, candidate_path.to_boxed())); + } + } + + Ok(found.map(|(_, path)| path)) +}