From 5c517473e38d278befe4e6fb2409153361a20be1 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sat, 3 Oct 2026 17:24:22 -0700 Subject: [PATCH] feat(bls): parse loader.conf --- crates/bls/src/lib.rs | 79 ++++++++++++++++++++++++++++++++ crates/bls/src/loader_conf.rs | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 crates/bls/src/loader_conf.rs diff --git a/crates/bls/src/lib.rs b/crates/bls/src/lib.rs index 067037e..52cea83 100644 --- a/crates/bls/src/lib.rs +++ b/crates/bls/src/lib.rs @@ -7,6 +7,10 @@ use alloc::vec::Vec; use anyhow::{Error, Result}; use core::{cmp::Ordering, iter::Peekable, str::FromStr}; +mod loader_conf; + +pub use loader_conf::{LoaderConf, LoaderTimeout}; + /// Represents a parsed BLS entry. /// Fields unrelated to Sprout are not included. #[derive(Default, Debug, Clone)] @@ -579,6 +583,81 @@ mod tests { assert!(!entry.is_bad()); } + #[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. diff --git a/crates/bls/src/loader_conf.rs b/crates/bls/src/loader_conf.rs new file mode 100644 index 0000000..baa106d --- /dev/null +++ b/crates/bls/src/loader_conf.rs @@ -0,0 +1,85 @@ +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +/// The menu timeout of a `loader.conf` file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoaderTimeout { + /// Show the menu for this many seconds. Zero hides the menu. + Seconds(u64), + /// Hide the menu unless a key is held. + Hidden, + /// Never show the menu. + Disabled, + /// Always show the menu and wait for the user. + Force, +} + +impl LoaderTimeout { + /// Parses a `timeout` value, which is a number of seconds or one of the menu words. + fn parse(value: &str) -> Option { + match value { + "menu-hidden" => Some(Self::Hidden), + "menu-disabled" => Some(Self::Disabled), + "menu-force" => Some(Self::Force), + _ if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => { + value.parse().ok().map(Self::Seconds) + } + _ => None, + } + } +} + +/// The settings Sprout understands from a `loader.conf` file. +/// Reference: +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LoaderConf { + /// A glob pattern that selects the default entry, or `@saved`. + pub default: Option, + /// How long to show the boot menu. + pub timeout: Option, + /// Problems found while parsing, such as keys that are not supported. + pub warnings: Vec, +} + +impl LoaderConf { + /// Parses the `input` as a `loader.conf` file. Parsing never fails, as a bad setting + /// should not prevent booting. Anything that is ignored is described in `warnings`. + /// When a key appears more than once, the last value is used. + pub fn parse(input: &str) -> Self { + let mut conf = Self::default(); + + // Some editors write a UTF-8 byte order mark at the start of the file. + let input = input.strip_prefix('\u{feff}').unwrap_or(input); + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (key, value) = match line.split_once(char::is_whitespace) { + Some((key, value)) => (key, value.trim()), + None => (line, ""), + }; + + match key { + "default" if !value.is_empty() => conf.default = Some(value.to_string()), + "timeout" => match LoaderTimeout::parse(value) { + Some(timeout) => conf.timeout = Some(timeout), + None => conf + .warnings + .push(format!("ignoring invalid loader.conf timeout '{}'", value)), + }, + "default" => conf + .warnings + .push("ignoring loader.conf default without a value".to_string()), + _ => conf + .warnings + .push(format!("ignoring unsupported loader.conf key '{}'", key)), + } + } + + conf + } +}