2026-03-25 15:25:14 -04:00
|
|
|
#![no_std]
|
|
|
|
|
extern crate alloc;
|
|
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
use alloc::format;
|
2026-03-25 15:25:14 -04:00
|
|
|
use alloc::string::{String, ToString};
|
2026-09-26 19:45:47 -07:00
|
|
|
use alloc::vec::Vec;
|
2026-03-25 15:25:14 -04:00
|
|
|
use anyhow::{Error, Result};
|
|
|
|
|
use core::{cmp::Ordering, iter::Peekable, str::FromStr};
|
|
|
|
|
|
2026-10-03 17:24:22 -07:00
|
|
|
mod loader_conf;
|
2026-10-03 19:09:17 -07:00
|
|
|
mod pe;
|
|
|
|
|
mod uki;
|
2026-10-03 17:24:22 -07:00
|
|
|
|
|
|
|
|
pub use loader_conf::{LoaderConf, LoaderTimeout};
|
2026-10-03 19:09:17 -07:00
|
|
|
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};
|
2026-10-03 17:24:22 -07:00
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
/// 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<String>,
|
|
|
|
|
/// The options to pass to the entry.
|
|
|
|
|
pub options: Option<String>,
|
|
|
|
|
/// The path to the linux kernel.
|
|
|
|
|
pub linux: Option<String>,
|
2026-09-26 19:45:47 -07:00
|
|
|
/// The paths to the initrds, in the order they appear.
|
|
|
|
|
pub initrd: Vec<String>,
|
2026-03-25 15:25:14 -04:00
|
|
|
/// The path to an EFI image.
|
|
|
|
|
pub efi: Option<String>,
|
2026-10-03 19:21:10 -07:00
|
|
|
/// The path to a unified kernel image.
|
|
|
|
|
pub uki: Option<String>,
|
|
|
|
|
/// The profile of the unified kernel image to boot.
|
|
|
|
|
pub profile: Option<String>,
|
|
|
|
|
/// The architecture the entry is for, such as `x64` or `aa64`.
|
|
|
|
|
pub architecture: Option<String>,
|
2026-03-25 15:25:14 -04:00
|
|
|
/// The sort key for the entry.
|
|
|
|
|
pub sort_key: Option<String>,
|
|
|
|
|
/// The version of the entry.
|
|
|
|
|
pub version: Option<String>,
|
|
|
|
|
/// The machine id of the entry.
|
|
|
|
|
pub machine_id: Option<String>,
|
2026-10-03 19:09:17 -07:00
|
|
|
/// The command line embedded in a unified kernel image.
|
|
|
|
|
pub cmdline: Option<String>,
|
|
|
|
|
/// The kernel version embedded in a unified kernel image.
|
|
|
|
|
pub uname: Option<String>,
|
2026-10-03 17:19:27 -07:00
|
|
|
/// 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<BootCounter>,
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<Self> {
|
|
|
|
|
// All the fields in a BLS entry we understand.
|
|
|
|
|
// Set all to None initially.
|
|
|
|
|
let mut title: Option<String> = None;
|
|
|
|
|
let mut options: Option<String> = None;
|
|
|
|
|
let mut linux: Option<String> = None;
|
2026-09-26 19:45:47 -07:00
|
|
|
let mut initrd: Vec<String> = Vec::new();
|
2026-03-25 15:25:14 -04:00
|
|
|
let mut efi: Option<String> = None;
|
2026-10-03 19:21:10 -07:00
|
|
|
let mut uki: Option<String> = None;
|
|
|
|
|
let mut profile: Option<String> = None;
|
|
|
|
|
let mut architecture: Option<String> = None;
|
2026-03-25 15:25:14 -04:00
|
|
|
let mut sort_key: Option<String> = None;
|
|
|
|
|
let mut version: Option<String> = None;
|
|
|
|
|
let mut machine_id: Option<String> = None;
|
|
|
|
|
|
2026-09-26 20:16:19 -07:00
|
|
|
// 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);
|
|
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 20:16:09 -07:00
|
|
|
// 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.
|
2026-03-25 15:25:14 -04:00
|
|
|
"options" => {
|
2026-09-26 20:16:09 -07:00
|
|
|
let value = value.trim();
|
|
|
|
|
match options.as_mut() {
|
|
|
|
|
Some(options) => {
|
|
|
|
|
options.push(' ');
|
|
|
|
|
options.push_str(value);
|
|
|
|
|
}
|
|
|
|
|
None => options = Some(value.to_string()),
|
|
|
|
|
}
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The path to the linux kernel.
|
|
|
|
|
"linux" => {
|
|
|
|
|
linux = Some(value.trim().to_string());
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 19:45:47 -07:00
|
|
|
// The path to an initrd. An entry can have multiple initrds, either as
|
|
|
|
|
// repeated initrd lines or as multiple paths separated by whitespace.
|
2026-03-25 15:25:14 -04:00
|
|
|
"initrd" => {
|
2026-09-26 19:45:47 -07:00
|
|
|
initrd.extend(value.split_whitespace().map(|path| path.to_string()));
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The path to an EFI image.
|
|
|
|
|
"efi" => {
|
|
|
|
|
efi = Some(value.trim().to_string());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:21:10 -07:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
"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,
|
2026-10-03 19:21:10 -07:00
|
|
|
uki,
|
|
|
|
|
profile,
|
|
|
|
|
architecture,
|
2026-03-25 15:25:14 -04:00
|
|
|
sort_key,
|
|
|
|
|
version,
|
|
|
|
|
machine_id,
|
2026-10-03 19:09:17 -07:00
|
|
|
cmdline: None,
|
|
|
|
|
uname: None,
|
2026-10-03 17:19:27 -07:00
|
|
|
boot_counter: None,
|
2026-03-25 15:25:14 -04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BlsEntry {
|
|
|
|
|
/// Checks if this BLS entry is something we can actually boot in Sprout.
|
|
|
|
|
pub fn is_valid(&self) -> bool {
|
2026-10-03 19:21:10 -07:00
|
|
|
self.linux.is_some() || self.efi.is_some() || self.uki.is_some()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 `@<profile>` first, which selects the profile.
|
|
|
|
|
pub fn chainload_options(&self) -> Option<String> {
|
2026-10-03 19:37:43 -07:00
|
|
|
// 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()) {
|
2026-10-03 19:21:10 -07:00
|
|
|
(true, Some(profile), Some(options)) => Some(format!("@{} {}", profile, options)),
|
|
|
|
|
(true, Some(profile), None) => Some(format!("@{}", profile)),
|
|
|
|
|
(_, _, options) => options,
|
|
|
|
|
}
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetches the path to an EFI bootable image to boot, if any.
|
2026-10-03 19:21:10 -07:00
|
|
|
/// This prioritizes the linux field over efi, and efi over uki.
|
2026-03-25 15:25:14 -04:00
|
|
|
/// It also converts / to \\ to match EFI path style.
|
|
|
|
|
pub fn chainload_path(&self) -> Option<String> {
|
|
|
|
|
self.linux
|
|
|
|
|
.clone()
|
|
|
|
|
.or(self.efi.clone())
|
2026-10-03 19:21:10 -07:00
|
|
|
.or(self.uki.clone())
|
2026-03-25 15:25:14 -04:00
|
|
|
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 19:45:47 -07:00
|
|
|
/// Fetches the paths to the initrds to pass to the kernel, in order.
|
2026-03-25 15:25:14 -04:00
|
|
|
/// It also converts / to \\ to match EFI path style.
|
2026-09-26 19:45:47 -07:00
|
|
|
pub fn initrd_paths(&self) -> Vec<String> {
|
2026-10-03 19:21:10 -07:00
|
|
|
// A unified kernel image has its initrd built in.
|
|
|
|
|
if self.is_uki_only() {
|
|
|
|
|
return Vec::new();
|
|
|
|
|
}
|
2026-03-25 15:25:14 -04:00
|
|
|
self.initrd
|
2026-09-26 19:45:47 -07:00
|
|
|
.iter()
|
2026-03-25 15:25:14 -04:00
|
|
|
.map(|path| path.replace('/', "\\").trim_start_matches('\\').to_string())
|
2026-09-26 19:45:47 -07:00
|
|
|
.collect()
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetches the options to pass to the kernel, if any.
|
|
|
|
|
pub fn options(&self) -> Option<String> {
|
|
|
|
|
self.options.clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetches the title of the entry, if any.
|
|
|
|
|
pub fn title(&self) -> Option<String> {
|
|
|
|
|
self.title.clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetches the sort key of the entry, if any.
|
|
|
|
|
pub fn sort_key(&self) -> Option<String> {
|
|
|
|
|
self.sort_key.clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetches the version of the entry, if any.
|
|
|
|
|
pub fn version(&self) -> Option<String> {
|
|
|
|
|
self.version.clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetches the machine id of the entry, if any.
|
|
|
|
|
pub fn machine_id(&self) -> Option<String> {
|
|
|
|
|
self.machine_id.clone()
|
|
|
|
|
}
|
2026-10-03 17:19:27 -07:00
|
|
|
|
|
|
|
|
/// 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 `+<left>[-<done>]`.
|
|
|
|
|
/// Reference: <https://uapi-group.org/specifications/specs/boot_loader_specification/#boot-counting>
|
|
|
|
|
#[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 {
|
2026-10-03 19:37:43 -07:00
|
|
|
/// Creates a counter with the given number of tries left and tries done.
|
2026-10-03 19:20:45 -07:00
|
|
|
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.
|
2026-10-03 17:19:27 -07:00
|
|
|
pub fn parse(stem: &str) -> (&str, Option<Self>) {
|
|
|
|
|
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"));
|
2026-10-03 19:37:43 -07:00
|
|
|
Some((id, Self::new(parse_digits(left)?, parse_digits(done)?)))
|
2026-10-03 17:19:27 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:37:43 -07:00
|
|
|
/// Produces the counter after one more boot attempt.
|
2026-10-03 17:19:27 -07:00
|
|
|
pub fn decremented(&self) -> Self {
|
2026-10-03 19:37:43 -07:00
|
|
|
Self::new(
|
|
|
|
|
self.tries_left.saturating_sub(1),
|
|
|
|
|
self.tries_done.saturating_add(1),
|
|
|
|
|
)
|
2026-10-03 17:19:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Renders the entry file name stem for the entry `id` with this counter.
|
2026-10-03 19:37:43 -07:00
|
|
|
/// The counters are plain decimals, without leading zeros, as systemd-boot writes them and
|
|
|
|
|
/// as systemd-bless-boot can read them.
|
2026-10-03 17:19:27 -07:00
|
|
|
pub fn render(&self, id: &str) -> String {
|
|
|
|
|
if self.tries_done == 0 {
|
2026-10-03 19:37:43 -07:00
|
|
|
format!("{}+{}", id, self.tries_left)
|
2026-10-03 17:19:27 -07:00
|
|
|
} else {
|
2026-10-03 19:37:43 -07:00
|
|
|
format!("{}+{}-{}", id, self.tries_left, self.tries_done)
|
2026-10-03 17:19:27 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether the entry has run out of tries.
|
|
|
|
|
pub fn is_bad(&self) -> bool {
|
|
|
|
|
self.tries_left == 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:20:45 -07:00
|
|
|
/// Decides which entries are the default. Each item of `entries` is `(is_default, is_bad)`,
|
|
|
|
|
/// and the result says whether each entry is the default afterwards.
|
|
|
|
|
/// Bad entries, which have no boot counter tries left, are never the default while a good entry
|
|
|
|
|
/// exists. If no entry is a default, the first good entry is picked, or the first entry if every
|
|
|
|
|
/// entry is bad, so that there is always something to boot.
|
|
|
|
|
pub fn resolve_default_flags(entries: &[(bool, bool)]) -> Vec<bool> {
|
|
|
|
|
let any_good = entries.iter().any(|(_, bad)| !bad);
|
|
|
|
|
let mut flags: Vec<bool> = entries
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(default, bad)| *default && !(any_good && *bad))
|
|
|
|
|
.collect();
|
|
|
|
|
if !flags.iter().any(|default| *default) {
|
|
|
|
|
let pick = entries
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|(_, bad)| !bad)
|
|
|
|
|
.or_else(|| (!entries.is_empty()).then_some(0));
|
|
|
|
|
if let Some(index) = pick {
|
|
|
|
|
flags[index] = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
flags
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:09:17 -07:00
|
|
|
/// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:37:43 -07:00
|
|
|
/// 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-"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:36:49 -07:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
/// Parses `value` as a number made only of ASCII digits.
|
|
|
|
|
fn parse_digits(value: &str) -> Option<u32> {
|
|
|
|
|
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
value.parse().ok()
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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: <https://uapi-group.org/specifications/specs/boot_loader_specification/#sorting>
|
|
|
|
|
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();
|
|
|
|
|
|
2026-09-26 18:14:57 -07:00
|
|
|
// 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()
|
|
|
|
|
}),
|
|
|
|
|
(Some(_), None) => Ordering::Less,
|
|
|
|
|
(None, Some(_)) => Ordering::Greater,
|
|
|
|
|
(None, None) => Ordering::Equal,
|
|
|
|
|
};
|
2026-03-25 15:25:14 -04:00
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
// 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);
|
|
|
|
|
|
2026-09-26 18:14:57 -07:00
|
|
|
// If all else is equal, compare the file names of both entries, sorting newer entries first.
|
2026-10-03 19:20:45 -07:00
|
|
|
// 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(|| {
|
|
|
|
|
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))
|
|
|
|
|
})
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-09-26 18:15:04 -07:00
|
|
|
/// A missing value is considered older than any specified value.
|
2026-03-25 15:25:14 -04:00
|
|
|
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),
|
2026-09-26 18:15:04 -07:00
|
|
|
// 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,
|
2026-03-25 15:25:14 -04:00
|
|
|
// If both values are None, return that they are equal.
|
|
|
|
|
(None, None) => Ordering::Equal,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compares two strings using the BLS version comparison specification.
|
|
|
|
|
/// See: <https://uapi-group.org/specifications/specs/version_format_specification/>
|
|
|
|
|
pub fn compare_versions(a: &str, b: &str) -> Ordering {
|
2026-09-26 18:14:04 -07:00
|
|
|
// An empty string is older than any other string.
|
|
|
|
|
if a.is_empty() || b.is_empty() {
|
|
|
|
|
return a.len().cmp(&b.len());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
// 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);
|
|
|
|
|
|
2026-09-26 18:14:04 -07:00
|
|
|
// 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) => {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
// 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.
|
2026-09-26 18:14:17 -07:00
|
|
|
// If only one string has digits at this position, that string is newer.
|
2026-03-25 15:25:14 -04:00
|
|
|
if ca.is_ascii_digit() || cb.is_ascii_digit() {
|
2026-09-26 18:14:17 -07:00
|
|
|
if !ca.is_ascii_digit() {
|
|
|
|
|
return Ordering::Less;
|
|
|
|
|
}
|
|
|
|
|
if !cb.is_ascii_digit() {
|
|
|
|
|
return Ordering::Greater;
|
|
|
|
|
}
|
2026-03-25 15:25:14 -04:00
|
|
|
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<I: Iterator<Item = char>>(iter: &mut Peekable<I>) {
|
|
|
|
|
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.
|
2026-09-26 18:14:38 -07:00
|
|
|
/// Numbers are compared by their digit count first and then digit by digit,
|
|
|
|
|
/// which supports numbers of any length.
|
2026-03-25 15:25:14 -04:00
|
|
|
fn compare_numeric<I: Iterator<Item = char>>(
|
|
|
|
|
iter_a: &mut Peekable<I>,
|
|
|
|
|
iter_b: &mut Peekable<I>,
|
|
|
|
|
) -> Ordering {
|
|
|
|
|
let num_a = extract_number(iter_a);
|
|
|
|
|
let num_b = extract_number(iter_b);
|
|
|
|
|
|
2026-09-26 18:14:38 -07:00
|
|
|
num_a
|
|
|
|
|
.len()
|
|
|
|
|
.cmp(&num_b.len())
|
|
|
|
|
.then_with(|| num_a.cmp(&num_b))
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-26 18:14:38 -07:00
|
|
|
/// Extracts the digits of a number from the iterator, skipping leading zeros.
|
|
|
|
|
fn extract_number<I: Iterator<Item = char>>(iter: &mut Peekable<I>) -> String {
|
2026-03-25 15:25:14 -04:00
|
|
|
// Skip leading zeros
|
|
|
|
|
while let Some(&'0') = iter.peek() {
|
|
|
|
|
iter.next();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 18:14:38 -07:00
|
|
|
let mut num = String::new();
|
2026-03-25 15:25:14 -04:00
|
|
|
while let Some(&c) = iter.peek() {
|
|
|
|
|
if c.is_ascii_digit() {
|
|
|
|
|
iter.next();
|
2026-09-26 18:14:38 -07:00
|
|
|
num.push(c);
|
2026-03-25 15:25:14 -04:00
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
num
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compares alphabetical prefixes.
|
|
|
|
|
/// Capital letters compare lower than lowercase letters (B < a).
|
|
|
|
|
fn compare_alphabetic<I: Iterator<Item = char>>(
|
|
|
|
|
iter_a: &mut Peekable<I>,
|
|
|
|
|
iter_b: &mut Peekable<I>,
|
|
|
|
|
) -> 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::*;
|
2026-10-03 19:09:17 -07:00
|
|
|
use alloc::collections::BTreeMap;
|
|
|
|
|
use alloc::vec;
|
2026-03-25 15:25:14 -04:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
fn counted_entry(sort_key: &str, tries_left: u32) -> BlsEntry {
|
|
|
|
|
BlsEntry {
|
2026-10-03 19:20:45 -07:00
|
|
|
boot_counter: Some(BootCounter::new(tries_left, 0)),
|
2026-10-03 17:19:27 -07:00
|
|
|
..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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:09:17 -07:00
|
|
|
/// Builds a minimal PE image with the given sections, each padded to 16 bytes of raw data.
|
|
|
|
|
fn pe_image(sections: &[(&str, &[u8])]) -> Vec<u8> {
|
|
|
|
|
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<String, Vec<u8>> {
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:37:43 -07:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:09:17 -07:00
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:36:49 -07:00
|
|
|
#[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(""), "/");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:19:36 -07:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:24:22 -07:00
|
|
|
#[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"))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
#[test]
|
|
|
|
|
fn default_flags_keep_a_good_default() {
|
|
|
|
|
// The second entry is bad and also matched the default pattern.
|
|
|
|
|
let flags = resolve_default_flags(&[(true, false), (true, true), (false, false)]);
|
|
|
|
|
assert_eq!(flags, [true, false, false]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn default_flags_replace_a_bad_default_with_the_first_good_entry() {
|
|
|
|
|
let flags = resolve_default_flags(&[(false, false), (true, true), (false, false)]);
|
|
|
|
|
assert_eq!(flags, [true, false, false]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn default_flags_fallback_skips_bad_entries() {
|
|
|
|
|
let flags = resolve_default_flags(&[(false, true), (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), (true, true)]),
|
|
|
|
|
[false, true]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_default_flags(&[(false, true), (false, true)]),
|
|
|
|
|
[true, false]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn default_flags_of_no_entries_are_empty() {
|
|
|
|
|
assert!(resolve_default_flags(&[]).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:21:10 -07:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:37:43 -07:00
|
|
|
#[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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:21:10 -07:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
#[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");
|
2026-10-03 19:20:45 -07:00
|
|
|
assert_eq!(counter, Some(BootCounter::new(3, 0)));
|
2026-10-03 17:19:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn boot_counter_parse_left_and_done() {
|
|
|
|
|
let (id, counter) = BootCounter::parse("fedora-6.5+2-1");
|
|
|
|
|
assert_eq!(id, "fedora-6.5");
|
2026-10-03 19:20:45 -07:00
|
|
|
assert_eq!(counter, Some(BootCounter::new(2, 1)));
|
2026-10-03 17:19:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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() {
|
2026-10-03 19:20:45 -07:00
|
|
|
let counter = BootCounter::new(3, 0);
|
|
|
|
|
assert_eq!(counter.decremented(), BootCounter::new(2, 1));
|
2026-10-03 17:19:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn boot_counter_decremented_saturates_at_zero() {
|
2026-10-03 19:20:45 -07:00
|
|
|
let counter = BootCounter::new(0, 3);
|
2026-10-03 17:19:27 -07:00
|
|
|
assert_eq!(counter.decremented().tries_left, 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 19:20:45 -07:00
|
|
|
#[test]
|
2026-10-03 19:37:43 -07:00
|
|
|
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.
|
2026-10-03 19:20:45 -07:00
|
|
|
for (stem, expected) in [
|
2026-10-03 19:37:43 -07:00
|
|
|
("foo+10", "foo+9-1"),
|
|
|
|
|
("foo+5-08", "foo+4-9"),
|
|
|
|
|
("foo+5-9", "foo+4-10"),
|
2026-10-03 19:20:45 -07:00
|
|
|
("foo+0-3", "foo+0-4"),
|
2026-10-03 19:37:43 -07:00
|
|
|
("foo+3", "foo+2-1"),
|
2026-10-03 19:20:45 -07:00
|
|
|
] {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 17:19:27 -07:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
#[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());
|
2026-09-26 19:45:47 -07:00
|
|
|
assert!(entry.initrd.is_empty());
|
2026-03-25 15:25:14 -04:00
|
|
|
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"));
|
2026-09-26 19:45:47 -07:00
|
|
|
assert_eq!(entry.initrd, ["/boot/initrd-6.5.6.img"]);
|
2026-03-25 15:25:14 -04:00
|
|
|
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]
|
2026-09-26 19:45:47 -07:00
|
|
|
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"]);
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-09-26 19:45:47 -07:00
|
|
|
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"]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 20:16:09 -07:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 20:16:19 -07:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 19:45:47 -07:00
|
|
|
#[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() {
|
2026-03-25 15:25:14 -04:00
|
|
|
let entry: BlsEntry = "linux /vmlinuz\n".parse().unwrap();
|
2026-09-26 19:45:47 -07:00
|
|
|
assert!(entry.initrd_paths().is_empty());
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
2026-09-26 18:14:04 -07:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
2026-09-26 18:14:17 -07:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
2026-09-26 18:14:38 -07:00
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-26 18:14:57 -07:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
2026-03-25 15:25:14 -04:00
|
|
|
|
2026-09-26 18:15:04 -07:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 15:25:14 -04:00
|
|
|
#[test]
|
|
|
|
|
fn optional_both_none_equal() {
|
|
|
|
|
assert_eq!(compare_versions_optional(None, None), Ordering::Equal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-09-26 18:15:04 -07:00
|
|
|
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
|
|
|
|
|
);
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-09-26 18:15:04 -07:00
|
|
|
fn optional_none_vs_some_is_less() {
|
|
|
|
|
assert_eq!(compare_versions_optional(None, Some("1.0")), Ordering::Less);
|
2026-03-25 15:25:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|