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
5use anyhow::{Result, anyhow};
6use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
7use camino::Utf8PathBuf;
8use cfsctl::composefs_boot;
9use composefs_boot::bootloader::EFI_EXT;
10use core::fmt;
11use std::collections::HashMap;
12use std::fmt::Display;
13use uapi_version::Version;
14
15use crate::bootc_composefs::status::ComposefsCmdline;
16use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX};
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    /// Get the fs-verity digest from a BLS config
175    /// For EFI BLS entries, this returns the name of the UKI
176    /// For Non-EFI BLS entries, this returns the fs-verity digest in the "options" field
177    pub(crate) fn get_verity(&self) -> Result<String> {
178        match &self.cfg_type {
179            BLSConfigType::EFI { efi } => {
180                let name = efi
181                    .components()
182                    .last()
183                    .ok_or(anyhow::anyhow!("Empty efi field"))?
184                    .to_string()
185                    .strip_prefix(UKI_NAME_PREFIX)
186                    .ok_or_else(|| anyhow::anyhow!("efi does not start with custom prefix"))?
187                    .strip_suffix(EFI_EXT)
188                    .ok_or_else(|| anyhow::anyhow!("efi doesn't end with .efi"))?
189                    .to_string();
190
191                Ok(name)
192            }
193
194            BLSConfigType::NonEFI { options, .. } => {
195                let options = options
196                    .as_ref()
197                    .ok_or_else(|| anyhow::anyhow!("No options"))?;
198
199                let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(&options))
200                    .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?;
201
202                Ok(cfs_cmdline.digest.to_string())
203            }
204
205            BLSConfigType::Unknown => anyhow::bail!("Unknown config type"),
206        }
207    }
208
209    /// Returns name of UKI in case of EFI config
210    /// Returns name of the directory containing Kernel + Initrd in case of Non-EFI config
211    ///
212    /// The names are stripped of our custom prefix and suffixes, so this returns the
213    /// verity digest part of the name
214    pub(crate) fn boot_artifact_name(&self) -> Result<&str> {
215        match &self.cfg_type {
216            BLSConfigType::EFI { efi } => {
217                let file_name = efi
218                    .file_name()
219                    .ok_or_else(|| anyhow::anyhow!("EFI path missing file name: {}", efi))?;
220
221                let without_suffix = file_name.strip_suffix(EFI_EXT).ok_or_else(|| {
222                    anyhow::anyhow!(
223                        "EFI file name missing expected suffix '{}': {}",
224                        EFI_EXT,
225                        file_name
226                    )
227                })?;
228
229                // For backwards compatibility, we don't make this prefix mandatory
230                match without_suffix.strip_prefix(UKI_NAME_PREFIX) {
231                    Some(no_prefix) => Ok(no_prefix),
232                    None => Ok(without_suffix),
233                }
234            }
235
236            BLSConfigType::NonEFI { linux, .. } => {
237                let parent_dir = linux.parent().ok_or_else(|| {
238                    anyhow::anyhow!("Linux kernel path has no parent directory: {}", linux)
239                })?;
240
241                let dir_name = parent_dir.file_name().ok_or_else(|| {
242                    anyhow::anyhow!("Parent directory has no file name: {}", parent_dir)
243                })?;
244
245                // For backwards compatibility, we don't make this prefix mandatory
246                match dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) {
247                    Some(dir_name_no_prefix) => Ok(dir_name_no_prefix),
248                    None => Ok(dir_name),
249                }
250            }
251
252            BLSConfigType::Unknown => {
253                anyhow::bail!("Cannot extract boot artifact name from unknown config type")
254            }
255        }
256    }
257
258    /// Gets the `options` field from the config
259    /// Returns an error if the field doesn't exist
260    /// or if the config is of type `EFI`
261    pub(crate) fn get_cmdline(&self) -> Result<&Cmdline<'_>> {
262        match &self.cfg_type {
263            BLSConfigType::NonEFI { options, .. } => {
264                let options = options
265                    .as_ref()
266                    .ok_or_else(|| anyhow::anyhow!("No cmdline found for config"))?;
267
268                Ok(options)
269            }
270
271            _ => anyhow::bail!("No cmdline found for config"),
272        }
273    }
274}
275
276pub(crate) fn parse_bls_config(input: &str) -> Result<BLSConfig> {
277    let mut title = None;
278    let mut version = None;
279    let mut linux = None;
280    let mut efi = None;
281    let mut initrd = Vec::new();
282    let mut options = None;
283    let mut machine_id = None;
284    let mut sort_key = None;
285    let mut extra = HashMap::new();
286
287    for line in input.lines() {
288        let line = line.trim();
289        if line.is_empty() || line.starts_with('#') {
290            continue;
291        }
292
293        if let Some((key, value)) = line.split_once(' ') {
294            let value = value.trim().to_string();
295            match key {
296                "title" => title = Some(value),
297                "version" => version = Some(value),
298                "linux" => linux = Some(Utf8PathBuf::from(value)),
299                "initrd" => initrd.push(Utf8PathBuf::from(value)),
300                "options" => options = Some(CmdlineOwned::from(value)),
301                "machine-id" => machine_id = Some(value),
302                "sort-key" => sort_key = Some(value),
303                "efi" => efi = Some(Utf8PathBuf::from(value)),
304                _ => {
305                    extra.insert(key.to_string(), value);
306                }
307            }
308        }
309    }
310
311    let version = version.ok_or_else(|| anyhow!("Missing 'version' value"))?;
312
313    let cfg_type = match (linux, efi) {
314        (None, Some(efi)) => BLSConfigType::EFI { efi },
315
316        (Some(linux), None) => BLSConfigType::NonEFI {
317            linux,
318            initrd,
319            options,
320        },
321
322        // The spec makes no mention of whether both can be present or not
323        // Fow now, for us, we won't have both at the same time
324        (Some(_), Some(_)) => anyhow::bail!("'linux' and 'efi' values present"),
325        (None, None) => anyhow::bail!("Missing 'linux' or 'efi' value"),
326    };
327
328    Ok(BLSConfig {
329        title,
330        version,
331        cfg_type,
332        machine_id,
333        sort_key,
334        extra,
335    })
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_parse_valid_bls_config() -> Result<()> {
344        let input = r#"
345            title Fedora 42.20250623.3.1 (CoreOS)
346            version 2
347            linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
348            initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
349            options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
350            custom1 value1
351            custom2 value2
352        "#;
353
354        let config = parse_bls_config(input)?;
355
356        let BLSConfigType::NonEFI {
357            linux,
358            initrd,
359            options,
360        } = config.cfg_type
361        else {
362            panic!("Expected non EFI variant");
363        };
364
365        assert_eq!(
366            config.title,
367            Some("Fedora 42.20250623.3.1 (CoreOS)".to_string())
368        );
369        assert_eq!(config.version, "2");
370        assert_eq!(
371            linux,
372            "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"
373        );
374        assert_eq!(
375            initrd,
376            vec![
377                "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"
378            ]
379        );
380        assert_eq!(
381            &*options.unwrap(),
382            "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"
383        );
384        assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string()));
385        assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string()));
386
387        Ok(())
388    }
389
390    #[test]
391    fn test_parse_multiple_initrd() -> Result<()> {
392        let input = r#"
393            title Fedora 42.20250623.3.1 (CoreOS)
394            version 2
395            linux /boot/vmlinuz
396            initrd /boot/initramfs-1.img
397            initrd /boot/initramfs-2.img
398            options root=UUID=abc123 rw
399        "#;
400
401        let config = parse_bls_config(input)?;
402
403        let BLSConfigType::NonEFI { initrd, .. } = config.cfg_type else {
404            panic!("Expected non EFI variant");
405        };
406
407        assert_eq!(
408            initrd,
409            vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"]
410        );
411
412        Ok(())
413    }
414
415    #[test]
416    fn test_parse_missing_version() {
417        let input = r#"
418            title Fedora
419            linux /vmlinuz
420            initrd /initramfs.img
421            options root=UUID=xyz ro quiet
422        "#;
423
424        let parsed = parse_bls_config(input);
425        assert!(parsed.is_err());
426    }
427
428    #[test]
429    fn test_parse_missing_linux() {
430        let input = r#"
431            title Fedora
432            version 1
433            initrd /initramfs.img
434            options root=UUID=xyz ro quiet
435        "#;
436
437        let parsed = parse_bls_config(input);
438        assert!(parsed.is_err());
439    }
440
441    #[test]
442    fn test_display_output() -> Result<()> {
443        let input = r#"
444            title Test OS
445            version 10
446            linux /boot/vmlinuz
447            initrd /boot/initrd.img
448            initrd /boot/initrd-extra.img
449            options root=UUID=abc composefs=some-uuid
450            foo bar
451        "#;
452
453        let config = parse_bls_config(input)?;
454        let output = format!("{}", config);
455        let mut output_lines = output.lines();
456
457        assert_eq!(output_lines.next().unwrap(), "title Test OS");
458        assert_eq!(output_lines.next().unwrap(), "version 10");
459        assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz");
460        assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img");
461        assert_eq!(
462            output_lines.next().unwrap(),
463            "initrd /boot/initrd-extra.img"
464        );
465        assert_eq!(
466            output_lines.next().unwrap(),
467            "options root=UUID=abc composefs=some-uuid"
468        );
469        assert_eq!(output_lines.next().unwrap(), "foo bar");
470
471        Ok(())
472    }
473
474    #[test]
475    fn test_ordering_by_version() -> Result<()> {
476        let config1 = parse_bls_config(
477            r#"
478            title Entry 1
479            version 3
480            linux /vmlinuz-3
481            initrd /initrd-3
482            options opt1
483        "#,
484        )?;
485
486        let config2 = parse_bls_config(
487            r#"
488            title Entry 2
489            version 5
490            linux /vmlinuz-5
491            initrd /initrd-5
492            options opt2
493        "#,
494        )?;
495
496        assert!(config1 > config2);
497        Ok(())
498    }
499
500    #[test]
501    fn test_ordering_by_sort_key() -> Result<()> {
502        let config1 = parse_bls_config(
503            r#"
504            title Entry 1
505            version 3
506            sort-key a
507            linux /vmlinuz-3
508            initrd /initrd-3
509            options opt1
510        "#,
511        )?;
512
513        let config2 = parse_bls_config(
514            r#"
515            title Entry 2
516            version 5
517            sort-key b
518            linux /vmlinuz-5
519            initrd /initrd-5
520            options opt2
521        "#,
522        )?;
523
524        assert!(config1 < config2);
525        Ok(())
526    }
527
528    #[test]
529    fn test_ordering_by_sort_key_and_version() -> Result<()> {
530        let config1 = parse_bls_config(
531            r#"
532            title Entry 1
533            version 3
534            sort-key a
535            linux /vmlinuz-3
536            initrd /initrd-3
537            options opt1
538        "#,
539        )?;
540
541        let config2 = parse_bls_config(
542            r#"
543            title Entry 2
544            version 5
545            sort-key a
546            linux /vmlinuz-5
547            initrd /initrd-5
548            options opt2
549        "#,
550        )?;
551
552        assert!(config1 > config2);
553        Ok(())
554    }
555
556    #[test]
557    fn test_ordering_by_machine_id() -> Result<()> {
558        let config1 = parse_bls_config(
559            r#"
560            title Entry 1
561            version 3
562            machine-id a
563            linux /vmlinuz-3
564            initrd /initrd-3
565            options opt1
566        "#,
567        )?;
568
569        let config2 = parse_bls_config(
570            r#"
571            title Entry 2
572            version 5
573            machine-id b
574            linux /vmlinuz-5
575            initrd /initrd-5
576            options opt2
577        "#,
578        )?;
579
580        assert!(config1 < config2);
581        Ok(())
582    }
583
584    #[test]
585    fn test_ordering_by_machine_id_and_version() -> Result<()> {
586        let config1 = parse_bls_config(
587            r#"
588            title Entry 1
589            version 3
590            machine-id a
591            linux /vmlinuz-3
592            initrd /initrd-3
593            options opt1
594        "#,
595        )?;
596
597        let config2 = parse_bls_config(
598            r#"
599            title Entry 2
600            version 5
601            machine-id a
602            linux /vmlinuz-5
603            initrd /initrd-5
604            options opt2
605        "#,
606        )?;
607
608        assert!(config1 > config2);
609        Ok(())
610    }
611
612    #[test]
613    fn test_ordering_by_nontrivial_version() -> Result<()> {
614        let config_final = parse_bls_config(
615            r#"
616            title Entry 1
617            version 1.0
618            linux /vmlinuz-1
619            initrd /initrd-1
620        "#,
621        )?;
622
623        let config_rc1 = parse_bls_config(
624            r#"
625            title Entry 2
626            version 1.0~rc1
627            linux /vmlinuz-2
628            initrd /initrd-2
629        "#,
630        )?;
631
632        // In a sorted list, we want 1.0 to appear before 1.0~rc1 because
633        // versions are sorted descending. This means that in Rust's sort order,
634        // config_final should be "less than" config_rc1.
635        assert!(config_final < config_rc1);
636        Ok(())
637    }
638
639    #[test]
640    fn test_boot_artifact_name_efi_success() -> Result<()> {
641        use camino::Utf8PathBuf;
642
643        let efi_path = Utf8PathBuf::from("bootc_composefs-abcd1234.efi");
644        let config = BLSConfig {
645            cfg_type: BLSConfigType::EFI { efi: efi_path },
646            version: "1".to_string(),
647            ..Default::default()
648        };
649
650        let artifact_name = config.boot_artifact_name()?;
651        assert_eq!(artifact_name, "abcd1234");
652        Ok(())
653    }
654
655    #[test]
656    fn test_boot_artifact_name_non_efi_success() -> Result<()> {
657        use camino::Utf8PathBuf;
658
659        let linux_path = Utf8PathBuf::from("/boot/bootc_composefs-xyz5678/vmlinuz");
660        let config = BLSConfig {
661            cfg_type: BLSConfigType::NonEFI {
662                linux: linux_path,
663                initrd: vec![],
664                options: None,
665            },
666            version: "1".to_string(),
667            ..Default::default()
668        };
669
670        let artifact_name = config.boot_artifact_name()?;
671        assert_eq!(artifact_name, "xyz5678");
672        Ok(())
673    }
674
675    #[test]
676    fn test_boot_artifact_name_efi_missing_prefix() {
677        use camino::Utf8PathBuf;
678
679        let efi_path = Utf8PathBuf::from("/EFI/Linux/abcd1234.efi");
680        let config = BLSConfig {
681            cfg_type: BLSConfigType::EFI { efi: efi_path },
682            version: "1".to_string(),
683            ..Default::default()
684        };
685
686        let artifact_name = config
687            .boot_artifact_name()
688            .expect("Should extract artifact name");
689        assert_eq!(artifact_name, "abcd1234");
690    }
691
692    #[test]
693    fn test_boot_artifact_name_efi_missing_suffix() {
694        use camino::Utf8PathBuf;
695
696        let efi_path = Utf8PathBuf::from("bootc_composefs-abcd1234");
697        let config = BLSConfig {
698            cfg_type: BLSConfigType::EFI { efi: efi_path },
699            version: "1".to_string(),
700            ..Default::default()
701        };
702
703        let result = config.boot_artifact_name();
704        assert!(result.is_err());
705        assert!(
706            result
707                .unwrap_err()
708                .to_string()
709                .contains("missing expected suffix")
710        );
711    }
712
713    #[test]
714    fn test_boot_artifact_name_efi_no_filename() {
715        use camino::Utf8PathBuf;
716
717        let efi_path = Utf8PathBuf::from("/");
718        let config = BLSConfig {
719            cfg_type: BLSConfigType::EFI { efi: efi_path },
720            version: "1".to_string(),
721            ..Default::default()
722        };
723
724        let result = config.boot_artifact_name();
725        assert!(result.is_err());
726        assert!(
727            result
728                .unwrap_err()
729                .to_string()
730                .contains("missing file name")
731        );
732    }
733}