1use 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::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX};
17
18#[derive(Debug, PartialEq, Eq, Default, Clone)]
19pub enum BLSConfigType {
20 EFI {
21 efi: Utf8PathBuf,
23 },
24 NonEFI {
25 linux: Utf8PathBuf,
27 initrd: Vec<Utf8PathBuf>,
29 options: Option<CmdlineOwned>,
31 },
32 #[default]
33 Unknown,
34}
35
36#[derive(Debug, Eq, PartialEq, Default, Clone)]
42#[non_exhaustive]
43pub(crate) struct BLSConfig {
44 pub(crate) title: Option<String>,
46 version: String,
51
52 pub(crate) cfg_type: BLSConfigType,
53
54 pub(crate) machine_id: Option<String>,
56 pub(crate) sort_key: Option<String>,
58
59 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 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
75 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 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 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> {
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 pub(crate) fn boot_artifact_name(&self) -> Result<&str> {
215 Ok(self.boot_artifact_info()?.0)
216 }
217
218 pub(crate) fn boot_artifact_info(&self) -> Result<(&str, bool)> {
226 match &self.cfg_type {
227 BLSConfigType::EFI { efi } => {
228 let file_name = efi
229 .file_name()
230 .ok_or_else(|| anyhow::anyhow!("EFI path missing file name: {}", efi))?;
231
232 let without_suffix = file_name.strip_suffix(EFI_EXT).ok_or_else(|| {
233 anyhow::anyhow!(
234 "EFI file name missing expected suffix '{}': {}",
235 EFI_EXT,
236 file_name
237 )
238 })?;
239
240 match without_suffix.strip_prefix(UKI_NAME_PREFIX) {
242 Some(no_prefix) => Ok((no_prefix, true)),
243 None => Ok((without_suffix, false)),
244 }
245 }
246
247 BLSConfigType::NonEFI { linux, .. } => {
248 let parent_dir = linux.parent().ok_or_else(|| {
249 anyhow::anyhow!("Linux kernel path has no parent directory: {}", linux)
250 })?;
251
252 let dir_name = parent_dir.file_name().ok_or_else(|| {
253 anyhow::anyhow!("Parent directory has no file name: {}", parent_dir)
254 })?;
255
256 match dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) {
258 Some(dir_name_no_prefix) => Ok((dir_name_no_prefix, true)),
259 None => Ok((dir_name, false)),
260 }
261 }
262
263 BLSConfigType::Unknown => {
264 anyhow::bail!("Cannot extract boot artifact name from unknown config type")
265 }
266 }
267 }
268
269 pub(crate) fn get_cmdline(&self) -> Result<&Cmdline<'_>> {
273 match &self.cfg_type {
274 BLSConfigType::NonEFI { options, .. } => {
275 let options = options
276 .as_ref()
277 .ok_or_else(|| anyhow::anyhow!("No cmdline found for config"))?;
278
279 Ok(options)
280 }
281
282 _ => anyhow::bail!("No cmdline found for config"),
283 }
284 }
285}
286
287pub(crate) fn parse_bls_config(input: &str) -> Result<BLSConfig> {
288 let mut title = None;
289 let mut version = None;
290 let mut linux = None;
291 let mut efi = None;
292 let mut initrd = Vec::new();
293 let mut options = None;
294 let mut machine_id = None;
295 let mut sort_key = None;
296 let mut extra = HashMap::new();
297
298 for line in input.lines() {
299 let line = line.trim();
300 if line.is_empty() || line.starts_with('#') {
301 continue;
302 }
303
304 if let Some((key, value)) = line.split_once(' ') {
305 let value = value.trim().to_string();
306 match key {
307 "title" => title = Some(value),
308 "version" => version = Some(value),
309 "linux" => linux = Some(Utf8PathBuf::from(value)),
310 "initrd" => initrd.push(Utf8PathBuf::from(value)),
311 "options" => options = Some(CmdlineOwned::from(value)),
312 "machine-id" => machine_id = Some(value),
313 "sort-key" => sort_key = Some(value),
314 "efi" => efi = Some(Utf8PathBuf::from(value)),
315 _ => {
316 extra.insert(key.to_string(), value);
317 }
318 }
319 }
320 }
321
322 let version = version.ok_or_else(|| anyhow!("Missing 'version' value"))?;
323
324 let cfg_type = match (linux, efi) {
325 (None, Some(efi)) => BLSConfigType::EFI { efi },
326
327 (Some(linux), None) => BLSConfigType::NonEFI {
328 linux,
329 initrd,
330 options,
331 },
332
333 (Some(_), Some(_)) => anyhow::bail!("'linux' and 'efi' values present"),
336 (None, None) => anyhow::bail!("Missing 'linux' or 'efi' value"),
337 };
338
339 Ok(BLSConfig {
340 title,
341 version,
342 cfg_type,
343 machine_id,
344 sort_key,
345 extra,
346 })
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_parse_valid_bls_config() -> Result<()> {
355 let input = r#"
356 title Fedora 42.20250623.3.1 (CoreOS)
357 version 2
358 linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
359 initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
360 options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
361 custom1 value1
362 custom2 value2
363 "#;
364
365 let config = parse_bls_config(input)?;
366
367 let BLSConfigType::NonEFI {
368 linux,
369 initrd,
370 options,
371 } = config.cfg_type
372 else {
373 panic!("Expected non EFI variant");
374 };
375
376 assert_eq!(
377 config.title,
378 Some("Fedora 42.20250623.3.1 (CoreOS)".to_string())
379 );
380 assert_eq!(config.version, "2");
381 assert_eq!(
382 linux,
383 "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10"
384 );
385 assert_eq!(
386 initrd,
387 vec![
388 "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img"
389 ]
390 );
391 assert_eq!(
392 &*options.unwrap(),
393 "root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"
394 );
395 assert_eq!(config.extra.get("custom1"), Some(&"value1".to_string()));
396 assert_eq!(config.extra.get("custom2"), Some(&"value2".to_string()));
397
398 Ok(())
399 }
400
401 #[test]
402 fn test_parse_multiple_initrd() -> Result<()> {
403 let input = r#"
404 title Fedora 42.20250623.3.1 (CoreOS)
405 version 2
406 linux /boot/vmlinuz
407 initrd /boot/initramfs-1.img
408 initrd /boot/initramfs-2.img
409 options root=UUID=abc123 rw
410 "#;
411
412 let config = parse_bls_config(input)?;
413
414 let BLSConfigType::NonEFI { initrd, .. } = config.cfg_type else {
415 panic!("Expected non EFI variant");
416 };
417
418 assert_eq!(
419 initrd,
420 vec!["/boot/initramfs-1.img", "/boot/initramfs-2.img"]
421 );
422
423 Ok(())
424 }
425
426 #[test]
427 fn test_parse_missing_version() {
428 let input = r#"
429 title Fedora
430 linux /vmlinuz
431 initrd /initramfs.img
432 options root=UUID=xyz ro quiet
433 "#;
434
435 let parsed = parse_bls_config(input);
436 assert!(parsed.is_err());
437 }
438
439 #[test]
440 fn test_parse_missing_linux() {
441 let input = r#"
442 title Fedora
443 version 1
444 initrd /initramfs.img
445 options root=UUID=xyz ro quiet
446 "#;
447
448 let parsed = parse_bls_config(input);
449 assert!(parsed.is_err());
450 }
451
452 #[test]
453 fn test_display_output() -> Result<()> {
454 let input = r#"
455 title Test OS
456 version 10
457 linux /boot/vmlinuz
458 initrd /boot/initrd.img
459 initrd /boot/initrd-extra.img
460 options root=UUID=abc composefs=some-uuid
461 foo bar
462 "#;
463
464 let config = parse_bls_config(input)?;
465 let output = format!("{}", config);
466 let mut output_lines = output.lines();
467
468 assert_eq!(output_lines.next().unwrap(), "title Test OS");
469 assert_eq!(output_lines.next().unwrap(), "version 10");
470 assert_eq!(output_lines.next().unwrap(), "linux /boot/vmlinuz");
471 assert_eq!(output_lines.next().unwrap(), "initrd /boot/initrd.img");
472 assert_eq!(
473 output_lines.next().unwrap(),
474 "initrd /boot/initrd-extra.img"
475 );
476 assert_eq!(
477 output_lines.next().unwrap(),
478 "options root=UUID=abc composefs=some-uuid"
479 );
480 assert_eq!(output_lines.next().unwrap(), "foo bar");
481
482 Ok(())
483 }
484
485 #[test]
486 fn test_ordering_by_version() -> Result<()> {
487 let config1 = parse_bls_config(
488 r#"
489 title Entry 1
490 version 3
491 linux /vmlinuz-3
492 initrd /initrd-3
493 options opt1
494 "#,
495 )?;
496
497 let config2 = parse_bls_config(
498 r#"
499 title Entry 2
500 version 5
501 linux /vmlinuz-5
502 initrd /initrd-5
503 options opt2
504 "#,
505 )?;
506
507 assert!(config1 > config2);
508 Ok(())
509 }
510
511 #[test]
512 fn test_ordering_by_sort_key() -> Result<()> {
513 let config1 = parse_bls_config(
514 r#"
515 title Entry 1
516 version 3
517 sort-key a
518 linux /vmlinuz-3
519 initrd /initrd-3
520 options opt1
521 "#,
522 )?;
523
524 let config2 = parse_bls_config(
525 r#"
526 title Entry 2
527 version 5
528 sort-key b
529 linux /vmlinuz-5
530 initrd /initrd-5
531 options opt2
532 "#,
533 )?;
534
535 assert!(config1 < config2);
536 Ok(())
537 }
538
539 #[test]
540 fn test_ordering_by_sort_key_and_version() -> Result<()> {
541 let config1 = parse_bls_config(
542 r#"
543 title Entry 1
544 version 3
545 sort-key a
546 linux /vmlinuz-3
547 initrd /initrd-3
548 options opt1
549 "#,
550 )?;
551
552 let config2 = parse_bls_config(
553 r#"
554 title Entry 2
555 version 5
556 sort-key a
557 linux /vmlinuz-5
558 initrd /initrd-5
559 options opt2
560 "#,
561 )?;
562
563 assert!(config1 > config2);
564 Ok(())
565 }
566
567 #[test]
568 fn test_ordering_by_machine_id() -> Result<()> {
569 let config1 = parse_bls_config(
570 r#"
571 title Entry 1
572 version 3
573 machine-id a
574 linux /vmlinuz-3
575 initrd /initrd-3
576 options opt1
577 "#,
578 )?;
579
580 let config2 = parse_bls_config(
581 r#"
582 title Entry 2
583 version 5
584 machine-id b
585 linux /vmlinuz-5
586 initrd /initrd-5
587 options opt2
588 "#,
589 )?;
590
591 assert!(config1 < config2);
592 Ok(())
593 }
594
595 #[test]
596 fn test_ordering_by_machine_id_and_version() -> Result<()> {
597 let config1 = parse_bls_config(
598 r#"
599 title Entry 1
600 version 3
601 machine-id a
602 linux /vmlinuz-3
603 initrd /initrd-3
604 options opt1
605 "#,
606 )?;
607
608 let config2 = parse_bls_config(
609 r#"
610 title Entry 2
611 version 5
612 machine-id a
613 linux /vmlinuz-5
614 initrd /initrd-5
615 options opt2
616 "#,
617 )?;
618
619 assert!(config1 > config2);
620 Ok(())
621 }
622
623 #[test]
624 fn test_ordering_by_nontrivial_version() -> Result<()> {
625 let config_final = parse_bls_config(
626 r#"
627 title Entry 1
628 version 1.0
629 linux /vmlinuz-1
630 initrd /initrd-1
631 "#,
632 )?;
633
634 let config_rc1 = parse_bls_config(
635 r#"
636 title Entry 2
637 version 1.0~rc1
638 linux /vmlinuz-2
639 initrd /initrd-2
640 "#,
641 )?;
642
643 assert!(config_final < config_rc1);
647 Ok(())
648 }
649
650 #[test]
651 fn test_boot_artifact_name_efi_success() -> Result<()> {
652 use camino::Utf8PathBuf;
653
654 let efi_path = Utf8PathBuf::from("bootc_composefs-abcd1234.efi");
655 let config = BLSConfig {
656 cfg_type: BLSConfigType::EFI { efi: efi_path },
657 version: "1".to_string(),
658 ..Default::default()
659 };
660
661 let artifact_name = config.boot_artifact_name()?;
662 assert_eq!(artifact_name, "abcd1234");
663 Ok(())
664 }
665
666 #[test]
667 fn test_boot_artifact_name_non_efi_success() -> Result<()> {
668 use camino::Utf8PathBuf;
669
670 let linux_path = Utf8PathBuf::from("/boot/bootc_composefs-xyz5678/vmlinuz");
671 let config = BLSConfig {
672 cfg_type: BLSConfigType::NonEFI {
673 linux: linux_path,
674 initrd: vec![],
675 options: None,
676 },
677 version: "1".to_string(),
678 ..Default::default()
679 };
680
681 let artifact_name = config.boot_artifact_name()?;
682 assert_eq!(artifact_name, "xyz5678");
683 Ok(())
684 }
685
686 #[test]
687 fn test_boot_artifact_name_efi_missing_prefix() {
688 use camino::Utf8PathBuf;
689
690 let efi_path = Utf8PathBuf::from("/EFI/Linux/abcd1234.efi");
691 let config = BLSConfig {
692 cfg_type: BLSConfigType::EFI { efi: efi_path },
693 version: "1".to_string(),
694 ..Default::default()
695 };
696
697 let artifact_name = config
698 .boot_artifact_name()
699 .expect("Should extract artifact name");
700 assert_eq!(artifact_name, "abcd1234");
701 }
702
703 #[test]
704 fn test_boot_artifact_name_efi_missing_suffix() {
705 use camino::Utf8PathBuf;
706
707 let efi_path = Utf8PathBuf::from("bootc_composefs-abcd1234");
708 let config = BLSConfig {
709 cfg_type: BLSConfigType::EFI { efi: efi_path },
710 version: "1".to_string(),
711 ..Default::default()
712 };
713
714 let result = config.boot_artifact_name();
715 assert!(result.is_err());
716 assert!(
717 result
718 .unwrap_err()
719 .to_string()
720 .contains("missing expected suffix")
721 );
722 }
723
724 #[test]
725 fn test_boot_artifact_name_efi_no_filename() {
726 use camino::Utf8PathBuf;
727
728 let efi_path = Utf8PathBuf::from("/");
729 let config = BLSConfig {
730 cfg_type: BLSConfigType::EFI { efi: efi_path },
731 version: "1".to_string(),
732 ..Default::default()
733 };
734
735 let result = config.boot_artifact_name();
736 assert!(result.is_err());
737 assert!(
738 result
739 .unwrap_err()
740 .to_string()
741 .contains("missing file name")
742 );
743 }
744
745 #[test]
746 fn test_boot_artifact_name_unknown_type() {
747 let config = BLSConfig {
748 cfg_type: BLSConfigType::Unknown,
749 version: "1".to_string(),
750 ..Default::default()
751 };
752
753 let result = config.boot_artifact_name();
754 assert!(result.is_err());
755 assert!(
756 result
757 .unwrap_err()
758 .to_string()
759 .contains("unknown config type")
760 );
761 }
762 #[test]
763 fn test_boot_artifact_name_efi_nested_path() -> Result<()> {
764 let efi_path = Utf8PathBuf::from("/EFI/Linux/bootc/bootc_composefs-deadbeef01234567.efi");
765 let config = BLSConfig {
766 cfg_type: BLSConfigType::EFI { efi: efi_path },
767 version: "1".to_string(),
768 ..Default::default()
769 };
770
771 assert_eq!(config.boot_artifact_name()?, "deadbeef01234567");
772 Ok(())
773 }
774
775 #[test]
776 fn test_boot_artifact_name_non_efi_deep_path() -> Result<()> {
777 let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6";
779 let linux_path = Utf8PathBuf::from(format!("/boot/bootc_composefs-{digest}/vmlinuz"));
780 let config = BLSConfig {
781 cfg_type: BLSConfigType::NonEFI {
782 linux: linux_path,
783 initrd: vec![],
784 options: None,
785 },
786 version: "1".to_string(),
787 ..Default::default()
788 };
789
790 assert_eq!(config.boot_artifact_name()?, digest);
791 Ok(())
792 }
793
794 #[test]
796 fn test_boot_artifact_name_from_parsed_efi_config() -> Result<()> {
797 let digest = "f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346";
798 let input = format!(
799 r#"
800 title Fedora UKI
801 version 1
802 efi /EFI/Linux/bootc/bootc_composefs-{digest}.efi
803 sort-key bootc-fedora-0
804 "#
805 );
806
807 let config = parse_bls_config(&input)?;
808 assert_eq!(config.boot_artifact_name()?, digest);
809 assert_eq!(config.get_verity()?, digest);
810 Ok(())
811 }
812
813 #[test]
815 fn test_boot_artifact_name_non_efi_no_parent() {
816 let config = BLSConfig {
817 cfg_type: BLSConfigType::NonEFI {
818 linux: Utf8PathBuf::from("vmlinuz"),
819 initrd: vec![],
820 options: None,
821 },
822 version: "1".to_string(),
823 ..Default::default()
824 };
825
826 let result = config.boot_artifact_name();
827 assert!(result.is_err());
828 }
829}