Skip to main content

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 composefs_boot::bootloader::EFI_EXT;
9use composefs_ctl::composefs_boot;
10use core::fmt;
11use std::collections::HashMap;
12use std::fmt::Display;
13use uapi_version::Version;
14
15use crate::bootc_composefs::status::ComposefsCmdline;
16use crate::bootloader::bootctl_systemd_version;
17use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX};
18use crate::spec::Bootloader;
19
20/// First systemd release that supports UKI without the `efi` prefix
21const SYSTEMD_UKI_MIN_VERSION: u32 = 258;
22
23#[derive(Debug, PartialEq, Eq, Clone)]
24pub enum EFIKey {
25    /// Relates to 'efi' key in BLSConfig file
26    Efi(Utf8PathBuf),
27    /// Relates to 'uki' key in BLSConfig file
28    Uki(Utf8PathBuf),
29}
30
31impl EFIKey {
32    /// Create an EFIKey with the appropriate variant based on bootloader and systemd version
33    ///
34    /// For GrubCC: always use "uki"
35    /// For systemd version >= SYSTEMD_UKI_MIN_VERSION use "uki" otherwise uses "efi"
36    pub(crate) fn for_bootloader(path: Utf8PathBuf, bootloader: &Bootloader) -> EFIKey {
37        if *bootloader == Bootloader::GrubCC {
38            // GrubCC doesn't support 'uki' key right now
39            // See: https://github.com/bootc-dev/bootc/issues/2268
40            EFIKey::Efi(path)
41        } else {
42            // Check systemd version for non-GrubCC bootloaders
43            match bootctl_systemd_version() {
44                Ok(version) if version >= SYSTEMD_UKI_MIN_VERSION => EFIKey::Uki(path),
45                _ => EFIKey::Efi(path),
46            }
47        }
48    }
49}
50
51#[derive(Debug, PartialEq, Eq, Default, Clone)]
52pub enum BLSConfigType {
53    EFI {
54        /// The path to the EFI binary, usually a UKI
55        key: EFIKey,
56    },
57    NonEFI {
58        /// The path to the linux kernel to boot.
59        linux: Utf8PathBuf,
60        /// The paths to the initrd images.
61        initrd: Vec<Utf8PathBuf>,
62        /// Kernel command line options.
63        options: Option<CmdlineOwned>,
64    },
65    #[default]
66    Unknown,
67}
68
69/// Represents a single Boot Loader Specification config file.
70///
71/// The boot loader should present the available boot menu entries to the user in a sorted list.
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#[derive(Debug, Eq, PartialEq, Default, Clone)]
75#[non_exhaustive]
76pub(crate) struct BLSConfig {
77    /// The title of the boot entry, to be displayed in the boot menu.
78    pub(crate) title: Option<String>,
79    /// The version of the boot entry.
80    /// See <https://uapi-group.org/specifications/specs/version_format_specification/>
81    ///
82    /// This is hidden and must be accessed via [`Self::version()`];
83    version: String,
84
85    pub(crate) cfg_type: BLSConfigType,
86
87    /// The machine ID of the OS.
88    pub(crate) machine_id: Option<String>,
89    /// The sort key for the boot menu.
90    pub(crate) sort_key: Option<String>,
91
92    /// Any extra fields not defined in the spec.
93    pub(crate) extra: HashMap<String, String>,
94}
95
96impl PartialOrd for BLSConfig {
97    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
98        Some(self.cmp(other))
99    }
100}
101
102impl Ord for BLSConfig {
103    /// This implements the sorting logic from the Boot Loader Specification.
104    ///
105    /// The list should be sorted by the `sort-key` field, if it exists, otherwise by the `machine-id` field.
106    /// If multiple entries have the same `sort-key` (or `machine-id`), they should be sorted by the `version` field in descending order.
107    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
108        // If both configs have a sort key, compare them.
109        if let (Some(key1), Some(key2)) = (&self.sort_key, &other.sort_key) {
110            let ord = key1.cmp(key2);
111            if ord != std::cmp::Ordering::Equal {
112                return ord;
113            }
114        }
115
116        // If both configs have a machine ID, compare them.
117        if let (Some(id1), Some(id2)) = (&self.machine_id, &other.machine_id) {
118            let ord = id1.cmp(id2);
119            if ord != std::cmp::Ordering::Equal {
120                return ord;
121            }
122        }
123
124        // Finally, sort by version in descending order.
125        self.version().cmp(&other.version()).reverse()
126    }
127}
128
129impl Display for BLSConfig {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        if let Some(title) = &self.title {
132            writeln!(f, "title {}", title)?;
133        }
134
135        writeln!(f, "version {}", self.version)?;
136
137        match &self.cfg_type {
138            BLSConfigType::EFI { key } => match key {
139                EFIKey::Efi(path) => writeln!(f, "efi {}", path)?,
140                EFIKey::Uki(path) => writeln!(f, "uki {}", path)?,
141            },
142
143            BLSConfigType::NonEFI {
144                linux,
145                initrd,
146                options,
147            } => {
148                writeln!(f, "linux {}", linux)?;
149                for initrd in initrd.iter() {
150                    writeln!(f, "initrd {}", initrd)?;
151                }
152
153                if let Some(options) = options.as_deref() {
154                    writeln!(f, "options {}", options)?;
155                }
156            }
157
158            BLSConfigType::Unknown => return Err(fmt::Error),
159        }
160
161        if let Some(machine_id) = self.machine_id.as_deref() {
162            writeln!(f, "machine-id {}", machine_id)?;
163        }
164        if let Some(sort_key) = self.sort_key.as_deref() {
165            writeln!(f, "sort-key {}", sort_key)?;
166        }
167
168        for (key, value) in &self.extra {
169            writeln!(f, "{} {}", key, value)?;
170        }
171
172        Ok(())
173    }
174}
175
176impl BLSConfig {
177    pub(crate) fn version(&self) -> Version {
178        Version::from(&self.version)
179    }
180
181    pub(crate) fn with_title(&mut self, new_val: String) -> &mut Self {
182        self.title = Some(new_val);
183        self
184    }
185    pub(crate) fn with_version(&mut self, new_val: String) -> &mut Self {
186        self.version = new_val;
187        self
188    }
189    pub(crate) fn with_cfg(&mut self, config: BLSConfigType) -> &mut Self {
190        self.cfg_type = config;
191        self
192    }
193    #[allow(dead_code)]
194    pub(crate) fn with_machine_id(&mut self, new_val: String) -> &mut Self {
195        self.machine_id = Some(new_val);
196        self
197    }
198    pub(crate) fn with_sort_key(&mut self, new_val: String) -> &mut Self {
199        self.sort_key = Some(new_val);
200        self
201    }
202    #[allow(dead_code)]
203    pub(crate) fn with_extra(&mut self, new_val: HashMap<String, String>) -> &mut Self {
204        self.extra = new_val;
205        self
206    }
207
208    /// Get the fs-verity digest from a BLS config
209    /// For EFI BLS entries, this returns the name of the UKI
210    /// For Non-EFI BLS entries, this returns the fs-verity digest in the "options" field
211    pub(crate) fn get_verity(&self) -> Result<String> {
212        match &self.cfg_type {
213            BLSConfigType::EFI { key } => {
214                let path = match key {
215                    EFIKey::Efi(path) | EFIKey::Uki(path) => path,
216                };
217                let name = path
218                    .components()
219                    .last()
220                    .ok_or(anyhow::anyhow!("Empty efi field"))?
221                    .to_string()
222                    .strip_prefix(UKI_NAME_PREFIX)
223                    .ok_or_else(|| anyhow::anyhow!("efi does not start with custom prefix"))?
224                    .strip_suffix(EFI_EXT)
225                    .ok_or_else(|| anyhow::anyhow!("efi doesn't end with .efi"))?
226                    .to_string();
227
228                Ok(name)
229            }
230
231            BLSConfigType::NonEFI { options, .. } => {
232                let options = options
233                    .as_ref()
234                    .ok_or_else(|| anyhow::anyhow!("No options"))?;
235
236                let cfs_cmdline = ComposefsCmdline::find_in_cmdline(&Cmdline::from(&options))
237                    .ok_or_else(|| anyhow::anyhow!("No composefs= param"))?;
238
239                Ok(cfs_cmdline.digest.to_string())
240            }
241
242            BLSConfigType::Unknown => anyhow::bail!("Unknown config type"),
243        }
244    }
245
246    /// Returns name of UKI in case of EFI config
247    /// Returns name of the directory containing Kernel + Initrd in case of Non-EFI config
248    ///
249    /// The names are stripped of our custom prefix and suffixes, so this returns the
250    /// verity digest part of the name
251    pub(crate) fn boot_artifact_name(&self) -> Result<&str> {
252        Ok(self.boot_artifact_info()?.0)
253    }
254
255    /// Returns name of UKI in case of EFI config
256    /// Returns name of the directory containing Kernel + Initrd in case of Non-EFI config
257    ///
258    /// The names are stripped of our custom prefix and suffixes, so this returns the
259    /// verity digest part of the name as the first value
260    ///
261    /// The second value is a boolean indicating whether it found our custom prefix or not
262    pub(crate) fn boot_artifact_info(&self) -> Result<(&str, bool)> {
263        match &self.cfg_type {
264            BLSConfigType::EFI { key } => {
265                let path = match key {
266                    EFIKey::Efi(path) | EFIKey::Uki(path) => path,
267                };
268                let file_name = path
269                    .file_name()
270                    .ok_or_else(|| anyhow::anyhow!("EFI path missing file name: {}", path))?;
271
272                let without_suffix = file_name.strip_suffix(EFI_EXT).ok_or_else(|| {
273                    anyhow::anyhow!(
274                        "EFI file name missing expected suffix '{}': {}",
275                        EFI_EXT,
276                        file_name
277                    )
278                })?;
279
280                // For backwards compatibility, we don't make this prefix mandatory
281                match without_suffix.strip_prefix(UKI_NAME_PREFIX) {
282                    Some(no_prefix) => Ok((no_prefix, true)),
283                    None => Ok((without_suffix, false)),
284                }
285            }
286
287            BLSConfigType::NonEFI { linux, .. } => {
288                let parent_dir = linux.parent().ok_or_else(|| {
289                    anyhow::anyhow!("Linux kernel path has no parent directory: {}", linux)
290                })?;
291
292                let dir_name = parent_dir.file_name().ok_or_else(|| {
293                    anyhow::anyhow!("Parent directory has no file name: {}", parent_dir)
294                })?;
295
296                // For backwards compatibility, we don't make this prefix mandatory
297                match dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) {
298                    Some(dir_name_no_prefix) => Ok((dir_name_no_prefix, true)),
299                    None => Ok((dir_name, false)),
300                }
301            }
302
303            BLSConfigType::Unknown => {
304                anyhow::bail!("Cannot extract boot artifact name from unknown config type")
305            }
306        }
307    }
308
309    /// Gets the `options` field from the config
310    /// Returns an error if the field doesn't exist
311    /// or if the config is of type `EFI`
312    pub(crate) fn get_cmdline(&self) -> Result<&Cmdline<'_>> {
313        match &self.cfg_type {
314            BLSConfigType::NonEFI { options, .. } => {
315                let options = options
316                    .as_ref()
317                    .ok_or_else(|| anyhow::anyhow!("No cmdline found for config"))?;
318
319                Ok(options)
320            }
321
322            _ => anyhow::bail!("No cmdline found for config"),
323        }
324    }
325}
326
327pub(crate) fn parse_bls_config(input: &str) -> Result<BLSConfig> {
328    let mut title = None;
329    let mut version = None;
330    let mut linux = None;
331    let mut efi_key = None;
332    let mut initrd = Vec::new();
333    let mut options = None;
334    let mut machine_id = None;
335    let mut sort_key = None;
336    let mut extra = HashMap::new();
337
338    for line in input.lines() {
339        let line = line.trim();
340        if line.is_empty() || line.starts_with('#') {
341            continue;
342        }
343
344        if let Some((key, value)) = line.split_once(' ') {
345            let value = value.trim().to_string();
346            match key {
347                "title" => title = Some(value),
348                "version" => version = Some(value),
349                "linux" => linux = Some(Utf8PathBuf::from(value)),
350                "initrd" => initrd.push(Utf8PathBuf::from(value)),
351                "options" => options = Some(CmdlineOwned::from(value)),
352                "machine-id" => machine_id = Some(value),
353                "sort-key" => sort_key = Some(value),
354                "efi" => efi_key = Some(EFIKey::Efi(Utf8PathBuf::from(value))),
355                "uki" => efi_key = Some(EFIKey::Uki(Utf8PathBuf::from(value))),
356                _ => {
357                    extra.insert(key.to_string(), value);
358                }
359            }
360        }
361    }
362
363    let version = version.ok_or_else(|| anyhow!("Missing 'version' value"))?;
364
365    let cfg_type = match (linux, efi_key) {
366        (None, Some(key)) => BLSConfigType::EFI { key },
367
368        (Some(linux), None) => BLSConfigType::NonEFI {
369            linux,
370            initrd,
371            options,
372        },
373
374        // The spec makes no mention of whether both can be present or not
375        // For now, for us, we won't have both at the same time
376        (Some(_), Some(_)) => anyhow::bail!("'linux' and 'efi'/'uki' values present"),
377        (None, None) => anyhow::bail!("Missing 'linux', 'efi', or 'uki' value"),
378    };
379
380    Ok(BLSConfig {
381        title,
382        version,
383        cfg_type,
384        machine_id,
385        sort_key,
386        extra,
387    })
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_parse_valid_bls_config() -> Result<()> {
396        let input = r#"
397            title Fedora 42.20250623.3.1 (CoreOS)
398            version 2
399            linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
400            initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
401            options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
402            custom1 value1
403            custom2 value2
404        "#;
405
406        let config = parse_bls_config(input)?;
407
408        let BLSConfigType::NonEFI {
409            linux,
410            initrd,
411            options,
412        } = config.cfg_type
413        else {
414            panic!("Expected non EFI variant");
415        };
416
417        assert_eq!(
418            config.title,
419            Some("Fedora 42.20250623.3.1 (CoreOS)".to_string())
420        );
421        assert_eq!(config.version, "2");
422        assert_eq!(
423            linux,
424            "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"
425        );
426        assert_eq!(
427            initrd,
428            vec![
429                "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"
430            ]
431        );
432        assert_eq!(
433            &*options.unwrap(),
434            "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"
435        );
436        assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string()));
437        assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string()));
438
439        Ok(())
440    }
441
442    #[test]
443    fn test_parse_multiple_initrd() -> Result<()> {
444        let input = r#"
445            title Fedora 42.20250623.3.1 (CoreOS)
446            version 2
447            linux /boot/vmlinuz
448            initrd /boot/initramfs-1.img
449            initrd /boot/initramfs-2.img
450            options root=UUID=abc123 rw
451        "#;
452
453        let config = parse_bls_config(input)?;
454
455        let BLSConfigType::NonEFI { initrd, .. } = config.cfg_type else {
456            panic!("Expected non EFI variant");
457        };
458
459        assert_eq!(
460            initrd,
461            vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"]
462        );
463
464        Ok(())
465    }
466
467    #[test]
468    fn test_parse_missing_version() {
469        let input = r#"
470            title Fedora
471            linux /vmlinuz
472            initrd /initramfs.img
473            options root=UUID=xyz ro quiet
474        "#;
475
476        let parsed = parse_bls_config(input);
477        assert!(parsed.is_err());
478    }
479
480    #[test]
481    fn test_parse_missing_linux() {
482        let input = r#"
483            title Fedora
484            version 1
485            initrd /initramfs.img
486            options root=UUID=xyz ro quiet
487        "#;
488
489        let parsed = parse_bls_config(input);
490        assert!(parsed.is_err());
491    }
492
493    #[test]
494    fn test_display_output() -> Result<()> {
495        let input = r#"
496            title Test OS
497            version 10
498            linux /boot/vmlinuz
499            initrd /boot/initrd.img
500            initrd /boot/initrd-extra.img
501            options root=UUID=abc composefs=some-uuid
502            foo bar
503        "#;
504
505        let config = parse_bls_config(input)?;
506        let output = format!("{}", config);
507        let mut output_lines = output.lines();
508
509        assert_eq!(output_lines.next().unwrap(), "title Test OS");
510        assert_eq!(output_lines.next().unwrap(), "version 10");
511        assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz");
512        assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img");
513        assert_eq!(
514            output_lines.next().unwrap(),
515            "initrd /boot/initrd-extra.img"
516        );
517        assert_eq!(
518            output_lines.next().unwrap(),
519            "options root=UUID=abc composefs=some-uuid"
520        );
521        assert_eq!(output_lines.next().unwrap(), "foo bar");
522
523        Ok(())
524    }
525
526    #[test]
527    fn test_ordering_by_version() -> Result<()> {
528        let config1 = parse_bls_config(
529            r#"
530            title Entry 1
531            version 3
532            linux /vmlinuz-3
533            initrd /initrd-3
534            options opt1
535        "#,
536        )?;
537
538        let config2 = parse_bls_config(
539            r#"
540            title Entry 2
541            version 5
542            linux /vmlinuz-5
543            initrd /initrd-5
544            options opt2
545        "#,
546        )?;
547
548        assert!(config1 > config2);
549        Ok(())
550    }
551
552    #[test]
553    fn test_ordering_by_sort_key() -> Result<()> {
554        let config1 = parse_bls_config(
555            r#"
556            title Entry 1
557            version 3
558            sort-key a
559            linux /vmlinuz-3
560            initrd /initrd-3
561            options opt1
562        "#,
563        )?;
564
565        let config2 = parse_bls_config(
566            r#"
567            title Entry 2
568            version 5
569            sort-key b
570            linux /vmlinuz-5
571            initrd /initrd-5
572            options opt2
573        "#,
574        )?;
575
576        assert!(config1 < config2);
577        Ok(())
578    }
579
580    #[test]
581    fn test_ordering_by_sort_key_and_version() -> Result<()> {
582        let config1 = parse_bls_config(
583            r#"
584            title Entry 1
585            version 3
586            sort-key a
587            linux /vmlinuz-3
588            initrd /initrd-3
589            options opt1
590        "#,
591        )?;
592
593        let config2 = parse_bls_config(
594            r#"
595            title Entry 2
596            version 5
597            sort-key a
598            linux /vmlinuz-5
599            initrd /initrd-5
600            options opt2
601        "#,
602        )?;
603
604        assert!(config1 > config2);
605        Ok(())
606    }
607
608    #[test]
609    fn test_ordering_by_machine_id() -> Result<()> {
610        let config1 = parse_bls_config(
611            r#"
612            title Entry 1
613            version 3
614            machine-id a
615            linux /vmlinuz-3
616            initrd /initrd-3
617            options opt1
618        "#,
619        )?;
620
621        let config2 = parse_bls_config(
622            r#"
623            title Entry 2
624            version 5
625            machine-id b
626            linux /vmlinuz-5
627            initrd /initrd-5
628            options opt2
629        "#,
630        )?;
631
632        assert!(config1 < config2);
633        Ok(())
634    }
635
636    #[test]
637    fn test_ordering_by_machine_id_and_version() -> Result<()> {
638        let config1 = parse_bls_config(
639            r#"
640            title Entry 1
641            version 3
642            machine-id a
643            linux /vmlinuz-3
644            initrd /initrd-3
645            options opt1
646        "#,
647        )?;
648
649        let config2 = parse_bls_config(
650            r#"
651            title Entry 2
652            version 5
653            machine-id a
654            linux /vmlinuz-5
655            initrd /initrd-5
656            options opt2
657        "#,
658        )?;
659
660        assert!(config1 > config2);
661        Ok(())
662    }
663
664    #[test]
665    fn test_ordering_by_nontrivial_version() -> Result<()> {
666        let config_final = parse_bls_config(
667            r#"
668            title Entry 1
669            version 1.0
670            linux /vmlinuz-1
671            initrd /initrd-1
672        "#,
673        )?;
674
675        let config_rc1 = parse_bls_config(
676            r#"
677            title Entry 2
678            version 1.0~rc1
679            linux /vmlinuz-2
680            initrd /initrd-2
681        "#,
682        )?;
683
684        // In a sorted list, we want 1.0 to appear before 1.0~rc1 because
685        // versions are sorted descending. This means that in Rust's sort order,
686        // config_final should be "less than" config_rc1.
687        assert!(config_final < config_rc1);
688        Ok(())
689    }
690
691    #[test]
692    fn test_boot_artifact_name_efi_success() -> Result<()> {
693        use camino::Utf8PathBuf;
694
695        let efi_path = Utf8PathBuf::from("bootc_composefs-abcd1234.efi");
696        let config = BLSConfig {
697            cfg_type: BLSConfigType::EFI {
698                key: EFIKey::Efi(efi_path),
699            },
700            version: "1".to_string(),
701            ..Default::default()
702        };
703
704        let artifact_name = config.boot_artifact_name()?;
705        assert_eq!(artifact_name, "abcd1234");
706        Ok(())
707    }
708
709    #[test]
710    fn test_boot_artifact_name_non_efi_success() -> Result<()> {
711        use camino::Utf8PathBuf;
712
713        let linux_path = Utf8PathBuf::from("/boot/bootc_composefs-xyz5678/vmlinuz");
714        let config = BLSConfig {
715            cfg_type: BLSConfigType::NonEFI {
716                linux: linux_path,
717                initrd: vec![],
718                options: None,
719            },
720            version: "1".to_string(),
721            ..Default::default()
722        };
723
724        let artifact_name = config.boot_artifact_name()?;
725        assert_eq!(artifact_name, "xyz5678");
726        Ok(())
727    }
728
729    #[test]
730    fn test_boot_artifact_name_efi_missing_prefix() {
731        use camino::Utf8PathBuf;
732
733        let efi_path = Utf8PathBuf::from("/EFI/Linux/abcd1234.efi");
734        let config = BLSConfig {
735            cfg_type: BLSConfigType::EFI {
736                key: EFIKey::Efi(efi_path),
737            },
738            version: "1".to_string(),
739            ..Default::default()
740        };
741
742        let artifact_name = config
743            .boot_artifact_name()
744            .expect("Should extract artifact name");
745        assert_eq!(artifact_name, "abcd1234");
746    }
747
748    #[test]
749    fn test_boot_artifact_name_efi_missing_suffix() {
750        use camino::Utf8PathBuf;
751
752        let efi_path = Utf8PathBuf::from("bootc_composefs-abcd1234");
753        let config = BLSConfig {
754            cfg_type: BLSConfigType::EFI {
755                key: EFIKey::Efi(efi_path),
756            },
757            version: "1".to_string(),
758            ..Default::default()
759        };
760
761        let result = config.boot_artifact_name();
762        assert!(result.is_err());
763        assert!(
764            result
765                .unwrap_err()
766                .to_string()
767                .contains("missing expected suffix")
768        );
769    }
770
771    #[test]
772    fn test_boot_artifact_name_efi_no_filename() {
773        use camino::Utf8PathBuf;
774
775        let efi_path = Utf8PathBuf::from("/");
776        let config = BLSConfig {
777            cfg_type: BLSConfigType::EFI {
778                key: EFIKey::Efi(efi_path),
779            },
780            version: "1".to_string(),
781            ..Default::default()
782        };
783
784        let result = config.boot_artifact_name();
785        assert!(result.is_err());
786        assert!(
787            result
788                .unwrap_err()
789                .to_string()
790                .contains("missing file name")
791        );
792    }
793
794    #[test]
795    fn test_boot_artifact_name_unknown_type() {
796        let config = BLSConfig {
797            cfg_type: BLSConfigType::Unknown,
798            version: "1".to_string(),
799            ..Default::default()
800        };
801
802        let result = config.boot_artifact_name();
803        assert!(result.is_err());
804        assert!(
805            result
806                .unwrap_err()
807                .to_string()
808                .contains("unknown config type")
809        );
810    }
811    #[test]
812    fn test_boot_artifact_name_efi_nested_path() -> Result<()> {
813        let efi_path = Utf8PathBuf::from("/EFI/Linux/bootc/bootc_composefs-deadbeef01234567.efi");
814        let config = BLSConfig {
815            cfg_type: BLSConfigType::EFI {
816                key: EFIKey::Efi(efi_path),
817            },
818            version: "1".to_string(),
819            ..Default::default()
820        };
821
822        assert_eq!(config.boot_artifact_name()?, "deadbeef01234567");
823        Ok(())
824    }
825
826    #[test]
827    fn test_boot_artifact_name_non_efi_deep_path() -> Result<()> {
828        // Realistic Type1 path: /boot/bootc_composefs-<digest>/vmlinuz
829        let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6";
830        let linux_path = Utf8PathBuf::from(format!("/boot/bootc_composefs-{digest}/vmlinuz"));
831        let config = BLSConfig {
832            cfg_type: BLSConfigType::NonEFI {
833                linux: linux_path,
834                initrd: vec![],
835                options: None,
836            },
837            version: "1".to_string(),
838            ..Default::default()
839        };
840
841        assert_eq!(config.boot_artifact_name()?, digest);
842        Ok(())
843    }
844
845    /// Test boot_artifact_name from parsed EFI config
846    #[test]
847    fn test_boot_artifact_name_from_parsed_efi_config() -> Result<()> {
848        let digest = "f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346";
849        // Test 'efi' key
850        let input = format!(
851            r#"
852            title Fedora UKI
853            version 1
854            efi /EFI/Linux/bootc/bootc_composefs-{digest}.efi
855            sort-key bootc-fedora-0
856        "#
857        );
858
859        let config = parse_bls_config(&input)?;
860        assert_eq!(config.boot_artifact_name()?, digest);
861        assert_eq!(config.get_verity()?, digest);
862
863        let uki_input = input.replace("efi ", "uki ");
864        let config = parse_bls_config(&uki_input)?;
865        assert_eq!(config.boot_artifact_name()?, digest);
866        assert_eq!(config.get_verity()?, digest);
867
868        Ok(())
869    }
870
871    /// Test that Non-EFI boot_artifact_name fails when linux path has no parent
872    #[test]
873    fn test_boot_artifact_name_non_efi_no_parent() {
874        let config = BLSConfig {
875            cfg_type: BLSConfigType::NonEFI {
876                linux: Utf8PathBuf::from("vmlinuz"),
877                initrd: vec![],
878                options: None,
879            },
880            version: "1".to_string(),
881            ..Default::default()
882        };
883
884        let result = config.boot_artifact_name();
885        assert!(result.is_err());
886    }
887
888    #[test]
889    fn test_efi_key_variants_and_display() -> Result<()> {
890        use camino::Utf8PathBuf;
891
892        // Test EFI variant displays "efi"
893        let efi_config = BLSConfig {
894            title: Some("Test EFI Entry".to_string()),
895            version: "1.0".to_string(),
896            cfg_type: BLSConfigType::EFI {
897                key: EFIKey::Efi(Utf8PathBuf::from("/EFI/Linux/test.efi")),
898            },
899            sort_key: Some("test-key".to_string()),
900            ..Default::default()
901        };
902
903        let result = efi_config.to_string();
904        assert!(result.contains("efi /EFI/Linux/test.efi"));
905        assert!(result.contains("title Test EFI Entry"));
906        assert!(result.contains("version 1.0"));
907        assert!(result.contains("sort-key test-key"));
908        assert!(!result.contains("uki"));
909
910        // Test UKI variant displays "uki"
911        let uki_config = BLSConfig {
912            title: Some("Test UKI Entry".to_string()),
913            version: "1.0".to_string(),
914            cfg_type: BLSConfigType::EFI {
915                key: EFIKey::Uki(Utf8PathBuf::from("/EFI/Linux/test.efi")),
916            },
917            sort_key: Some("test-key".to_string()),
918            ..Default::default()
919        };
920
921        let result = uki_config.to_string();
922        assert!(result.contains("uki /EFI/Linux/test.efi"));
923        assert!(result.contains("title Test UKI Entry"));
924        assert!(result.contains("version 1.0"));
925        assert!(result.contains("sort-key test-key"));
926        // Don't check for absence of "efi" since we're looking for the key, not anywhere in the path
927        assert!(!result.starts_with("efi ") && !result.contains("\nefi "));
928
929        // Test that Non-EFI config is unaffected
930        let non_efi_config = BLSConfig {
931            version: "1.0".to_string(),
932            cfg_type: BLSConfigType::NonEFI {
933                linux: Utf8PathBuf::from("/boot/vmlinuz"),
934                initrd: vec![Utf8PathBuf::from("/boot/initrd.img")],
935                options: Some("root=UUID=abc123".into()),
936            },
937            ..Default::default()
938        };
939
940        let result = non_efi_config.to_string();
941        assert!(result.contains("linux /boot/vmlinuz"));
942        assert!(result.contains("initrd /boot/initrd.img"));
943        assert!(result.contains("options root=UUID=abc123"));
944        assert!(!result.contains("efi"));
945        assert!(!result.contains("uki"));
946
947        Ok(())
948    }
949}