1use 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 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)]
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 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 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 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 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 (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 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}