diff --git a/Cargo.lock b/Cargo.lock index 32afbbc38..74584512b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,7 @@ dependencies = [ "tokio-util", "toml 0.9.5", "tracing", + "uapi-version", "uuid", "xshell", ] @@ -2870,6 +2871,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "uapi-version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "849f6b1fe8a0fb07170737d7f3acf72cac5462fb3f4e86614474a49f7fac3b65" + [[package]] name = "unicase" version = "2.8.1" diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 1053e4cee..76f8d0a57 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -64,19 +64,20 @@ serde_ignored = "0.1.10" serde_yaml = "0.9.34" tini = "1.3.0" uuid = { version = "1.8.0", features = ["v4"] } +uapi-version = "0.4.0" [dev-dependencies] similar-asserts = { workspace = true } static_assertions = { workspace = true } [features] -default = ["install-to-disk", "grub"] +default = ["install-to-disk"] # This feature enables `bootc install to-disk`, which is considered just a "demo" # or reference installer; we expect most nontrivial use cases to be using # `bootc install to-filesystem`. install-to-disk = [] -# Enable direct support for the GRUB bootloader -grub = [] +# Enable support for the composefs native backend +composefs-backend = [] # This featuares enables `bootc internals publish-rhsm-facts` to integrate with # Red Hat Subscription Manager rhsm = [] diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index a60d0bc30..4b40a9ecd 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -36,7 +36,8 @@ mod containerenv; mod install; mod kernel_cmdline; -#[cfg(feature = "grub")] +#[cfg(feature = "composefs-backend")] +#[allow(dead_code)] pub(crate) mod parsers; #[cfg(feature = "rhsm")] mod rhsm; diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs new file mode 100644 index 000000000..2f9ff34f6 --- /dev/null +++ b/crates/lib/src/parsers/bls_config.rs @@ -0,0 +1,432 @@ +//! See +//! +//! This module parses the config files for the spec. + +use anyhow::{anyhow, Result}; +use std::collections::HashMap; +use std::fmt::Display; +use uapi_version::Version; + +/// Represents a single Boot Loader Specification config file. +/// +/// The boot loader should present the available boot menu entries to the user in a sorted list. +/// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. +/// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub(crate) struct BLSConfig { + /// The title of the boot entry, to be displayed in the boot menu. + pub(crate) title: Option, + /// The version of the boot entry. + /// See + /// + /// This is hidden and must be accessed via [`Self::version()`]; + version: String, + /// The path to the linux kernel to boot. + pub(crate) linux: String, + /// The paths to the initrd images. + pub(crate) initrd: Vec, + /// Kernel command line options. + pub(crate) options: Option, + /// The machine ID of the OS. + pub(crate) machine_id: Option, + /// The sort key for the boot menu. + pub(crate) sort_key: Option, + + /// Any extra fields not defined in the spec. + pub(crate) extra: HashMap, +} + +impl PartialOrd for BLSConfig { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for BLSConfig { + /// This implements the sorting logic from the Boot Loader Specification. + /// + /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field. + /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order. + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // If both configs have a sort key, compare them. + if let (Some(key1), Some(key2)) = (&self.sort_key, &other.sort_key) { + let ord = key1.cmp(key2); + if ord != std::cmp::Ordering::Equal { + return ord; + } + } + + // If both configs have a machine ID, compare them. + if let (Some(id1), Some(id2)) = (&self.machine_id, &other.machine_id) { + let ord = id1.cmp(id2); + if ord != std::cmp::Ordering::Equal { + return ord; + } + } + + // Finally, sort by version in descending order. + self.version().cmp(&other.version()).reverse() + } +} + +impl Display for BLSConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(title) = &self.title { + writeln!(f, "title {}", title)?; + } + + writeln!(f, "version {}", self.version)?; + writeln!(f, "linux {}", self.linux)?; + for initrd in self.initrd.iter() { + writeln!(f, "initrd {}", initrd)?; + } + if let Some(options) = self.options.as_deref() { + writeln!(f, "options {}", options)?; + } + if let Some(machine_id) = self.machine_id.as_deref() { + writeln!(f, "machine-id {}", machine_id)?; + } + if let Some(sort_key) = self.sort_key.as_deref() { + writeln!(f, "sort-key {}", sort_key)?; + } + + for (key, value) in &self.extra { + writeln!(f, "{} {}", key, value)?; + } + + Ok(()) + } +} + +impl BLSConfig { + pub(crate) fn version(&self) -> Version { + Version::from(&self.version) + } +} + +pub(crate) fn parse_bls_config(input: &str) -> Result { + let mut title = None; + let mut version = None; + let mut linux = None; + let mut initrd = Vec::new(); + let mut options = None; + let mut machine_id = None; + let mut sort_key = None; + let mut extra = HashMap::new(); + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(' ') { + let value = value.trim().to_string(); + match key { + "title" => title = Some(value), + "version" => version = Some(value), + "linux" => linux = Some(value), + "initrd" => initrd.push(value), + "options" => options = Some(value), + "machine-id" => machine_id = Some(value), + "sort-key" => sort_key = Some(value), + _ => { + extra.insert(key.to_string(), value); + } + } + } + } + + let linux = linux.ok_or_else(|| anyhow!("Missing 'linux' value"))?; + let version = version.ok_or_else(|| anyhow!("Missing 'version' value"))?; + + Ok(BLSConfig { + title, + version, + linux, + initrd, + options, + machine_id, + sort_key, + extra, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_bls_config() -> Result<()> { + let input = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version 2 + linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10 + initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img + options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6 + custom1 value1 + custom2 value2 + "#; + + let config = parse_bls_config(input)?; + + assert_eq!( + config.title, + Some("Fedora 42.20250623.3.1 (CoreOS)".to_string()) + ); + assert_eq!(config.version, "2"); + assert_eq!(config.linux, "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"); + assert_eq!(config.initrd, vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"]); + assert_eq!(config.options, Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".to_string())); + assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string())); + assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string())); + + Ok(()) + } + + #[test] + fn test_parse_multiple_initrd() -> Result<()> { + let input = r#" + title Fedora 42.20250623.3.1 (CoreOS) + version 2 + linux /boot/vmlinuz + initrd /boot/initramfs-1.img + initrd /boot/initramfs-2.img + options root=UUID=abc123 rw + "#; + + let config = parse_bls_config(input)?; + + assert_eq!( + config.initrd, + vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"] + ); + + Ok(()) + } + + #[test] + fn test_parse_missing_version() { + let input = r#" + title Fedora + linux /vmlinuz + initrd /initramfs.img + options root=UUID=xyz ro quiet + "#; + + let parsed = parse_bls_config(input); + assert!(parsed.is_err()); + } + + #[test] + fn test_parse_missing_linux() { + let input = r#" + title Fedora + version 1 + initrd /initramfs.img + options root=UUID=xyz ro quiet + "#; + + let parsed = parse_bls_config(input); + assert!(parsed.is_err()); + } + + #[test] + fn test_display_output() -> Result<()> { + let input = r#" + title Test OS + version 10 + linux /boot/vmlinuz + initrd /boot/initrd.img + initrd /boot/initrd-extra.img + options root=UUID=abc composefs=some-uuid + foo bar + "#; + + let config = parse_bls_config(input)?; + let output = format!("{}", config); + let mut output_lines = output.lines(); + + assert_eq!(output_lines.next().unwrap(), "title Test OS"); + assert_eq!(output_lines.next().unwrap(), "version 10"); + assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz"); + assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img"); + assert_eq!( + output_lines.next().unwrap(), + "initrd /boot/initrd-extra.img" + ); + assert_eq!( + output_lines.next().unwrap(), + "options root=UUID=abc composefs=some-uuid" + ); + assert_eq!(output_lines.next().unwrap(), "foo bar"); + + Ok(()) + } + + #[test] + fn test_ordering_by_version() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 > config2); + Ok(()) + } + + #[test] + fn test_ordering_by_sort_key() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + sort-key a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + sort-key b + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 < config2); + Ok(()) + } + + #[test] + fn test_ordering_by_sort_key_and_version() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + sort-key a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + sort-key a + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 > config2); + Ok(()) + } + + #[test] + fn test_ordering_by_machine_id() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + machine-id a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + machine-id b + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 < config2); + Ok(()) + } + + #[test] + fn test_ordering_by_machine_id_and_version() -> Result<()> { + let config1 = parse_bls_config( + r#" + title Entry 1 + version 3 + machine-id a + linux /vmlinuz-3 + initrd /initrd-3 + options opt1 + "#, + )?; + + let config2 = parse_bls_config( + r#" + title Entry 2 + version 5 + machine-id a + linux /vmlinuz-5 + initrd /initrd-5 + options opt2 + "#, + )?; + + assert!(config1 > config2); + Ok(()) + } + + #[test] + fn test_ordering_by_nontrivial_version() -> Result<()> { + let config_final = parse_bls_config( + r#" + title Entry 1 + version 1.0 + linux /vmlinuz-1 + initrd /initrd-1 + "#, + )?; + + let config_rc1 = parse_bls_config( + r#" + title Entry 2 + version 1.0~rc1 + linux /vmlinuz-2 + initrd /initrd-2 + "#, + )?; + + // In a sorted list, we want 1.0 to appear before 1.0~rc1 because + // versions are sorted descending. This means that in Rust's sort order, + // config_final should be "less than" config_rc1. + assert!(config_final < config_rc1); + Ok(()) + } +} diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 688a07af0..146903ef9 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -71,7 +71,6 @@ impl<'a> From> for MenuentryBody<'a> { /// A complete GRUB menuentry with title and body commands. #[derive(Debug, PartialEq, Eq)] -#[allow(dead_code)] pub(crate) struct MenuEntry<'a> { /// Display title (supports escaped quotes) pub(crate) title: &'a str, @@ -88,8 +87,7 @@ impl<'a> Display for MenuEntry<'a> { } /// Parser that takes content until balanced brackets, handling nested brackets and escapes. -#[allow(dead_code)] -pub fn take_until_balanced_allow_nested( +fn take_until_balanced_allow_nested( opening_bracket: char, closing_bracket: char, ) -> impl Fn(&str) -> IResult<&str, &str> { @@ -141,7 +139,6 @@ pub fn take_until_balanced_allow_nested( } /// Parses a single menuentry with title and body commands. -#[allow(dead_code)] fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> { let (input, _) = tag("menuentry").parse(input)?; @@ -190,14 +187,12 @@ fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> { } /// Skips content until finding "menuentry" keyword or end of input. -#[allow(dead_code)] fn skip_to_menuentry(input: &str) -> IResult<&str, ()> { let (input, _) = take_until("menuentry")(input)?; Ok((input, ())) } /// Parses all menuentries from a GRUB configuration file. -#[allow(dead_code)] fn parse_all(input: &str) -> IResult<&str, Vec>> { let mut remaining = input; let mut entries = Vec::new(); @@ -229,7 +224,6 @@ fn parse_all(input: &str) -> IResult<&str, Vec>> { } /// Main entry point for parsing GRUB menuentry files. -#[allow(dead_code)] pub(crate) fn parse_grub_menuentry_file(contents: &str) -> anyhow::Result>> { let (_, entries) = parse_all(&contents) .map_err(|e| anyhow::anyhow!("Failed to parse GRUB menuentries: {e}"))?; diff --git a/crates/lib/src/parsers/mod.rs b/crates/lib/src/parsers/mod.rs index ca3d0453a..e3640c8ef 100644 --- a/crates/lib/src/parsers/mod.rs +++ b/crates/lib/src/parsers/mod.rs @@ -1 +1,2 @@ +pub(crate) mod bls_config; pub(crate) mod grub_menuconfig;