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::{anyhow, Result};
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!(linux, "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10");
320        assert_eq!(initrd, vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"]);
321        assert_eq!(&*options.unwrap(), "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6");
322        assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string()));
323        assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string()));
324
325        Ok(())
326    }
327
328    #[test]
329    fn test_parse_multiple_initrd() -> Result<()> {
330        let input = r#"
331            title Fedora 42.20250623.3.1 (CoreOS)
332            version 2
333            linux /boot/vmlinuz
334            initrd /boot/initramfs-1.img
335            initrd /boot/initramfs-2.img
336            options root=UUID=abc123 rw
337        "#;
338
339        let config = parse_bls_config(input)?;
340
341        let BLSConfigType::NonEFI { initrd, .. } = config.cfg_type else {
342            panic!("Expected non EFI variant");
343        };
344
345        assert_eq!(
346            initrd,
347            vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"]
348        );
349
350        Ok(())
351    }
352
353    #[test]
354    fn test_parse_missing_version() {
355        let input = r#"
356            title Fedora
357            linux /vmlinuz
358            initrd /initramfs.img
359            options root=UUID=xyz ro quiet
360        "#;
361
362        let parsed = parse_bls_config(input);
363        assert!(parsed.is_err());
364    }
365
366    #[test]
367    fn test_parse_missing_linux() {
368        let input = r#"
369            title Fedora
370            version 1
371            initrd /initramfs.img
372            options root=UUID=xyz ro quiet
373        "#;
374
375        let parsed = parse_bls_config(input);
376        assert!(parsed.is_err());
377    }
378
379    #[test]
380    fn test_display_output() -> Result<()> {
381        let input = r#"
382            title Test OS
383            version 10
384            linux /boot/vmlinuz
385            initrd /boot/initrd.img
386            initrd /boot/initrd-extra.img
387            options root=UUID=abc composefs=some-uuid
388            foo bar
389        "#;
390
391        let config = parse_bls_config(input)?;
392        let output = format!("{}", config);
393        let mut output_lines = output.lines();
394
395        assert_eq!(output_lines.next().unwrap(), "title Test OS");
396        assert_eq!(output_lines.next().unwrap(), "version 10");
397        assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz");
398        assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img");
399        assert_eq!(
400            output_lines.next().unwrap(),
401            "initrd /boot/initrd-extra.img"
402        );
403        assert_eq!(
404            output_lines.next().unwrap(),
405            "options root=UUID=abc composefs=some-uuid"
406        );
407        assert_eq!(output_lines.next().unwrap(), "foo bar");
408
409        Ok(())
410    }
411
412    #[test]
413    fn test_ordering_by_version() -> Result<()> {
414        let config1 = parse_bls_config(
415            r#"
416            title Entry 1
417            version 3
418            linux /vmlinuz-3
419            initrd /initrd-3
420            options opt1
421        "#,
422        )?;
423
424        let config2 = parse_bls_config(
425            r#"
426            title Entry 2
427            version 5
428            linux /vmlinuz-5
429            initrd /initrd-5
430            options opt2
431        "#,
432        )?;
433
434        assert!(config1 > config2);
435        Ok(())
436    }
437
438    #[test]
439    fn test_ordering_by_sort_key() -> Result<()> {
440        let config1 = parse_bls_config(
441            r#"
442            title Entry 1
443            version 3
444            sort-key a
445            linux /vmlinuz-3
446            initrd /initrd-3
447            options opt1
448        "#,
449        )?;
450
451        let config2 = parse_bls_config(
452            r#"
453            title Entry 2
454            version 5
455            sort-key b
456            linux /vmlinuz-5
457            initrd /initrd-5
458            options opt2
459        "#,
460        )?;
461
462        assert!(config1 < config2);
463        Ok(())
464    }
465
466    #[test]
467    fn test_ordering_by_sort_key_and_version() -> Result<()> {
468        let config1 = parse_bls_config(
469            r#"
470            title Entry 1
471            version 3
472            sort-key a
473            linux /vmlinuz-3
474            initrd /initrd-3
475            options opt1
476        "#,
477        )?;
478
479        let config2 = parse_bls_config(
480            r#"
481            title Entry 2
482            version 5
483            sort-key a
484            linux /vmlinuz-5
485            initrd /initrd-5
486            options opt2
487        "#,
488        )?;
489
490        assert!(config1 > config2);
491        Ok(())
492    }
493
494    #[test]
495    fn test_ordering_by_machine_id() -> Result<()> {
496        let config1 = parse_bls_config(
497            r#"
498            title Entry 1
499            version 3
500            machine-id a
501            linux /vmlinuz-3
502            initrd /initrd-3
503            options opt1
504        "#,
505        )?;
506
507        let config2 = parse_bls_config(
508            r#"
509            title Entry 2
510            version 5
511            machine-id b
512            linux /vmlinuz-5
513            initrd /initrd-5
514            options opt2
515        "#,
516        )?;
517
518        assert!(config1 < config2);
519        Ok(())
520    }
521
522    #[test]
523    fn test_ordering_by_machine_id_and_version() -> Result<()> {
524        let config1 = parse_bls_config(
525            r#"
526            title Entry 1
527            version 3
528            machine-id a
529            linux /vmlinuz-3
530            initrd /initrd-3
531            options opt1
532        "#,
533        )?;
534
535        let config2 = parse_bls_config(
536            r#"
537            title Entry 2
538            version 5
539            machine-id a
540            linux /vmlinuz-5
541            initrd /initrd-5
542            options opt2
543        "#,
544        )?;
545
546        assert!(config1 > config2);
547        Ok(())
548    }
549
550    #[test]
551    fn test_ordering_by_nontrivial_version() -> Result<()> {
552        let config_final = parse_bls_config(
553            r#"
554            title Entry 1
555            version 1.0
556            linux /vmlinuz-1
557            initrd /initrd-1
558        "#,
559        )?;
560
561        let config_rc1 = parse_bls_config(
562            r#"
563            title Entry 2
564            version 1.0~rc1
565            linux /vmlinuz-2
566            initrd /initrd-2
567        "#,
568        )?;
569
570        // In a sorted list, we want 1.0 to appear before 1.0~rc1 because
571        // versions are sorted descending. This means that in Rust's sort order,
572        // config_final should be "less than" config_rc1.
573        assert!(config_final < config_rc1);
574        Ok(())
575    }
576}