From f4221131f1d8fe4ba69aeb4534967cd6fe1b3581 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 19:09:17 -0700 Subject: [PATCH] feat(bls): boot unified kernel images (Type #2 entries) (#111) * feat(bls): read PE sections without loading the whole image * feat(bls): produce entries from unified kernel images * feat(bls): boot unified kernel images as type 2 entries Scan the EFI/Linux directory, or the new uki-path option of the BLS generator, for .efi files and turn each one into an entry using the metadata in its PE sections. Autoconfigure now also detects a disk that only has unified kernel images. An image with the id of a type 1 entry is skipped, and boot counting works on the .efi file name. * fix(boot): rename boot counter files in place instead of copying them * fix(bls): skip hidden files and keep unified kernel images when the type 1 scan fails Check file name suffixes without slicing by byte offset, drop the PE section bytes once an image has been parsed, and only fail the generator on a type 1 error when unified kernel images are disabled. --- crates/bls/src/lib.rs | 231 ++++++++++++++++++++ crates/bls/src/pe.rs | 135 ++++++++++++ crates/bls/src/uki.rs | 98 +++++++++ crates/boot/src/autoconfigure/bls.rs | 25 ++- crates/boot/src/boot_counter.rs | 69 ++++-- crates/boot/src/entries.rs | 6 +- crates/boot/src/generators/bls.rs | 289 +++++++++++++++++++------- crates/boot/src/generators/bls/uki.rs | 117 +++++++++++ crates/config/src/generators/bls.rs | 73 +++++++ 9 files changed, 948 insertions(+), 95 deletions(-) create mode 100644 crates/bls/src/pe.rs create mode 100644 crates/bls/src/uki.rs create mode 100644 crates/boot/src/generators/bls/uki.rs diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 0543351..579cf15 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -8,8 +8,15 @@ use anyhow::{Error, Result}; use core::{cmp::Ordering, iter::Peekable, str::FromStr}; mod loader_conf; +mod pe; +mod uki; pub use loader_conf::{LoaderConf, LoaderTimeout}; +pub use pe::{ + MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, ReadAt, read_pe, + read_sections, +}; +pub use uki::{OsRelease, UKI_SECTIONS}; /// Represents a parsed BLS entry. /// Fields unrelated to Sprout are not included. @@ -31,6 +38,10 @@ pub struct BlsEntry { pub version: Option, /// The machine id of the entry. pub machine_id: Option, + /// The command line embedded in a unified kernel image. + pub cmdline: Option, + /// The kernel version embedded in a unified kernel image. + pub uname: Option, /// The boot counter of the entry, taken from the entry file name. /// This is never set by parsing the file content. pub boot_counter: Option, @@ -136,6 +147,8 @@ impl FromStr for BlsEntry { sort_key, version, machine_id, + cmdline: None, + uname: None, boot_counter: None, }) } @@ -252,6 +265,16 @@ impl BootCounter { } } +/// Splits the `extension` off the end of the file `name`, ignoring case. +/// Returns the stem and the extension as it was written in the name. +pub fn strip_extension<'a>(name: &'a str, extension: &str) -> Option<(&'a str, &'a str)> { + let split = name.len().checked_sub(extension.len())?; + if !name.is_char_boundary(split) || !name[split..].eq_ignore_ascii_case(extension) { + return None; + } + Some(name.split_at(split)) +} + /// Converts an EFI `path` to the form used by the bootloader interface: separated by forward /// slashes, with a leading slash and no repeated or trailing separators. pub fn boot_path(path: &str) -> String { @@ -541,6 +564,8 @@ fn compare_alphabetic>( #[cfg(test)] mod tests { use super::*; + use alloc::collections::BTreeMap; + use alloc::vec; use core::cmp::Ordering; fn sort_entry( @@ -597,6 +622,212 @@ mod tests { assert!(!entry.is_bad()); } + /// Builds a minimal PE image with the given sections, each padded to 16 bytes of raw data. + fn pe_image(sections: &[(&str, &[u8])]) -> Vec { + let table_start = 0x80 + 24; + let data_start = table_start + sections.len() * 40; + let mut image = vec![0u8; data_start]; + image[..2].copy_from_slice(b"MZ"); + image[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes()); + image[0x80..0x84].copy_from_slice(b"PE\0\0"); + image[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes()); + image[0x86..0x88].copy_from_slice(&(sections.len() as u16).to_le_bytes()); + let mut offset = data_start; + for (index, (name, data)) in sections.iter().enumerate() { + let entry = table_start + index * 40; + image[entry..entry + name.len()].copy_from_slice(name.as_bytes()); + let raw_size = data.len().div_ceil(16) * 16; + image[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_le_bytes()); + image[entry + 16..entry + 20].copy_from_slice(&(raw_size as u32).to_le_bytes()); + image[entry + 20..entry + 24].copy_from_slice(&(offset as u32).to_le_bytes()); + let mut raw = data.to_vec(); + raw.resize(raw_size, 0); + image.extend_from_slice(&raw); + offset += raw_size; + } + image + } + + fn uki_sections(items: &[(&str, &str)]) -> BTreeMap> { + items + .iter() + .map(|(name, data)| (name.to_string(), data.as_bytes().to_vec())) + .collect() + } + + #[test] + fn os_release_parses_plain_quoted_and_commented_lines() { + let os_release = OsRelease::parse( + "# comment\nID=arch\nPRETTY_NAME=\"Arch Linux\"\nNAME='Arch'\n\nBAD LINE\n", + ); + assert_eq!(os_release.get("ID"), Some("arch")); + assert_eq!(os_release.get("PRETTY_NAME"), Some("Arch Linux")); + assert_eq!(os_release.get("NAME"), Some("Arch")); + assert_eq!(os_release.get("MISSING"), None); + } + + #[test] + fn os_release_unescapes_quoted_values_and_last_value_wins() { + let os_release = OsRelease::parse("A=\"say \\\"hi\\\" \\\\ \\$x\"\nB=1\nB=2\n"); + assert_eq!(os_release.get("A"), Some("say \"hi\" \\ $x")); + assert_eq!(os_release.get("B"), Some("2")); + } + + #[test] + fn uki_entry_uses_os_release_title_version_and_sort_key() { + let sections = uki_sections(&[ + ( + ".osrel", + "ID=fedora\nIMAGE_ID=coreos\nPRETTY_NAME=\"Fedora CoreOS\"\nVERSION_ID=40\nIMAGE_VERSION=40.1\n", + ), + (".uname", "6.5.0\0"), + ]); + let entry = BlsEntry::from_uki(§ions, "/EFI/Linux/fedora.efi"); + assert_eq!(entry.title.as_deref(), Some("Fedora CoreOS")); + 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!(entry.options.is_none()); + assert!(entry.is_valid()); + } + + #[test] + fn uki_entry_falls_back_through_the_fields() { + let sections = uki_sections(&[(".osrel", "ID=arch\nBUILD_ID=rolling\n")]); + let entry = BlsEntry::from_uki(§ions, "/EFI/Linux/a.efi"); + assert_eq!(entry.title.as_deref(), Some("arch")); + assert_eq!(entry.version.as_deref(), Some("rolling")); + assert_eq!(entry.sort_key.as_deref(), Some("arch")); + + let sections = uki_sections(&[(".uname", "6.1.2\n")]); + let entry = BlsEntry::from_uki(§ions, "/EFI/Linux/a.efi"); + assert_eq!(entry.title, None); + assert_eq!(entry.version.as_deref(), Some("6.1.2")); + } + + #[test] + fn uki_entry_keeps_the_trimmed_cmdline() { + let sections = uki_sections(&[(".cmdline", "root=/dev/sda1 quiet\0\n")]); + let entry = BlsEntry::from_uki(§ions, "/EFI/Linux/a.efi"); + assert_eq!(entry.cmdline.as_deref(), Some("root=/dev/sda1 quiet")); + } + + #[test] + fn uki_entry_without_sections_is_still_bootable() { + let entry = BlsEntry::from_uki(&BTreeMap::new(), "/EFI/Linux/a.efi"); + assert!(entry.title.is_none()); + assert!(entry.is_valid()); + assert_eq!(entry.chainload_path().as_deref(), Some("EFI\\Linux\\a.efi")); + } + + #[test] + fn pe_reads_only_the_requested_sections() { + let image = pe_image(&[ + (".text", b"code"), + (".osrel", b"ID=arch\n"), + (".uname", b"6.5"), + ]); + let sections = read_sections(&mut image.as_slice(), &[".osrel", ".uname"]).unwrap(); + assert_eq!(sections.len(), 2); + assert_eq!(sections[".osrel"], b"ID=arch\n"); + assert_eq!(sections[".uname"], b"6.5"); + } + + #[test] + fn strip_extension_ignores_case_and_keeps_the_original_extension() { + assert_eq!( + strip_extension("foo+3.conf", ".conf"), + Some(("foo+3", ".conf")) + ); + assert_eq!(strip_extension("FOO.EFI", ".efi"), Some(("FOO", ".EFI"))); + assert_eq!(strip_extension("a.Efi", ".efi"), Some(("a", ".Efi"))); + } + + #[test] + fn strip_extension_rejects_other_names() { + assert_eq!(strip_extension("foo.conf", ".efi"), None); + assert_eq!(strip_extension("efi", ".efi"), None); + assert_eq!(strip_extension("", ".efi"), None); + assert_eq!(strip_extension("foo.efi.bak", ".efi"), None); + } + + #[test] + fn strip_extension_does_not_split_characters() { + // The last bytes of this name are not at a character boundary for a four byte suffix. + assert_eq!(strip_extension("a\u{e9}fi", ".efi"), None); + assert_eq!( + strip_extension("\u{e9}.efi", ".efi"), + Some(("\u{e9}", ".efi")) + ); + } + + #[test] + fn strip_extension_of_a_bare_extension_has_an_empty_stem() { + assert_eq!(strip_extension(".efi", ".efi"), Some(("", ".efi"))); + } + + #[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")); + } + + #[test] + fn pe_leaves_out_missing_sections() { + let image = pe_image(&[(".text", b"code")]); + let sections = read_sections(&mut image.as_slice(), &[".osrel"]).unwrap(); + assert!(sections.is_empty()); + } + + #[test] + fn pe_uses_the_virtual_size_not_the_padded_size() { + let image = pe_image(&[(".cmdline", b"quiet")]); + let sections = read_sections(&mut image.as_slice(), &[".cmdline"]).unwrap(); + assert_eq!(sections[".cmdline"], b"quiet"); + } + + #[test] + fn pe_skips_sections_over_the_size_limit() { + let big = vec![b'a'; MAX_SECTION_SIZE as usize + 1]; + let image = pe_image(&[(".cmdline", &big), (".osrel", b"ID=x")]); + let sections = read_sections(&mut image.as_slice(), &[".cmdline", ".osrel"]).unwrap(); + assert!(!sections.contains_key(".cmdline")); + assert!(sections.contains_key(".osrel")); + } + + #[test] + fn pe_rejects_images_that_are_not_pe() { + assert!(read_sections(&mut b"".as_slice(), &[".osrel"]).is_err()); + assert!(read_sections(&mut vec![0u8; 0x200].as_slice(), &[".osrel"]).is_err()); + let mut image = pe_image(&[(".osrel", b"ID=x")]); + image[0x80] = b'X'; + assert!(read_sections(&mut image.as_slice(), &[".osrel"]).is_err()); + } + + #[test] + fn pe_rejects_a_missing_mz_signature() { + let mut image = pe_image(&[(".osrel", b"ID=x")]); + image[0] = b'X'; + assert!(read_sections(&mut image.as_slice(), &[".osrel"]).is_err()); + } + + #[test] + fn pe_rejects_a_truncated_image() { + let image = pe_image(&[(".osrel", b"ID=archlinux\n")]); + let truncated = &image[..image.len() - 8]; + assert!(read_sections(&mut &truncated[..], &[".osrel"]).is_err()); + } + + #[test] + fn pe_rejects_too_many_sections() { + let mut image = pe_image(&[(".osrel", b"ID=x")]); + image[0x86..0x88].copy_from_slice(&200u16.to_le_bytes()); + assert!(read_sections(&mut image.as_slice(), &[".osrel"]).is_err()); + } + #[test] fn boot_path_uses_forward_slashes_and_a_leading_slash() { assert_eq!( diff --git a/crates/bls/src/pe.rs b/crates/bls/src/pe.rs new file mode 100644 index 0000000..a9f89d6 --- /dev/null +++ b/crates/bls/src/pe.rs @@ -0,0 +1,135 @@ +use alloc::collections::BTreeMap; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use anyhow::{Result, bail}; + +/// The largest section that will be read. UKI metadata sections are small, and a limit +/// keeps a corrupt image from causing a large allocation. +pub const MAX_SECTION_SIZE: u64 = 64 * 1024; + +/// The machine type of an x86_64 PE image. +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 size of a section table entry. +const SECTION_ENTRY_SIZE: usize = 40; + +/// A source of bytes that can be read at an offset, such as a file. +pub trait ReadAt { + /// Fills `buf` with the bytes at `offset`, failing if they can't all be read. + fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()>; +} + +/// Reads from a slice of bytes, which is useful for images that are already in memory. +impl ReadAt for &[u8] { + fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> { + let start = usize::try_from(offset)?; + let end = start.checked_add(buf.len()); + match end.and_then(|end| self.get(start..end)) { + Some(bytes) => { + buf.copy_from_slice(bytes); + Ok(()) + } + None => bail!("read past the end of the image"), + } + } +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +/// The parts of a PE image that were read. +#[derive(Debug, Clone, PartialEq, Eq)] +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>, +} + +/// Reads the sections named in `wanted` from the PE image in `reader`. +/// See [read_pe] for the details. +pub fn read_sections( + reader: &mut R, + wanted: &[&str], +) -> Result>> { + Ok(read_pe(reader, wanted)?.sections) +} + +/// 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. +/// 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. + let mut dos = [0u8; 0x40]; + reader.read_at(0, &mut dos)?; + if &dos[..2] != b"MZ" { + bail!("not a PE image: missing the MZ signature"); + } + let pe_offset = u64::from(u32_at(&dos, 0x3c)); + + // The PE header is the "PE\0\0" signature followed by the COFF header. + let mut coff = [0u8; 24]; + reader.read_at(pe_offset, &mut coff)?; + if &coff[..4] != b"PE\0\0" { + bail!("not a PE image: missing the PE signature"); + } + let machine = u16_at(&coff, 4); + let section_count = usize::from(u16_at(&coff, 6)); + let optional_header_size = u64::from(u16_at(&coff, 20)); + if section_count > MAX_SECTIONS { + bail!("PE image has too many sections: {}", section_count); + } + + // The section table follows the optional header. + 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(); + 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) { + continue; + } + + // The real size is the virtual size, as the raw size is padded to the file alignment. + 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) + }; + if size > MAX_SECTION_SIZE { + continue; + } + + let mut data = vec![0u8; size as usize]; + reader.read_at(raw_offset, &mut data)?; + sections.insert(name.to_string(), data); + } + + Ok(PeImage { machine, sections }) +} diff --git a/crates/bls/src/uki.rs b/crates/bls/src/uki.rs new file mode 100644 index 0000000..e8ec04d --- /dev/null +++ b/crates/bls/src/uki.rs @@ -0,0 +1,98 @@ +use crate::BlsEntry; +use alloc::collections::BTreeMap; +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"]; + +/// A parsed os-release file, such as the `.osrel` section of a unified kernel image. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct OsRelease { + fields: BTreeMap, +} + +impl OsRelease { + /// Parses the `input` as an os-release file, which has one `KEY=value` per line. + /// Lines that aren't a setting are ignored, and the last value of a key is used. + pub fn parse(input: &str) -> Self { + let mut fields = BTreeMap::new(); + for line in input.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + fields.insert(key.trim().to_string(), unquote(value.trim())); + } + Self { fields } + } + + /// Fetches the value of `key`, if it is set. + pub fn get(&self, key: &str) -> Option<&str> { + self.fields.get(key).map(String::as_str) + } +} + +/// Removes one pair of matching quotes from `value` and unescapes a double quoted value. +fn unquote(value: &str) -> String { + if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { + return value[1..value.len() - 1].to_string(); + } + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + let mut result = String::new(); + let mut chars = value[1..value.len() - 1].chars(); + while let Some(c) = chars.next() { + // Only these characters can be escaped. Any other backslash is kept as written. + match (c, chars.clone().next()) { + ('\\', Some(next @ ('"' | '\\' | '$' | '`'))) => { + result.push(next); + chars.next(); + } + _ => result.push(c), + } + } + return result; + } + value.to_string() +} + +/// Decodes a section as text, dropping the NUL and whitespace padding at the end. +/// An empty section is treated as missing. +fn section_text(sections: &BTreeMap>, name: &str) -> Option { + let text = String::from_utf8_lossy(sections.get(name)?); + let text = text.trim_end_matches(|c: char| c == '\0' || c.is_whitespace()); + (!text.is_empty()).then(|| text.to_string()) +} + +impl BlsEntry { + /// Produces an entry for the unified kernel image at `efi_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 { + let os_release = section_text(sections, ".osrel") + .map(|text| OsRelease::parse(&text)) + .unwrap_or_default(); + let first = |keys: &[&str]| { + keys.iter() + .filter_map(|key| os_release.get(key)) + .find(|value| !value.is_empty()) + .map(ToString::to_string) + }; + let uname = section_text(sections, ".uname"); + + Self { + 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()), + cmdline: section_text(sections, ".cmdline"), + uname, + ..Self::default() + } + } +} diff --git a/crates/boot/src/autoconfigure/bls.rs b/crates/boot/src/autoconfigure/bls.rs index 3d47431..d19ad52 100644 --- a/crates/boot/src/autoconfigure/bls.rs +++ b/crates/boot/src/autoconfigure/bls.rs @@ -28,6 +28,8 @@ pub fn scan( let bls_loader_conf_path = Path::new(cstr16!("\\loader\\loader.conf")); // BLS also has an entries directory that can specify explicit entries. let bls_entries_path = Path::new(cstr16!("\\loader\\entries")); + // BLS Type #2 entries are unified kernel images in the EFI/Linux directory. + let bls_uki_path = Path::new(cstr16!("\\EFI\\Linux")); // Convert the device path root to a string we can use in the configuration. let mut root = root @@ -64,9 +66,27 @@ pub fn scan( }) .unwrap_or(false); + // Whether we have any unified kernel images. + let has_ukis = filesystem + .read_dir(bls_uki_path) + .map(|mut iterator| { + iterator.any(|entry| { + entry.is_ok_and(|entry| { + entry.is_regular_file() + && entry + .file_name() + .to_string() + .to_lowercase() + .ends_with(".efi") + }) + }) + }) + .unwrap_or(false); + // Detect if a BLS supported configuration is on this filesystem. - // We check both loader.conf and entries directory as only one of them is required. - if !(has_loader_conf || has_entries_dir) { + // We check loader.conf, the entries directory and unified kernel images, as only one of + // them is required. + if !(has_loader_conf || has_entries_dir || has_ukis) { return Ok(false); } @@ -81,6 +101,7 @@ pub fn scan( ..Default::default() }, path: format!("{}\\loader", root), + uki_path: None, pin_names: true, }; diff --git a/crates/boot/src/boot_counter.rs b/crates/boot/src/boot_counter.rs index a1dca9e..3ffd6e6 100644 --- a/crates/boot/src/boot_counter.rs +++ b/crates/boot/src/boot_counter.rs @@ -1,12 +1,18 @@ use alloc::{format, string::String}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use edera_sprout_bls::BootCounter; use uefi::{ CString16, Handle, - fs::{FileSystem, PathBuf}, + data_types::Align, + fs::PathBuf, + mem::AlignedBuffer, + proto::media::file::{File, FileAttribute, FileInfo, FileMode}, proto::media::fs::SimpleFileSystem, }; +/// The size of the fixed part of a file info: three sizes, three timestamps and the attributes. +const FILE_INFO_HEADER_SIZE: usize = 3 * 8 + 3 * 16 + 8; + /// Where the boot counter of an entry is stored, so that a try can be consumed when the /// entry is booted. #[derive(Clone)] @@ -21,34 +27,71 @@ pub struct BootCounterTarget { pub id: String, /// The current file name, including the counter and the extension. pub file_name: String, + /// The extension of the file name, such as `.conf`, in its original case. + pub extension: String, } impl BootCounterTarget { /// Consumes one try by renaming the entry file, such as `foo+3.conf` to `foo+2-1.conf`. + /// The file is renamed in place without copying it, as unified kernel images are large and + /// a copy interrupted by a crash would leave a truncated image behind. /// Returns the path of the renamed file. pub fn consume(&self) -> Result { - // The extension is taken from the current file name, as it may be in any case. - let extension = &self.file_name[self.file_name.len() - ".conf".len()..]; let new_name = format!( "{}{}", self.counter.decremented().render(&self.id), - extension + self.extension ); + let new_name_16 = + CString16::try_from(new_name.as_str()).context("invalid new entry file name")?; + let mut fs = uefi::boot::open_protocol_exclusive::(self.filesystem) + .context("unable to open the entry filesystem")?; + let mut root = fs + .open_volume() + .context("unable to open the entry volume")?; + + // Open the entry file for writing. let mut from = self.directory.clone(); from.push(PathBuf::from( CString16::try_from(self.file_name.as_str()).context("invalid entry file name")?, )); - let mut to = self.directory.clone(); - to.push(PathBuf::from( - CString16::try_from(new_name.as_str()).context("invalid new entry file name")?, - )); + let from_name = + CString16::try_from(format!("{}", from).as_str()).context("invalid entry file path")?; + let handle = root + .open(&from_name, FileMode::ReadWrite, FileAttribute::empty()) + .context("unable to open the entry file")?; + let mut file = handle + .into_regular_file() + .ok_or_else(|| anyhow!("the entry is not a regular file"))?; - let fs = uefi::boot::open_protocol_exclusive::(self.filesystem) - .context("unable to open the entry filesystem")?; - let mut fs = FileSystem::new(fs); - fs.rename(&from, &to) + // Renaming a file is done by setting its file info with a different name. + let info = file + .get_boxed_info::() + .context("unable to get the entry file info")?; + // The info is a fixed header followed by the null-terminated UCS-2 name. + let size = + FILE_INFO_HEADER_SIZE + 2 * (new_name.len() + 1) + ::alignment(); + let mut storage = AlignedBuffer::from_size_align(size, ::alignment()) + .context("unable to allocate the entry file info")?; + let renamed = FileInfo::new( + storage.as_slice_mut(), + info.file_size(), + info.physical_size(), + *info.create_time(), + *info.last_access_time(), + *info.modification_time(), + info.attribute(), + &new_name_16, + ) + .map_err(|error| anyhow!("unable to build the renamed entry file info: {:?}", error))?; + file.set_info(renamed) .context("unable to rename the entry file")?; + file.flush() + .context("unable to flush the renamed entry file")?; + + let mut to = self.directory.clone(); + to.push(PathBuf::from(new_name_16)); Ok(to) } } diff --git a/crates/boot/src/entries.rs b/crates/boot/src/entries.rs index e6c1284..c3583cc 100644 --- a/crates/boot/src/entries.rs +++ b/crates/boot/src/entries.rs @@ -134,10 +134,12 @@ impl BootableEntry { } /// Determine if this entry matches `needle` by comparing to the id of the entry, which is - /// the name with or without the `.conf` suffix. This is how loader.conf selects entries. + /// the name with or without the `.conf` or `.efi` suffix. This is how loader.conf selects entries. /// The `needle` is a glob pattern, where `*` matches any sequence of characters. pub fn is_match_id(&self, needle: &str) -> bool { - glob_match(needle, &self.name) || glob_match(needle, &format!("{}.conf", self.name)) + glob_match(needle, &self.name) + || glob_match(needle, &format!("{}.conf", self.name)) + || glob_match(needle, &format!("{}.efi", self.name)) } /// Create a variant of this entry, with the name suffixed by `suffix` and using `context`. diff --git a/crates/boot/src/generators/bls.rs b/crates/boot/src/generators/bls.rs index 2aa135b..b38d5bf 100644 --- a/crates/boot/src/generators/bls.rs +++ b/crates/boot/src/generators/bls.rs @@ -9,9 +9,9 @@ use alloc::{ }; use anyhow::{Context, Result}; use core::{cmp::Ordering, str::FromStr}; -use edera_sprout_bls::{BlsEntry, BootCounter, sort_bls}; +use edera_sprout_bls::{BlsEntry, BootCounter, sort_bls, strip_extension}; use edera_sprout_config::generators::bls::BlsConfiguration; -use log::warn; +use log::{info, warn}; use uefi::{ cstr16, fs::{FileSystem, PathBuf}, @@ -19,6 +19,15 @@ use uefi::{ proto::media::fs::SimpleFileSystem, }; +mod uki; + +/// The PE machine type of the images that can boot on this machine. +#[cfg(target_arch = "x86_64")] +const PE_MACHINE: u16 = edera_sprout_bls::PE_MACHINE_X86_64; +/// The PE machine type of the images that can boot on this machine. +#[cfg(target_arch = "aarch64")] +const PE_MACHINE: u16 = edera_sprout_bls::PE_MACHINE_AARCH64; + /// The number of initrd slots set on each BLS entry. /// Entries set `initrd-0` through `initrd-7`, and slots without an initrd are empty. pub const BLS_INITRD_SLOTS: usize = 8; @@ -41,18 +50,84 @@ fn sort_entries(a: &(BlsEntry, BootableEntry), b: &(BlsEntry, BootableEntry)) -> sort_bls(a_bls, a_boot.name(), b_bls, b_boot.name()) } -/// Generates entries from the BLS entries 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> { +/// Produces the bootable entry for the BLS `entry` with the id `name` and the `initrds`. +fn bootable_entry( + context: &Rc, + bls: &BlsConfiguration, + name: &str, + entry: &BlsEntry, + initrds: &[String], +) -> BootableEntry { + // Produce a new sprout context for the entry with the extracted values. + let mut context = context.fork(); + + let title_base = entry.title().unwrap_or_else(|| name.to_string()); + let chainload = entry.chainload_path().unwrap_or_default(); + let options = entry.options().unwrap_or_default(); + let version = entry.version().unwrap_or_default(); + let machine_id = entry.machine_id().unwrap_or_default(); + + // Combine the title with the version if a version is present, except if it already contains it. + // Sometimes BLS will have a version in the title already, and this makes it unique. + let title_full = if !version.is_empty() && !title_base.contains(&version) { + format!("{} {}", title_base, version) + } else { + title_base.clone() + }; + + 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("uname", entry.uname.clone().unwrap_or_default()); + // The initrd value keeps the last initrd, which is what it held before + // multiple initrds were supported. + context.set("initrd", initrds.last().cloned().unwrap_or_default()); + + // Set every initrd slot, even the unused ones. An unset slot would let "$initrd-1" + // match "$initrd" instead, producing the initrd value followed by "-1". + for slot in 0..BLS_INITRD_SLOTS { + context.set( + format!("initrd-{}", slot), + initrds.get(slot).cloned().unwrap_or_default(), + ); + } + + context.set("version", version); + context.set("machine-id", machine_id); + + // Produce a new bootable entry. + let mut boot = BootableEntry::new( + name.to_string(), + bls.entry.title.clone(), + context.freeze(), + bls.entry.clone(), + ); + + // Pin the entry name to prevent prefixing. + // This is needed as the bootloader interface requires the name to be + // the same as the entry file name, minus the .conf extension. + if bls.pin_names { + boot.mark_pin_name(); + } + + boot +} + +/// Generates the BLS Type #1 entries from the entries directory under `path`. +/// The conversion is best-effort and will ignore any unsupported entries. +fn generate_type1( + context: &Rc, + bls: &BlsConfiguration, + path: &str, +) -> Result> { let mut entries = Vec::new(); - // Stamp the path to the BLS directory. - let path = context.stamp(&bls.path); - // Resolve the path to the BLS directory. - let bls_resolved = - eficore::path::resolve_path(Some(context.root().loaded_image_path()?), &path) - .context("unable to resolve bls path")?; + let bls_resolved = eficore::path::resolve_path(Some(context.root().loaded_image_path()?), path) + .context("unable to resolve bls path")?; // Construct a filesystem path to the BLS entries directory. let mut entries_path = PathBuf::from( @@ -94,26 +169,22 @@ pub fn generate(context: Rc, bls: &BlsConfiguration) -> Result