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;