#![no_std] extern crate alloc; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; 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, RebootOnError}; pub use pe::{ MAX_SECTION_SIZE, PE_MACHINE_AARCH64, PE_MACHINE_X86_64, PeImage, PeSection, ReadAt, read_pe, read_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. #[derive(Default, Debug, Clone)] pub struct BlsEntry { /// The title of the entry. pub title: Option, /// The options to pass to the entry. pub options: Option, /// The path to the linux kernel. pub linux: Option, /// The paths to the initrds, in the order they appear. pub initrd: Vec, /// The path to an EFI image. 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`. pub architecture: Option, /// The sort key for the entry. pub sort_key: Option, /// The version of the entry. 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, } /// Parser for a BLS entry. impl FromStr for BlsEntry { type Err = Error; /// Parses the `input` as a BLS entry file. fn from_str(input: &str) -> Result { // All the fields in a BLS entry we understand. // Set all to None initially. let mut title: Option = None; let mut options: Option = None; let mut linux: Option = None; 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; let mut version: Option = None; let mut machine_id: Option = None; // Some editors write a UTF-8 byte order mark at the start of the file. // It isn't whitespace, so it would otherwise be part of the first key. let input = input.strip_prefix('\u{feff}').unwrap_or(input); // Iterate over each line in the input and parse it. for line in input.lines() { let line = line.trim(); // Skip over empty lines and comments. if line.is_empty() || line.starts_with('#') { continue; } // Split the line once by whitespace. This technically includes newlines but since // the lines iterator is used, there should never be a newline here. let Some((key, value)) = line.split_once(char::is_whitespace) else { continue; }; // Match the key to a field we understand. match key { // The title of the entry. "title" => { title = Some(value.trim().to_string()); } // The options to pass to the entry. The options key can appear more than once, // and all the options are combined in the order they appear. "options" => { let value = value.trim(); match options.as_mut() { Some(options) => { options.push(' '); options.push_str(value); } None => options = Some(value.to_string()), } } // The path to the linux kernel. "linux" => { linux = Some(value.trim().to_string()); } // The path to an initrd. An entry can have multiple initrds, either as // repeated initrd lines or as multiple paths separated by whitespace. "initrd" => { initrd.extend(value.split_whitespace().map(|path| path.to_string())); } // The path to an EFI image. "efi" => { 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()); } // The profile of the unified kernel image to boot. "profile" => { profile = Some(value.trim().to_string()); } // The architecture the entry is for. "architecture" => { architecture = Some(value.trim().to_string()); } "sort-key" => { sort_key = Some(value.trim().to_string()); } "version" => { version = Some(value.trim().to_string()); } "machine-id" => { machine_id = Some(value.trim().to_string()); } // Ignore any other key. _ => { continue; } } } // Produce a BLS entry from the parsed fields. Ok(Self { title, options, linux, initrd, efi, uki, devicetree, profile, architecture, sort_key, version, machine_id, cmdline: None, uname: None, boot_counter: None, }) } } impl BlsEntry { /// Checks if this BLS entry is something we can actually boot in Sprout. pub fn is_valid(&self) -> bool { self.linux.is_some() || self.efi.is_some() || self.uki.is_some() } /// Whether the entry names more than one of `linux`, `efi` and `uki` to boot. /// systemd-boot considers such an entry broken and does not show it. pub fn mixes_boot_targets(&self) -> bool { [&self.linux, &self.efi, &self.uki] .iter() .filter(|target| target.is_some()) .count() > 1 } /// Whether the entry only boots a unified kernel image, which carries its own initrd. fn is_uki_only(&self) -> bool { self.linux.is_none() && self.efi.is_none() && self.uki.is_some() } /// Whether the entry is for the architecture named `machine`, such as `x64`. /// An entry without an architecture is for every architecture. pub fn matches_architecture(&self, machine: &str) -> bool { self.architecture .as_deref() .is_none_or(|architecture| architecture.eq_ignore_ascii_case(machine)) } /// Fetches the load options to pass to the image that is chainloaded. /// A unified kernel image with a profile gets `@` first, which selects the profile. pub fn chainload_options(&self) -> Option { // Only a profile that is a number above zero selects a profile, as in systemd-boot. let profile = self .profile .as_deref() .and_then(parse_digits) .filter(|profile| *profile > 0); match (self.is_uki_only(), profile, self.options()) { (true, Some(profile), Some(options)) => Some(format!("@{} {}", profile, options)), (true, Some(profile), None) => Some(format!("@{}", profile)), (_, _, options) => options, } } /// Fetches the path to an EFI bootable image to boot, if any. /// This prioritizes the linux field over efi, and efi over uki. /// It also converts / to \\ to match EFI path style. pub fn chainload_path(&self) -> Option { self.linux .clone() .or(self.efi.clone()) .or(self.uki.clone()) .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 { // A unified kernel image has its initrd built in. if self.is_uki_only() { return Vec::new(); } self.initrd .iter() .map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string()) .collect() } /// Fetches the options to pass to the kernel, if any. pub fn options(&self) -> Option { self.options.clone() } /// Fetches the title of the entry, if any. pub fn title(&self) -> Option { self.title.clone() } /// Fetches the sort key of the entry, if any. pub fn sort_key(&self) -> Option { self.sort_key.clone() } /// Fetches the version of the entry, if any. pub fn version(&self) -> Option { self.version.clone() } /// Fetches the machine id of the entry, if any. pub fn machine_id(&self) -> Option { 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()) } } /// The boot counter of a BLS entry, encoded in the entry file name as `+[-]`. /// Reference: #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BootCounter { /// The number of boot attempts left. pub tries_left: u32, /// The number of boot attempts already made. pub tries_done: u32, } impl BootCounter { /// Creates a counter with the given number of tries left and tries done. pub fn new(tries_left: u32, tries_done: u32) -> Self { Self { tries_left, tries_done, } } /// Splits the entry file name `stem` (without `.conf`) into the entry id and its boot counter. /// A stem without a valid counter is returned whole, with no counter. pub fn parse(stem: &str) -> (&str, Option) { Self::try_parse(stem).map_or((stem, None), |(id, counter)| (id, Some(counter))) } fn try_parse(stem: &str) -> Option<(&str, Self)> { let (id, suffix) = stem.rsplit_once('+')?; if id.is_empty() { return None; } let (left, done) = suffix.split_once('-').unwrap_or((suffix, "0")); Some((id, Self::new(parse_digits(left)?, parse_digits(done)?))) } /// Produces the counter after one more boot attempt. pub fn decremented(&self) -> Self { Self::new( self.tries_left.saturating_sub(1), self.tries_done.saturating_add(1), ) } /// Renders the entry file name stem for the entry `id` with this counter. /// The counters are plain decimals, without leading zeros, as systemd-boot writes them and /// as systemd-bless-boot can read them. pub fn render(&self, id: &str) -> String { if self.tries_done == 0 { format!("{}+{}", id, self.tries_left) } else { format!("{}+{}-{}", id, self.tries_left, self.tries_done) } } /// Whether the entry has run out of tries. pub fn is_bad(&self) -> bool { self.tries_left == 0 } } /// 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, 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)) .collect(); if !flags.iter().any(|default| *default) { let pick = entries .iter() .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; } } flags } /// 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)) } /// Whether the entry file `name` is reserved for the entries that the boot loader makes itself, /// which are named with `auto-`. Such files are not entries. pub fn is_reserved_entry_name(name: &str) -> bool { name.get(..5) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("auto-")) } /// 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 { let mut result = String::new(); for part in path.split(['\\', '/']).filter(|part| !part.is_empty()) { result.push('/'); result.push_str(part); } if result.is_empty() { result.push('/'); } result } /// Parses `value` as a number made only of ASCII digits. fn parse_digits(value: &str) -> Option { if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { return None; } value.parse().ok() } /// Sorts two BLS entries according to the BLS sort system. /// `a_name` and `b_name` are the entry filenames (without `.conf`) used as the /// final tiebreaker when all other fields are equal. /// Reference: pub fn sort_bls(a_bls: &BlsEntry, a_name: &str, b_bls: &BlsEntry, b_name: &str) -> Ordering { // Grab the sort keys from both entries. let a_sort_key = a_bls.sort_key(); let b_sort_key = b_bls.sort_key(); // Sort keys, machine-id and version are only considered when both entries have a sort key. // If only one entry has a sort key, that entry sorts first. let ordering = match (a_sort_key, b_sort_key) { (Some(a_sort_key), Some(b_sort_key)) => a_sort_key .cmp(&b_sort_key) // If the sort keys are equal, sort by machine-id. .then_with(|| a_bls.machine_id().cmp(&b_bls.machine_id())) // If the machine-id values are equal, sort by version, sorting newer versions first. .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, }; // Entries with no boot counter tries left sort after every other entry. let ordering = a_bls.is_bad().cmp(&b_bls.is_bad()).then(ordering); // If all else is equal, compare the file names of both entries, sorting newer entries first. // Entries that are still equal, such as one file in the middle of being renamed, sort by // 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 .boot_counter .map_or((0, 0), |counter| (counter.tries_left, counter.tries_done)) }; let (a_left, a_done) = tries(a_bls); let (b_left, b_done) = tries(b_bls); b_left.cmp(&a_left).then(a_done.cmp(&b_done)) }) } /// Handles single character advancement and comparison. macro_rules! handle_single_char { ($ca: expr, $cb:expr, $a_chars:expr, $b_chars:expr, $c:expr) => { match ($ca == $c, $cb == $c) { (true, false) => return Ordering::Less, (false, true) => return Ordering::Greater, (true, true) => { $a_chars.next(); $b_chars.next(); continue; } _ => {} } }; } /// Compares two strings using the BLS version comparison specification. /// Handles optional values as well by comparing only if both are specified. /// A missing value is considered older than any specified value. pub fn compare_versions_optional(a: Option<&str>, b: Option<&str>) -> Ordering { match (a, b) { // If both have values, compare them. (Some(a), Some(b)) => compare_versions(a, b), // If the second value is None, then `a` is greater than `b`. (Some(_a), None) => Ordering::Greater, // If the first value is None, the `a` is less than `b`. (None, Some(_b)) => Ordering::Less, // If both values are None, return that they are equal. (None, None) => Ordering::Equal, } } /// Compares two strings using the BLS version comparison specification. /// See: pub fn compare_versions(a: &str, b: &str) -> Ordering { // An empty string is older than any other string. if a.is_empty() || b.is_empty() { return a.len().cmp(&b.len()); } // Acquire a peekable iterator for each string. let mut a_chars = a.chars().peekable(); let mut b_chars = b.chars().peekable(); // Loop until we have reached the end of one of the strings. loop { // Skip invalid characters in both strings. skip_invalid(&mut a_chars); skip_invalid(&mut b_chars); // Handle the ~ character. This must happen before the end of string check, // as a string with ~ is older than a string that has ended (1~rc1 < 1). match (a_chars.peek() == Some(&'~'), b_chars.peek() == Some(&'~')) { (true, false) => return Ordering::Less, (false, true) => return Ordering::Greater, (true, true) => { a_chars.next(); b_chars.next(); continue; } (false, false) => {} } // Check if either string has ended. match (a_chars.peek(), b_chars.peek()) { // No more characters in either string. (None, None) => return Ordering::Equal, // One string has ended, the other hasn't. (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, // Both strings have characters left. (Some(&ca), Some(&cb)) => { // Handle '-' character. handle_single_char!(ca, cb, a_chars, b_chars, '-'); // Handle the '^' character. handle_single_char!(ca, cb, a_chars, b_chars, '^'); // Handle the '.' character. handle_single_char!(ca, cb, a_chars, b_chars, '.'); // Handle digits with numerical comparison. // If only one string has digits at this position, that string is newer. if ca.is_ascii_digit() || cb.is_ascii_digit() { if !ca.is_ascii_digit() { return Ordering::Less; } if !cb.is_ascii_digit() { return Ordering::Greater; } let result = compare_numeric(&mut a_chars, &mut b_chars); if result != Ordering::Equal { return result; } continue; } // Handle letters with alphabetical comparison. // We key off of the A character being alphabetical intentionally as we presume // this indicates it will be the same at this position. if ca.is_ascii_alphabetic() || cb.is_ascii_alphabetic() { let result = compare_alphabetic(&mut a_chars, &mut b_chars); if result != Ordering::Equal { return result; } continue; } } } } } /// Skips characters that are not in the valid character set. fn skip_invalid>(iter: &mut Peekable) { while let Some(&c) = iter.peek() { if is_valid_char(c) { break; } iter.next(); } } /// Checks if a character is in the valid character set for comparison. fn is_valid_char(c: char) -> bool { matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '.' | '~' | '^') } /// Compares numerical prefixes by extracting numbers. /// Numbers are compared by their digit count first and then digit by digit, /// which supports numbers of any length. fn compare_numeric>( iter_a: &mut Peekable, iter_b: &mut Peekable, ) -> Ordering { let num_a = extract_number(iter_a); let num_b = extract_number(iter_b); num_a .len() .cmp(&num_b.len()) .then_with(|| num_a.cmp(&num_b)) } /// Extracts the digits of a number from the iterator, skipping leading zeros. fn extract_number>(iter: &mut Peekable) -> String { // Skip leading zeros while let Some(&'0') = iter.peek() { iter.next(); } let mut num = String::new(); while let Some(&c) = iter.peek() { if c.is_ascii_digit() { iter.next(); num.push(c); } else { break; } } num } /// Compares alphabetical prefixes. /// Capital letters compare lower than lowercase letters (B < a). fn compare_alphabetic>( iter_a: &mut Peekable, iter_b: &mut Peekable, ) -> Ordering { loop { return match (iter_a.peek(), iter_b.peek()) { (Some(&ca), Some(&cb)) if ca.is_ascii_alphabetic() && cb.is_ascii_alphabetic() => { if ca == cb { // Same character, we should continue. iter_a.next(); iter_b.next(); continue; } // Different characters found. // All capital letters compare lower than lowercase letters. match (ca.is_ascii_uppercase(), cb.is_ascii_uppercase()) { (true, false) => Ordering::Less, // uppercase < lowercase (false, true) => Ordering::Greater, // lowercase > uppercase (true, true) => ca.cmp(&cb), // both are uppercase (false, false) => ca.cmp(&cb), // both are lowercase } } (Some(&ca), Some(_)) if ca.is_ascii_alphabetic() => { // a has letters, b doesn't Ordering::Greater } (Some(_), Some(&cb)) if cb.is_ascii_alphabetic() => { // b has letters, a doesn't Ordering::Less } (Some(&ca), None) if ca.is_ascii_alphabetic() => Ordering::Greater, (None, Some(&cb)) if cb.is_ascii_alphabetic() => Ordering::Less, _ => Ordering::Equal, }; } } #[cfg(test)] mod tests { use super::*; use alloc::collections::BTreeMap; use alloc::vec; use core::cmp::Ordering; fn sort_entry( sort_key: Option<&str>, machine_id: Option<&str>, version: Option<&str>, ) -> BlsEntry { BlsEntry { sort_key: sort_key.map(|s| s.to_string()), machine_id: machine_id.map(|s| s.to_string()), version: version.map(|s| s.to_string()), linux: Some("/vmlinuz".to_string()), ..BlsEntry::default() } } fn counted_entry(sort_key: &str, tries_left: u32) -> BlsEntry { BlsEntry { boot_counter: Some(BootCounter::new(tries_left, 0)), ..sort_entry(Some(sort_key), None, None) } } #[test] fn bad_entries_sort_after_good_entries() { // "a" would normally sort first, but it has no tries left. let bad = counted_entry("a", 0); let good = counted_entry("z", 2); assert_eq!(sort_bls(&bad, "bad", &good, "good"), Ordering::Greater); assert_eq!(sort_bls(&good, "good", &bad, "bad"), Ordering::Less); } #[test] fn bad_entries_sort_after_entries_without_counters() { let bad = counted_entry("a", 0); let plain = sort_entry(Some("z"), None, None); assert_eq!(sort_bls(&bad, "bad", &plain, "plain"), Ordering::Greater); } #[test] fn bad_entries_use_normal_order_among_themselves() { let a = counted_entry("a", 0); let b = counted_entry("b", 0); assert_eq!(sort_bls(&a, "a", &b, "b"), Ordering::Less); } #[test] fn parsed_entries_have_no_counter() { let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap(); assert!(entry.boot_counter.is_none()); 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.uki.as_deref(), Some("/EFI/Linux/fedora.efi")); assert!(entry.options.is_none()); assert!(entry.is_valid()); } #[test] fn uki_title_and_version_use_every_systemd_fallback_in_strict_mode() { let sections = uki_sections(&[( ".osrel", "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=7.1\nVERSION_ID=7\n", )]); let entry = BlsEntry::from_uki_with(§ions, "/a.efi", true); assert_eq!(entry.title.as_deref(), Some("img")); assert_eq!(entry.version.as_deref(), Some("7.1")); let sections = uki_sections(&[(".osrel", "NAME=N\nID=i\n")]); let entry = BlsEntry::from_uki_with(§ions, "/a.efi", true); assert_eq!(entry.title.as_deref(), Some("N")); } #[test] fn uki_title_and_version_keep_the_sprout_fallbacks_outside_strict_mode() { // The long VERSION would be added to the title, so it is not used. let sections = uki_sections(&[( ".osrel", "IMAGE_ID=img\nNAME=N\nID=i\nVERSION=\"24.04 LTS (Noble)\"\nVERSION_ID=7\n", )]); let entry = BlsEntry::from_uki(§ions, "/a.efi"); assert_eq!(entry.title.as_deref(), Some("i")); assert_eq!(entry.version.as_deref(), Some("7")); } #[test] fn uki_version_only_falls_back_to_uname_outside_strict_mode() { let sections = uki_sections(&[(".osrel", "ID=a\n"), (".uname", "6.1.2\n")]); let loose = BlsEntry::from_uki_with(§ions, "/a.efi", false); assert_eq!(loose.version.as_deref(), Some("6.1.2")); let strict = BlsEntry::from_uki_with(§ions, "/a.efi", true); assert_eq!(strict.version, None); // The kernel version is still there, it is just not the version of the entry. assert_eq!(strict.uname.as_deref(), Some("6.1.2")); } #[test] fn entries_that_mix_boot_targets_are_found() { for (input, mixed) in [ ("linux /v\n", false), ("efi /e.efi\n", false), ("uki /u.efi\n", false), ("linux /v\nefi /e.efi\n", true), ("linux /v\nuki /u.efi\n", true), ("efi /e.efi\nuki /u.efi\n", true), ] { let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.mixes_boot_targets(), mixed, "{input}"); } } #[test] fn uki_entry_falls_back_through_the_fields() { let sections = uki_sections(&[(".osrel", "ID=arch\nBUILD_ID=rolling\n")]); 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 auto_names_are_reserved_ignoring_case() { assert!(is_reserved_entry_name("auto-windows.conf")); assert!(is_reserved_entry_name("AUTO-x.efi")); assert!(!is_reserved_entry_name("autox.conf")); assert!(!is_reserved_entry_name("fedora-auto-1.conf")); assert!(!is_reserved_entry_name("aut")); } #[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"))); } 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_eq!(pe.sections.len(), 1); assert_eq!(pe.sections[0].name, ".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!( boot_path("\\loader\\entries\\foo+2-1.conf"), "/loader/entries/foo+2-1.conf" ); assert_eq!( boot_path("loader/entries/foo.conf"), "/loader/entries/foo.conf" ); } #[test] fn boot_path_collapses_repeated_separators() { assert_eq!(boot_path("\\a/\\b//c"), "/a/b/c"); assert_eq!(boot_path(""), "/"); } #[test] fn loader_conf_reads_preferred() { let conf = LoaderConf::parse("preferred fedora-*\n"); assert_eq!(conf.preferred.as_deref(), Some("fedora-*")); assert!(conf.warnings.is_empty()); } #[test] fn loader_conf_strips_matching_quotes() { let conf = LoaderConf::parse("default \"foo*\"\npreferred 'bar'\ntimeout \"3\"\n"); assert_eq!(conf.default.as_deref(), Some("foo*")); assert_eq!(conf.preferred.as_deref(), Some("bar")); assert_eq!(conf.timeout, Some(LoaderTimeout::Seconds(3))); } #[test] fn loader_conf_keeps_unmatched_quotes() { let conf = LoaderConf::parse("default \"foo\n"); 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(""); assert_eq!(conf, LoaderConf::default()); assert!(conf.warnings.is_empty()); } #[test] fn loader_conf_reads_default_and_timeout() { let conf = LoaderConf::parse("default fedora-*\ntimeout 7\n"); assert_eq!(conf.default.as_deref(), Some("fedora-*")); assert_eq!(conf.timeout, Some(LoaderTimeout::Seconds(7))); assert!(conf.warnings.is_empty()); } #[test] fn loader_conf_skips_comments_blank_lines_and_bom() { let conf = LoaderConf::parse("\u{feff}# a comment\n\n \n default arch.conf \n"); assert_eq!(conf.default.as_deref(), Some("arch.conf")); assert!(conf.warnings.is_empty()); } #[test] fn loader_conf_last_value_wins() { let conf = LoaderConf::parse("default a\ndefault b\ntimeout 1\ntimeout 2\n"); assert_eq!(conf.default.as_deref(), Some("b")); assert_eq!(conf.timeout, Some(LoaderTimeout::Seconds(2))); } #[test] fn loader_conf_timeout_words() { for (value, expected) in [ ("0", LoaderTimeout::Seconds(0)), ("menu-hidden", LoaderTimeout::Hidden), ("menu-disabled", LoaderTimeout::Disabled), ("menu-force", LoaderTimeout::Force), ] { let conf = LoaderConf::parse(&format!("timeout {value}\n")); assert_eq!(conf.timeout, Some(expected), "{value}"); } } #[test] fn loader_conf_invalid_timeout_is_ignored_with_a_warning() { for value in ["soon", "-1", "99999999999999999999999"] { let conf = LoaderConf::parse(&format!("timeout {value}\n")); assert_eq!(conf.timeout, None, "{value}"); assert_eq!(conf.warnings.len(), 1, "{value}"); } } #[test] fn loader_conf_keys_without_values_are_ignored_with_a_warning() { let conf = LoaderConf::parse("default\ntimeout\n"); assert_eq!(conf.default, None); assert_eq!(conf.timeout, None); assert_eq!(conf.warnings.len(), 2); } #[test] fn loader_conf_warns_about_unsupported_keys() { let conf = LoaderConf::parse("editor no\nentry-token machine-id\nconsole-mode max\n"); assert_eq!(conf.warnings.len(), 3); assert!( conf.warnings .iter() .any(|warning| warning.contains("editor")) ); assert!( conf.warnings .iter() .any(|warning| warning.contains("entry-token")) ); } #[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, 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, 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, false, false)]); assert_eq!(flags, [false, true]); } #[test] 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, false), (true, true, false)]), [false, true] ); assert_eq!( resolve_default_flags(&[(false, true, false), (false, true, false)]), [true, false] ); } #[test] fn default_flags_of_no_entries_are_empty() { 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" .parse() .unwrap(); assert_eq!(entry.uki.as_deref(), Some("/EFI/Linux/a.efi")); assert_eq!(entry.architecture.as_deref(), Some("x64")); assert_eq!(entry.profile.as_deref(), Some("2")); assert!(entry.is_valid()); } #[test] fn uki_is_used_last_for_chainloading() { let entry: BlsEntry = "uki /a.efi\nefi /b.efi\n".parse().unwrap(); assert_eq!(entry.chainload_path().as_deref(), Some("b.efi")); let entry: BlsEntry = "uki /EFI/Linux/a.efi\n".parse().unwrap(); assert_eq!(entry.chainload_path().as_deref(), Some("EFI\\Linux\\a.efi")); } #[test] fn uki_entries_have_no_initrds() { let entry: BlsEntry = "uki /a.efi\ninitrd /initrd\n".parse().unwrap(); assert!(entry.initrd_paths().is_empty()); let entry: BlsEntry = "linux /vmlinuz\ninitrd /initrd\n".parse().unwrap(); assert_eq!(entry.initrd_paths(), ["initrd"]); } #[test] fn architecture_must_match_ignoring_case() { let entry: BlsEntry = "linux /v\narchitecture X64\n".parse().unwrap(); assert!(entry.matches_architecture("x64")); assert!(!entry.matches_architecture("aa64")); let entry: BlsEntry = "linux /v\n".parse().unwrap(); assert!(entry.matches_architecture("aa64")); } #[test] fn uki_profile_zero_and_invalid_profiles_add_no_prefix() { for profile in ["0", "x", "-1", "2x", ""] { let entry: BlsEntry = format!("uki /a.efi\nprofile {profile}\noptions quiet\n") .parse() .unwrap(); assert_eq!( entry.chainload_options().as_deref(), Some("quiet"), "{profile}" ); } } #[test] fn uki_profile_prefixes_the_load_options() { let entry: BlsEntry = "uki /a.efi\nprofile 2\noptions quiet\n".parse().unwrap(); assert_eq!(entry.chainload_options().as_deref(), Some("@2 quiet")); let entry: BlsEntry = "uki /a.efi\nprofile 2\n".parse().unwrap(); assert_eq!(entry.chainload_options().as_deref(), Some("@2")); let entry: BlsEntry = "linux /v\nprofile 2\noptions quiet\n".parse().unwrap(); assert_eq!(entry.chainload_options().as_deref(), Some("quiet")); let entry: BlsEntry = "linux /v\n".parse().unwrap(); assert_eq!(entry.chainload_options(), None); } #[test] fn boot_counter_parse_without_suffix() { assert_eq!(BootCounter::parse("foo"), ("foo", None)); } #[test] fn boot_counter_parse_tries_left_only() { let (id, counter) = BootCounter::parse("foo+3"); assert_eq!(id, "foo"); assert_eq!(counter, Some(BootCounter::new(3, 0))); } #[test] fn boot_counter_parse_left_and_done() { let (id, counter) = BootCounter::parse("fedora-6.5+2-1"); assert_eq!(id, "fedora-6.5"); assert_eq!(counter, Some(BootCounter::new(2, 1))); } #[test] fn boot_counter_parse_zero_tries_left() { let (_, counter) = BootCounter::parse("foo+0-3"); assert!(counter.unwrap().is_bad()); } #[test] fn boot_counter_parse_rejects_malformed_suffixes() { for stem in ["foo+", "foo+3-", "foo+a", "foo+3-x", "+3", "foo+-1"] { assert_eq!(BootCounter::parse(stem), (stem, None), "{stem}"); } } #[test] fn boot_counter_parse_rejects_overflow() { let stem = "foo+99999999999"; assert_eq!(BootCounter::parse(stem), (stem, None)); } #[test] fn boot_counter_uses_the_last_plus() { let (id, counter) = BootCounter::parse("a+b+2"); assert_eq!(id, "a+b"); assert_eq!(counter.unwrap().tries_left, 2); } #[test] fn boot_counter_decremented_moves_a_try_to_done() { let counter = BootCounter::new(3, 0); assert_eq!(counter.decremented(), BootCounter::new(2, 1)); } #[test] fn boot_counter_decremented_saturates_at_zero() { let counter = BootCounter::new(0, 3); assert_eq!(counter.decremented().tries_left, 0); } #[test] fn boot_counter_renders_plain_decimals_that_systemd_can_parse() { // Leading zeros would be read as octal by systemd-bless-boot, so they are never written. for (stem, expected) in [ ("foo+10", "foo+9-1"), ("foo+5-08", "foo+4-9"), ("foo+5-9", "foo+4-10"), ("foo+0-3", "foo+0-4"), ("foo+3", "foo+2-1"), ] { let (id, counter) = BootCounter::parse(stem); assert_eq!( counter.unwrap().decremented().render(id), expected, "{stem}" ); } } #[test] fn entries_with_more_tries_left_sort_first_when_otherwise_equal() { let more = counted_entry("a", 3); let fewer = counted_entry("a", 1); assert_eq!(sort_bls(&more, "x", &fewer, "x"), Ordering::Less); assert_eq!(sort_bls(&fewer, "x", &more, "x"), Ordering::Greater); } #[test] fn boot_counter_render_round_trips() { for stem in ["foo+3", "foo+2-1", "foo+0-3"] { let (id, counter) = BootCounter::parse(stem); assert_eq!(counter.unwrap().render(id), stem); } } #[test] fn parse_empty_input_gives_all_none() { let entry: BlsEntry = "".parse().unwrap(); assert!(entry.title.is_none()); assert!(entry.linux.is_none()); assert!(entry.efi.is_none()); assert!(entry.initrd.is_empty()); assert!(entry.options.is_none()); assert!(entry.sort_key.is_none()); assert!(entry.version.is_none()); assert!(entry.machine_id.is_none()); } #[test] fn parse_all_known_fields() { let input = "\ title Fedora Linux 6.5.6 version 6.5.6-300.fc39.x86_64 machine-id abc123def456 linux /boot/vmlinuz-6.5.6 initrd /boot/initrd-6.5.6.img options root=/dev/sda1 ro quiet sort-key fedora efi /EFI/fedora/shimx64.efi "; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.title.as_deref(), Some("Fedora Linux 6.5.6")); assert_eq!(entry.version.as_deref(), Some("6.5.6-300.fc39.x86_64")); assert_eq!(entry.machine_id.as_deref(), Some("abc123def456")); assert_eq!(entry.linux.as_deref(), Some("/boot/vmlinuz-6.5.6")); assert_eq!(entry.initrd, ["/boot/initrd-6.5.6.img"]); assert_eq!(entry.options.as_deref(), Some("root=/dev/sda1 ro quiet")); assert_eq!(entry.sort_key.as_deref(), Some("fedora")); assert_eq!(entry.efi.as_deref(), Some("/EFI/fedora/shimx64.efi")); } #[test] fn parse_skips_blank_lines() { let input = "\n\ntitle My Entry\n\n\nlinux /vmlinuz\n\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.title.as_deref(), Some("My Entry")); assert_eq!(entry.linux.as_deref(), Some("/vmlinuz")); } #[test] fn parse_skips_comment_lines() { let input = "# this is a comment\ntitle My Entry\n# another comment\nlinux /vmlinuz\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.title.as_deref(), Some("My Entry")); assert_eq!(entry.linux.as_deref(), Some("/vmlinuz")); } #[test] fn parse_trims_leading_whitespace_from_value() { let input = "title Padded Title\nlinux /boot/vmlinuz\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.title.as_deref(), Some("Padded Title")); assert_eq!(entry.linux.as_deref(), Some("/boot/vmlinuz")); } #[test] fn parse_ignores_unknown_keys() { let input = "title My Entry\nunknown-key some-value\nfuture-field value\nlinux /vmlinuz\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.title.as_deref(), Some("My Entry")); assert_eq!(entry.linux.as_deref(), Some("/vmlinuz")); } #[test] fn parse_skips_lines_without_whitespace_separator() { // A line with no whitespace cannot be split into key+value, so it is skipped let input = "title My Entry\nnovalueline\nlinux /vmlinuz\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.title.as_deref(), Some("My Entry")); assert_eq!(entry.linux.as_deref(), Some("/vmlinuz")); } #[test] fn is_valid_when_linux_present() { let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap(); assert!(entry.is_valid()); } #[test] fn is_valid_when_efi_present() { let entry: BlsEntry = "efi /EFI/boot/bootx64.efi\n".parse().unwrap(); assert!(entry.is_valid()); } #[test] fn not_valid_without_linux_or_efi() { let entry: BlsEntry = "title Just a Title\noptions quiet\n".parse().unwrap(); assert!(!entry.is_valid()); } #[test] fn chainload_path_normalises_forward_slashes_to_backslashes() { let entry: BlsEntry = "linux /boot/vmlinuz\n".parse().unwrap(); assert_eq!(entry.chainload_path().as_deref(), Some("boot\\vmlinuz")); } #[test] fn chainload_path_strips_leading_backslash() { let entry: BlsEntry = "linux \\EFI\\boot\\kernel\n".parse().unwrap(); assert_eq!(entry.chainload_path().as_deref(), Some("EFI\\boot\\kernel")); } #[test] fn chainload_path_prefers_linux_over_efi() { let input = "linux /boot/vmlinuz\nefi /EFI/boot/bootx64.efi\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.chainload_path().as_deref(), Some("boot\\vmlinuz")); } #[test] fn chainload_path_falls_back_to_efi_when_no_linux() { let entry: BlsEntry = "efi /EFI/Microsoft/Boot/bootmgfw.efi\n".parse().unwrap(); assert_eq!( entry.chainload_path().as_deref(), Some("EFI\\Microsoft\\Boot\\bootmgfw.efi") ); } #[test] fn chainload_path_none_when_neither_linux_nor_efi() { let entry: BlsEntry = "title Only Title\n".parse().unwrap(); assert!(entry.chainload_path().is_none()); } #[test] fn parse_multiple_initrd_lines_in_order() { let input = "linux /vmlinuz\ninitrd /intel-ucode.img\ninitrd /initramfs.img\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.initrd, ["/intel-ucode.img", "/initramfs.img"]); } #[test] fn parse_multiple_initrds_on_one_line() { let input = "linux /vmlinuz\ninitrd /intel-ucode.img /initramfs.img\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.initrd, ["/intel-ucode.img", "/initramfs.img"]); } #[test] fn parse_multiple_options_lines_combined_in_order() { let input = "linux /vmlinuz\noptions root=/dev/sda1 ro\noptions quiet\n"; let entry: BlsEntry = input.parse().unwrap(); assert_eq!(entry.options().as_deref(), Some("root=/dev/sda1 ro quiet")); } #[test] fn parse_ignores_byte_order_mark() { let entry: BlsEntry = "\u{feff}title Linux\nlinux /vmlinuz\n".parse().unwrap(); assert_eq!(entry.title().as_deref(), Some("Linux")); } #[test] fn initrd_paths_normalises_slashes() { let entry: BlsEntry = "linux /vmlinuz\ninitrd /boot/initrd.img\n".parse().unwrap(); assert_eq!(entry.initrd_paths(), ["boot\\initrd.img"]); } #[test] fn initrd_paths_empty_when_not_set() { let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap(); assert!(entry.initrd_paths().is_empty()); } #[test] fn sort_key_is_primary_criterion() { let a = sort_entry(Some("alpine"), None, None); let b = sort_entry(Some("fedora"), None, None); assert_eq!(sort_bls(&a, "a", &b, "b"), Ordering::Less); assert_eq!(sort_bls(&b, "b", &a, "a"), Ordering::Greater); } #[test] fn machine_id_is_secondary_criterion() { let a = sort_entry(Some("linux"), Some("aaa"), None); let b = sort_entry(Some("linux"), Some("bbb"), None); assert_eq!(sort_bls(&a, "a", &b, "b"), Ordering::Less); assert_eq!(sort_bls(&b, "b", &a, "a"), Ordering::Greater); } #[test] fn version_is_tertiary_criterion_newer_first() { let a = sort_entry(Some("linux"), Some("abc"), Some("6.5.0")); let b = sort_entry(Some("linux"), Some("abc"), Some("6.4.0")); // newer version (a) sorts before older version (b), so a < b in sort order assert_eq!(sort_bls(&a, "a", &b, "b"), Ordering::Less); assert_eq!(sort_bls(&b, "b", &a, "a"), Ordering::Greater); } #[test] fn name_is_final_tiebreaker_newer_first() { let a = sort_entry(Some("linux"), Some("abc"), Some("6.5.0")); let b = sort_entry(Some("linux"), Some("abc"), Some("6.5.0")); // name comparison via compare_versions, reversed — higher name sorts first assert_eq!(sort_bls(&a, "entry-2", &b, "entry-1"), Ordering::Less); assert_eq!(sort_bls(&a, "entry-1", &b, "entry-2"), Ordering::Greater); } #[test] fn identical_entries_are_equal() { let a = sort_entry(Some("linux"), Some("abc"), Some("6.5.0")); let b = sort_entry(Some("linux"), Some("abc"), Some("6.5.0")); assert_eq!(sort_bls(&a, "entry-1", &b, "entry-1"), Ordering::Equal); } #[test] fn equal_strings_are_equal() { assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal); } #[test] fn empty_strings_are_equal() { assert_eq!(compare_versions("", ""), Ordering::Equal); } #[test] fn simple_numeric_less() { assert_eq!(compare_versions("1", "2"), Ordering::Less); } #[test] fn simple_numeric_greater() { assert_eq!(compare_versions("2", "1"), Ordering::Greater); } #[test] fn numeric_is_not_lexicographic() { // "10" > "9", not "10" < "9" as in lexicographic order assert_eq!(compare_versions("10", "9"), Ordering::Greater); assert_eq!(compare_versions("1.10", "1.9"), Ordering::Greater); } #[test] fn leading_zeros_are_ignored() { assert_eq!(compare_versions("01", "1"), Ordering::Equal); assert_eq!(compare_versions("1.00", "1.0"), Ordering::Equal); assert_eq!(compare_versions("007", "7"), Ordering::Equal); } #[test] fn multi_component_comparison() { assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less); assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater); assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater); } #[test] fn more_components_is_greater() { assert_eq!(compare_versions("1.0.0", "1.0"), Ordering::Greater); assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Less); assert_eq!(compare_versions("1.0.0.0", "1.0.0"), Ordering::Greater); } #[test] fn alphabetic_sections_compare_lexicographically() { assert_eq!(compare_versions("1.0a", "1.0b"), Ordering::Less); assert_eq!(compare_versions("1.0b", "1.0a"), Ordering::Greater); assert_eq!(compare_versions("1.0abc", "1.0abd"), Ordering::Less); } #[test] fn uppercase_letters_sort_below_lowercase() { // Capital letters compare lower than lowercase (B < a) assert_eq!(compare_versions("B", "a"), Ordering::Less); assert_eq!(compare_versions("a", "B"), Ordering::Greater); assert_eq!(compare_versions("Z", "a"), Ordering::Less); } #[test] fn uppercase_letters_compare_among_themselves() { assert_eq!(compare_versions("A", "B"), Ordering::Less); assert_eq!(compare_versions("B", "A"), Ordering::Greater); assert_eq!(compare_versions("A", "A"), Ordering::Equal); } #[test] fn invalid_characters_are_skipped() { // Characters not in [a-zA-Z0-9.-~^] are skipped before comparison assert_eq!(compare_versions("##1.0", "1.0"), Ordering::Equal); assert_eq!(compare_versions("1.0##", "1.0"), Ordering::Equal); } #[test] fn tilde_between_two_present_tilde_strings() { // When both have ~ at the same position, they are consumed and comparison continues assert_eq!(compare_versions("1~alpha", "1~beta"), Ordering::Less); assert_eq!(compare_versions("1~rc1", "1~rc2"), Ordering::Less); assert_eq!(compare_versions("1~rc1", "1~rc1"), Ordering::Equal); } #[test] fn tilde_when_only_one_side_has_it() { // When a has ~ but b doesn't at the same position, a < b assert_eq!(compare_versions("1~rc1", "1.0"), Ordering::Less); // When b has ~ but a doesn't, a > b assert_eq!(compare_versions("1.0", "1~rc1"), Ordering::Greater); } #[test] fn tilde_is_older_than_end_of_string() { // A pre-release is older than the release it precedes. assert_eq!(compare_versions("1~rc1", "1"), Ordering::Less); assert_eq!(compare_versions("1", "1~rc1"), Ordering::Greater); assert_eq!(compare_versions("6.1~rc3", "6.1"), Ordering::Less); } #[test] fn empty_string_is_oldest() { assert_eq!(compare_versions("", "~1"), Ordering::Less); assert_eq!(compare_versions("~1", ""), Ordering::Greater); assert_eq!(compare_versions("", "1"), Ordering::Less); } #[test] fn digits_are_newer_than_letters() { assert_eq!(compare_versions("0", "a"), Ordering::Greater); assert_eq!(compare_versions("a", "0"), Ordering::Less); assert_eq!(compare_versions("1.0", "1.a"), Ordering::Greater); assert_eq!(compare_versions("1.a", "1.0"), Ordering::Less); } #[test] fn large_numbers_do_not_saturate() { assert_eq!( compare_versions("99999999999999999999", "99999999999999999998"), Ordering::Greater ); assert_eq!( compare_versions("1.99999999999999999998", "1.99999999999999999999"), Ordering::Less ); } #[test] fn entry_with_sort_key_sorts_before_entry_without() { let with_key = sort_entry(Some("fedora"), None, None); let without_key = sort_entry(None, None, None); assert_eq!(sort_bls(&with_key, "a", &without_key, "b"), Ordering::Less); assert_eq!( sort_bls(&without_key, "b", &with_key, "a"), Ordering::Greater ); } #[test] fn entries_without_sort_key_sort_by_name_only() { // Without a sort key, machine-id and version are ignored. let a = sort_entry(None, Some("aaa"), Some("1.0")); let b = sort_entry(None, Some("bbb"), Some("2.0")); assert_eq!(sort_bls(&a, "entry-2", &b, "entry-1"), Ordering::Less); assert_eq!(sort_bls(&b, "entry-1", &a, "entry-2"), Ordering::Greater); } #[test] fn entry_with_version_sorts_before_entry_without() { let a = sort_entry(Some("linux"), Some("abc"), Some("6.5.0")); let b = sort_entry(Some("linux"), Some("abc"), None); assert_eq!(sort_bls(&a, "a", &b, "b"), Ordering::Less); assert_eq!(sort_bls(&b, "b", &a, "a"), Ordering::Greater); } #[test] fn optional_both_none_equal() { assert_eq!(compare_versions_optional(None, None), Ordering::Equal); } #[test] fn optional_some_vs_none_is_greater() { // A missing version is older than any specified version. assert_eq!( compare_versions_optional(Some("1.0"), None), Ordering::Greater ); } #[test] fn optional_none_vs_some_is_less() { assert_eq!(compare_versions_optional(None, Some("1.0")), Ordering::Less); } #[test] fn optional_both_some_delegates_to_compare_versions() { assert_eq!( compare_versions_optional(Some("1.0"), Some("2.0")), Ordering::Less ); assert_eq!( compare_versions_optional(Some("2.0"), Some("1.0")), Ordering::Greater ); assert_eq!( compare_versions_optional(Some("1.0"), Some("1.0")), Ordering::Equal ); } }