diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 6f1c0e9..bbdd3b5 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -13,10 +13,13 @@ mod uki; 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. @@ -282,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()) @@ -347,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; @@ -429,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, @@ -443,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 @@ -786,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()); } @@ -875,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] @@ -1122,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]); } @@ -1142,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] ); } 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/entries.rs b/crates/boot/src/entries.rs index 56d0e08..b0d4923 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,7 @@ pub struct BootableEntry { sort_key: Option, boot_counter: Option, id_suffix: Option, + id_profile: Option, } impl BootableEntry { @@ -38,6 +40,7 @@ impl BootableEntry { sort_key: None, boot_counter: None, id_suffix: None, + id_profile: None, } } @@ -51,11 +54,38 @@ 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() + } + + /// 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 57ecd04..2a47ef4 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`. @@ -297,8 +299,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", @@ -306,13 +307,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; @@ -329,37 +323,71 @@ 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(), + }; - // 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; + let mut entry = profile.entry; + entry.boot_counter = boot_counter; + + let mut boot = bootable_entry(context, bls, &name, &entry, &[]); + 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; + } + + 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)); } - - 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, - }); - } - found.push((entry, boot)); } Ok(found) } 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 bb392fc..e1780ff 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -547,7 +547,7 @@ 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())) + .map(|entry| (entry.is_default(), entry.is_bad(), entry.is_extra_profile())) .collect::>(), ); for (entry, default) in entries.iter_mut().zip(default_flags) {