bootc_lib/parsers/
bls_config.rs

1//! See <https://uapi-group.org/specifications/specs/boot_loader_specification/>
2//!
3//! This module parses the config files for the spec.
4
5#![allow(dead_code)]
6
7use anyhow::{Result, anyhow};
8use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
9use camino::Utf8PathBuf;
10use composefs_boot::bootloader::EFI_EXT;
11use core::fmt;
12use std::collections::HashMap;
13use std::fmt::Display;
14use uapi_version::Version;
15
16use crate::composefs_consts::COMPOSEFS_CMDLINE;
17
18#[derive(Debug, PartialEq, Eq, Default)]
19pub enum BLSConfigType {
20    EFI {
21        /// The path to the EFI binary, usually a UKI
22        efi: Utf8PathBuf,
23    },
24    NonEFI {
25        /// The path to the linux kernel to boot.
26        linux: Utf8PathBuf,
27        /// The paths to the initrd images.
28        initrd: Vec<Utf8PathBuf>,
29        /// Kernel command line options.
30        options: Option<CmdlineOwned>,
31    },
32    #[default]
33    Unknown,
34}
35
36/// Represents a single Boot Loader Specification config file.
37///
38/// The boot loader should present the available boot menu entries to the user in a sorted list.
39/// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field.
40/// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order.
41#[derive(Debug, Eq, PartialEq, Default)]
42#[non_exhaustive]
43pub(crate) struct BLSConfig {
44    /// The title of the boot entry, to be displayed in the boot menu.
45    pub(crate) title: Option<String>,
46    /// The version of the boot entry.
47    /// See <https://uapi-group.org/specifications/specs/version_format_specification/>
48    ///
49    /// This is hidden and must be accessed via [`Self::version()`];
50    version: String,
51
52    pub(crate) cfg_type: BLSConfigType,
53
54    /// The machine ID of the OS.
55    pub(crate) machine_id: Option<String>,
56    /// The sort key for the boot menu.
57    pub(crate) sort_key: Option<String>,
58
59    /// Any extra fields not defined in the spec.
60    pub(crate) extra: HashMap<String, String>,
61}
62
63impl PartialOrd for BLSConfig {
64    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
65        Some(self.cmp(other))
66    }
67}
68
69impl Ord for BLSConfig {
70    /// This implements the sorting logic from the Boot Loader Specification.
71    ///
72    /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field.
73    /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order.
74    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
75        // If both configs have a sort key, compare them.
76        if let (Some(key1), Some(key2)) = (&self.sort_key, &other.sort_key) {
77            let ord = key1.cmp(key2);
78            if ord != std::cmp::Ordering::Equal {
79                return ord;
80            }
81        }
82
83        // If both configs have a machine ID, compare them.
84        if let (Some(id1), Some(id2)) = (&self.machine_id, &other.machine_id) {
85            let ord = id1.cmp(id2);
86            if ord != std::cmp::Ordering::Equal {
87                return ord;
88            }
89        }
90
91        // Finally, sort by version in descending order.
92        self.version().cmp(&other.version()).reverse()
93    }
94}
95
96impl Display for BLSConfig {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        if let Some(title) = &self.title {
99            writeln!(f, "title {}", title)?;
100        }
101
102        writeln!(f, "version {}", self.version)?;
103
104        match &self.cfg_type {
105            BLSConfigType::EFI { efi } => {
106                writeln!(f, "efi {}", efi)?;
107            }
108
109            BLSConfigType::NonEFI {
110                linux,
111                initrd,
112                options,
113            } => {
114                writeln!(f, "linux {}", linux)?;
115                for initrd in initrd.iter() {
116                    writeln!(f, "initrd {}", initrd)?;
117                }
118
119                if let Some(options) = options.as_deref() {
120                    writeln!(f, "options {}", options)?;
121                }
122            }
123
124            BLSConfigType::Unknown => return Err(fmt::Error),
125        }
126
127        if let Some(machine_id) = self.machine_id.as_deref() {
128            writeln!(f, "machine-id {}", machine_id)?;
129        }
130        if let Some(sort_key) = self.sort_key.as_deref() {
131            writeln!(f, "sort-key {}", sort_key)?;
132        }
133
134        for (key, value) in &self.extra {
135            writeln!(f, "{} {}", key, value)?;
136        }
137
138        Ok(())
139    }
140}
141
142impl BLSConfig {
143    pub(crate) fn version(&self) -> Version {
144        Version::from(&self.version)
145    }
146
147    pub(crate) fn with_title(&mut self, new_val: String) -> &mut Self {
148        self.title = Some(new_val);
149        self
150    }
151    pub(crate) fn with_version(&mut self, new_val: String) -> &mut Self {
152        self.version = new_val;
153        self
154    }
155    pub(crate) fn with_cfg(&mut self, config: BLSConfigType) -> &mut Self {
156        self.cfg_type = config;
157        self
158    }
159    #[allow(dead_code)]
160    pub(crate) fn with_machine_id(&mut self, new_val: String) -> &mut Self {
161        self.machine_id = Some(new_val);
162        self
163    }
164    pub(crate) fn with_sort_key(&mut self, new_val: String) -> &mut Self {
165        self.sort_key = Some(new_val);
166        self
167    }
168    #[allow(dead_code)]
169    pub(crate) fn with_extra(&mut self, new_val: HashMap<String, String>) -> &mut Self {
170        self.extra = new_val;
171        self
172    }
173
174    pub(crate) fn get_verity(&self) -> Result<String> {
175        match &self.cfg_type {
176            BLSConfigType::EFI { efi } => Ok(efi
177                .components()
178                .last()
179                .ok_or(anyhow::anyhow!("Empty efi field"))?
180                .to_string()
181                .strip_suffix(EFI_EXT)
182                .ok_or(anyhow::anyhow!("efi doesn't end with .efi"))?
183                .to_string()),
184
185            BLSConfigType::NonEFI { options, .. } => {
186                let options = options.as_ref().ok_or(anyhow::anyhow!("No options"))?;
187
188                let cmdline = Cmdline::from(&options);
189
190                let kv = cmdline
191                    .find(COMPOSEFS_CMDLINE)
192                    .ok_or(anyhow::anyhow!("No composefs= param"))?;
193
194                let value = kv
195                    .value()
196                    .ok_or(anyhow::anyhow!("Empty composefs= param"))?;
197
198                let value = value.to_owned();
199
200                Ok(value)
201            }
202
203            BLSConfigType::Unknown => anyhow::bail!("Unknown config type"),
204        }
205    }
206
207    /// Gets the `options` field from the config
208    /// Returns an error if the field doesn't exist
209    /// or if the config is of type `EFI`
210    pub(crate) fn get_cmdline(&self) -> Result<&Cmdline<'_>> {
211        match &self.cfg_type {
212            BLSConfigType::NonEFI { options, .. } => {
213                let options = options
214                    .as_ref()
215                    .ok_or_else(|| anyhow::anyhow!("No cmdline found for config"))?;
216
217                Ok(options)
218            }
219
220            _ => anyhow::bail!("No cmdline found for config"),
221        }
222    }
223}
224
225pub(crate) fn parse_bls_config(input: &str) -> Result<BLSConfig> {
226    let mut title = None;
227    let mut version = None;
228    let mut linux = None;
229    let mut efi = None;
230    let mut initrd = Vec::new();
231    let mut options = None;
232    let mut machine_id = None;
233    let mut sort_key = None;
234    let mut extra = HashMap::new();
235
236    for line in input.lines() {
237        let line = line.trim();
238        if line.is_empty() || line.starts_with('#') {
239            continue;
240        }
241
242        if let Some((key, value)) = line.split_once(' ') {
243            let value = value.trim().to_string();
244            match key {
245                "title" => title = Some(value),
246                "version" => version = Some(value),
247                "linux" => linux = Some(Utf8PathBuf::from(value)),
248                "initrd" => initrd.push(Utf8PathBuf::from(value)),
249                "options" => options = Some(CmdlineOwned::from(value)),
250                "machine-id" => machine_id = Some(value),
251                "sort-key" => sort_key = Some(value),
252                "efi" => efi = Some(Utf8PathBuf::from(value)),
253                _ => {
254                    extra.insert(key.to_string(), value);
255                }
256            }
257        }
258    }
259
260    let version = version.ok_or_else(|| anyhow!("Missing 'version' value"))?;
261
262    let cfg_type = match (linux, efi) {
263        (None, Some(efi)) => BLSConfigType::EFI { efi },
264
265        (Some(linux), None) => BLSConfigType::NonEFI {
266            linux,
267            initrd,
268            options,
269        },
270
271        // The spec makes no mention of whether both can be present or not
272        // Fow now, for us, we won't have both at the same time
273        (Some(_), Some(_)) => anyhow::bail!("'linux' and 'efi' values present"),
274        (None, None) => anyhow::bail!("Missing 'linux' or 'efi' value"),
275    };
276
277    Ok(BLSConfig {
278        title,
279        version,
280        cfg_type,
281        machine_id,
282        sort_key,
283        extra,
284    })
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_parse_valid_bls_config() -> Result<()> {
293        let input = r#"
294            title Fedora 42.20250623.3.1 (CoreOS)
295            version 2
296            linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
297            initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
298            options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
299            custom1 value1
300            custom2 value2
301        "#;
302
303        let config = parse_bls_config(input)?;
304
305        let BLSConfigType::NonEFI {
306            linux,
307            initrd,
308            options,
309        } = config.cfg_type
310        else {
311            panic!("Expected non EFI variant");
312        };
313
314        assert_eq!(
315            config.title,
316            Some("Fedora 42.20250623.3.1 (CoreOS)".to_string())
317        );
318        assert_eq!(config.version, "2");
319        assert_eq!(
320            linux,
321            "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"
322        );
323        assert_eq!(
324            initrd,
325            vec![
326                "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"
327            ]
328        );
329        assert_eq!(
330            &*options.unwrap(),
331            "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"
332        );
333        assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string()));
334        assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string()));
335
336        Ok(())
337    }
338
339    #[test]
340    fn test_parse_multiple_initrd() -> Result<()> {
341        let input = r#"
342            title Fedora 42.20250623.3.1 (CoreOS)
343            version 2
344            linux /boot/vmlinuz
345            initrd /boot/initramfs-1.img
346            initrd /boot/initramfs-2.img
347            options root=UUID=abc123 rw
348        "#;
349
350        let config = parse_bls_config(input)?;
351
352        let BLSConfigType::NonEFI { initrd, .. } = config.cfg_type else {
353            panic!("Expected non EFI variant");
354        };
355
356        assert_eq!(
357            initrd,
358            vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"]
359        );
360
361        Ok(())
362    }
363
364    #[test]
365    fn test_parse_missing_version() {
366        let input = r#"
367            title Fedora
368            linux /vmlinuz
369            initrd /initramfs.img
370            options root=UUID=xyz ro quiet
371        "#;
372
373        let parsed = parse_bls_config(input);
374        assert!(parsed.is_err());
375    }
376
377    #[test]
378    fn test_parse_missing_linux() {
379        let input = r#"
380            title Fedora
381            version 1
382            initrd /initramfs.img
383            options root=UUID=xyz ro quiet
384        "#;
385
386        let parsed = parse_bls_config(input);
387        assert!(parsed.is_err());
388    }
389
390    #[test]
391    fn test_display_output() -> Result<()> {
392        let input = r#"
393            title Test OS
394            version 10
395            linux /boot/vmlinuz
396            initrd /boot/initrd.img
397            initrd /boot/initrd-extra.img
398            options root=UUID=abc composefs=some-uuid
399            foo bar
400        "#;
401
402        let config = parse_bls_config(input)?;
403        let output = format!("{}", config);
404        let mut output_lines = output.lines();
405
406        assert_eq!(output_lines.next().unwrap(), "title Test OS");
407        assert_eq!(output_lines.next().unwrap(), "version 10");
408        assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz");
409        assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img");
410        assert_eq!(
411            output_lines.next().unwrap(),
412            "initrd /boot/initrd-extra.img"
413        );
414        assert_eq!(
415            output_lines.next().unwrap(),
416            "options root=UUID=abc composefs=some-uuid"
417        );
418        assert_eq!(output_lines.next().unwrap(), "foo bar");
419
420        Ok(())
421    }
422
423    #[test]
424    fn test_ordering_by_version() -> Result<()> {
425        let config1 = parse_bls_config(
426            r#"
427            title Entry 1
428            version 3
429            linux /vmlinuz-3
430            initrd /initrd-3
431            options opt1
432        "#,
433        )?;
434
435        let config2 = parse_bls_config(
436            r#"
437            title Entry 2
438            version 5
439            linux /vmlinuz-5
440            initrd /initrd-5
441            options opt2
442        "#,
443        )?;
444
445        assert!(config1 > config2);
446        Ok(())
447    }
448
449    #[test]
450    fn test_ordering_by_sort_key() -> Result<()> {
451        let config1 = parse_bls_config(
452            r#"
453            title Entry 1
454            version 3
455            sort-key a
456            linux /vmlinuz-3
457            initrd /initrd-3
458            options opt1
459        "#,
460        )?;
461
462        let config2 = parse_bls_config(
463            r#"
464            title Entry 2
465            version 5
466            sort-key b
467            linux /vmlinuz-5
468            initrd /initrd-5
469            options opt2
470        "#,
471        )?;
472
473        assert!(config1 < config2);
474        Ok(())
475    }
476
477    #[test]
478    fn test_ordering_by_sort_key_and_version() -> Result<()> {
479        let config1 = parse_bls_config(
480            r#"
481            title Entry 1
482            version 3
483            sort-key a
484            linux /vmlinuz-3
485            initrd /initrd-3
486            options opt1
487        "#,
488        )?;
489
490        let config2 = parse_bls_config(
491            r#"
492            title Entry 2
493            version 5
494            sort-key a
495            linux /vmlinuz-5
496            initrd /initrd-5
497            options opt2
498        "#,
499        )?;
500
501        assert!(config1 > config2);
502        Ok(())
503    }
504
505    #[test]
506    fn test_ordering_by_machine_id() -> Result<()> {
507        let config1 = parse_bls_config(
508            r#"
509            title Entry 1
510            version 3
511            machine-id a
512            linux /vmlinuz-3
513            initrd /initrd-3
514            options opt1
515        "#,
516        )?;
517
518        let config2 = parse_bls_config(
519            r#"
520            title Entry 2
521            version 5
522            machine-id b
523            linux /vmlinuz-5
524            initrd /initrd-5
525            options opt2
526        "#,
527        )?;
528
529        assert!(config1 < config2);
530        Ok(())
531    }
532
533    #[test]
534    fn test_ordering_by_machine_id_and_version() -> Result<()> {
535        let config1 = parse_bls_config(
536            r#"
537            title Entry 1
538            version 3
539            machine-id a
540            linux /vmlinuz-3
541            initrd /initrd-3
542            options opt1
543        "#,
544        )?;
545
546        let config2 = parse_bls_config(
547            r#"
548            title Entry 2
549            version 5
550            machine-id a
551            linux /vmlinuz-5
552            initrd /initrd-5
553            options opt2
554        "#,
555        )?;
556
557        assert!(config1 > config2);
558        Ok(())
559    }
560
561    #[test]
562    fn test_ordering_by_nontrivial_version() -> Result<()> {
563        let config_final = parse_bls_config(
564            r#"
565            title Entry 1
566            version 1.0
567            linux /vmlinuz-1
568            initrd /initrd-1
569        "#,
570        )?;
571
572        let config_rc1 = parse_bls_config(
573            r#"
574            title Entry 2
575            version 1.0~rc1
576            linux /vmlinuz-2
577            initrd /initrd-2
578        "#,
579        )?;
580
581        // In a sorted list, we want 1.0 to appear before 1.0~rc1 because
582        // versions are sorted descending. This means that in Rust's sort order,
583        // config_final should be "less than" config_rc1.
584        assert!(config_final < config_rc1);
585        Ok(())
586    }
587}