1use std::cell::Cell;
65use std::fs::create_dir_all;
66use std::io::{Read, Seek, SeekFrom, Write};
67use std::path::Path;
68use std::sync::Arc;
69
70use anyhow::{Context, Result, anyhow, bail};
71use bootc_mount::tempmount::TempMount;
72use camino::{Utf8Path, Utf8PathBuf};
73use cap_std_ext::{
74 cap_std::{ambient_authority, fs::Dir},
75 dirext::CapStdExtDirExt,
76};
77use clap::ValueEnum;
78use composefs::fs::read_file;
79use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
80use composefs::tree::RegularFile;
81use composefs_boot::bootloader::{
82 BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType,
83 UsrLibModulesVmlinuz, get_boot_resources,
84};
85use composefs_boot::{
86 cmdline::ComposefsCmdline as ComposefsBootCmdline, os_release::OsReleaseInfo, uki,
87};
88use composefs_ctl::composefs;
89use composefs_ctl::composefs_boot;
90use composefs_ctl::composefs_oci;
91use fn_error_context::context;
92use linux_kernel_cmdline::utf8::{Cmdline, Parameter};
93use rustix::{mount::MountFlags, path::Arg};
94use schemars::JsonSchema;
95use serde::{Deserialize, Serialize};
96
97use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state};
98use crate::bootc_composefs::status::ComposefsCmdline;
99use crate::bootc_kargs::compute_new_kargs;
100use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED};
101use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey};
102use crate::spec::BootloaderKind;
103use crate::task::Task;
104use crate::{bootc_composefs::repo::open_composefs_repo, store::Storage};
105use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState};
106use crate::{
107 composefs_consts::{
108 BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED,
109 },
110 spec::{Bootloader, Host},
111};
112use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs};
113
114use crate::install::{RootSetup, State};
115
116pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg";
118pub(crate) const EFI_LINUX: &str = "EFI/Linux";
120
121const SYSTEMD_TIMEOUT: &str = "timeout 5";
123const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf";
124
125pub(crate) const INITRD: &str = "initrd";
126pub(crate) const VMLINUZ: &str = "vmlinuz";
127
128const BOOTC_AUTOENROLL_PATH: &str = "usr/lib/bootc/install/secureboot-keys";
129
130const AUTH_EXT: &str = "auth";
131
132pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc";
137
138pub(crate) enum BootSetupType<'a> {
139 Setup((&'a RootSetup, &'a State, &'a PostFetchState)),
141 Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)),
143}
144
145#[derive(
146 ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema,
147)]
148pub enum BootType {
149 #[default]
150 Bls,
151 Uki,
152}
153
154impl ::std::fmt::Display for BootType {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 let s = match self {
157 BootType::Bls => "bls",
158 BootType::Uki => "uki",
159 };
160
161 write!(f, "{}", s)
162 }
163}
164
165impl TryFrom<&str> for BootType {
166 type Error = anyhow::Error;
167
168 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
169 match value {
170 "bls" => Ok(Self::Bls),
171 "uki" => Ok(Self::Uki),
172 unrecognized => Err(anyhow::anyhow!(
173 "Unrecognized boot option: '{unrecognized}'"
174 )),
175 }
176 }
177}
178
179impl From<&ComposefsBootEntry<Sha512HashValue>> for BootType {
180 fn from(entry: &ComposefsBootEntry<Sha512HashValue>) -> Self {
181 match entry {
182 ComposefsBootEntry::Type1(..) => Self::Bls,
183 ComposefsBootEntry::Type2(..) => Self::Uki,
184 ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls,
185 }
186 }
187}
188
189pub(crate) fn get_efi_uuid_source() -> String {
192 format!(
193 r#"
194if [ -f ${{config_directory}}/{EFI_UUID_FILE} ]; then
195 source ${{config_directory}}/{EFI_UUID_FILE}
196fi
197"#
198 )
199}
200
201const ESP_MOUNT_FLAGS: MountFlags =
203 MountFlags::from_bits_retain(MountFlags::NOEXEC.bits() | MountFlags::NOSUID.bits());
204
205const ESP_MOUNT_DATA: &std::ffi::CStr = c"fmask=0177,dmask=0077";
207
208pub fn mount_esp(device: &str) -> Result<TempMount> {
210 TempMount::mount_dev(device, "vfat", ESP_MOUNT_FLAGS, Some(ESP_MOUNT_DATA))
211}
212
213pub(crate) fn mount_esp_at(
216 device: &str,
217 path: std::path::PathBuf,
218) -> Result<bootc_mount::tempmount::MountGuard> {
219 bootc_mount::tempmount::MountGuard::mount(
220 device,
221 path,
222 "vfat",
223 ESP_MOUNT_FLAGS,
224 Some(ESP_MOUNT_DATA),
225 )
226}
227
228pub(crate) const FILENAME_PRIORITY_PRIMARY: &str = "1";
231
232pub(crate) const FILENAME_PRIORITY_SECONDARY: &str = "0";
234
235pub(crate) const SORTKEY_PRIORITY_PRIMARY: &str = "0";
238
239pub(crate) const SORTKEY_PRIORITY_SECONDARY: &str = "1";
241
242pub fn type1_entry_conf_file_name(
254 os_id: &str,
255 version: impl std::fmt::Display,
256 priority: &str,
257) -> String {
258 let os_id_safe = os_id.replace('-', "_");
259 format!("bootc_{os_id_safe}-{version}-{priority}.conf")
260}
261
262pub(crate) fn primary_sort_key(os_id: &str) -> String {
267 format!("bootc-{os_id}-{SORTKEY_PRIORITY_PRIMARY}")
268}
269
270pub(crate) fn secondary_sort_key(os_id: &str) -> String {
273 format!("bootc-{os_id}-{SORTKEY_PRIORITY_SECONDARY}")
274}
275
276pub(crate) fn get_type1_dir_name(depl_verity: &str) -> String {
278 format!("{TYPE1_BOOT_DIR_PREFIX}{depl_verity}")
279}
280
281pub(crate) fn get_uki_name(depl_verity: &str) -> String {
283 format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_EXT}")
284}
285
286pub(crate) fn get_uki_addon_dir_name(depl_verity: &str) -> String {
288 format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_DIR_EXT}")
289}
290
291#[allow(dead_code)]
292pub(crate) fn get_uki_addon_file_name(depl_verity: &str) -> String {
294 format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_FILE_EXT}")
295}
296
297#[context("Computing boot digest")]
303fn compute_boot_digest(
304 entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
305 repo: &crate::store::ComposefsRepository,
306) -> Result<String> {
307 let vmlinuz = read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?;
308
309 let Some(initramfs) = &entry.initramfs else {
310 anyhow::bail!("initramfs not found");
311 };
312
313 let initramfs = read_file(initramfs, &repo).context("Reading intird")?;
314
315 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
316 .context("Creating hasher")?;
317
318 hasher.update(&vmlinuz).context("hashing vmlinuz")?;
319 hasher.update(&initramfs).context("hashing initrd")?;
320
321 let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
322
323 Ok(hex::encode(digest))
324}
325
326#[context("Computing boot digest for Type1 entries")]
327fn compute_boot_digest_type1(dir: &Dir) -> Result<String> {
328 let mut vmlinuz = dir
329 .open(VMLINUZ)
330 .with_context(|| format!("Opening {VMLINUZ}"))?;
331
332 let mut initrd = dir
333 .open(INITRD)
334 .with_context(|| format!("Opening {INITRD}"))?;
335
336 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
337 .context("Creating hasher")?;
338
339 std::io::copy(&mut vmlinuz, &mut hasher)?;
340 std::io::copy(&mut initrd, &mut hasher)?;
341
342 let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
343
344 Ok(hex::encode(digest))
345}
346
347#[context("Computing boot digest")]
353pub(crate) fn compute_boot_digest_uki<R: Read + Seek>(uki_reader: &mut R) -> Result<String> {
354 let vmlinuz = uki::get_section_buffered(uki_reader, ".linux").context(".linux not present")?;
355 uki_reader
356 .seek(SeekFrom::Start(0))
357 .context("Moving seek to 0")?;
358 let initramfs =
359 uki::get_section_buffered(uki_reader, ".initrd").context(".initrd not present")?;
360
361 let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())
362 .context("Creating hasher")?;
363
364 hasher.update(&vmlinuz).context("hashing vmlinuz")?;
365 hasher.update(&initramfs).context("hashing initrd")?;
366
367 let digest: &[u8] = &hasher.finish().context("Finishing digest")?;
368
369 Ok(hex::encode(digest))
370}
371
372#[context("Checking boot entry duplicates")]
378pub(crate) fn find_vmlinuz_initrd_duplicate(
379 storage: &Storage,
380 digest: &str,
381) -> Result<Option<String>> {
382 let boot_dir = storage.bls_boot_binaries_dir()?;
383
384 for entry in boot_dir.entries_utf8()? {
385 let entry = entry?;
386 let dir_name = entry.file_name()?;
387
388 if !entry.file_type()?.is_dir() {
389 continue;
390 }
391
392 let Some(..) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
393 continue;
394 };
395
396 let entry_digest = compute_boot_digest_type1(&boot_dir.open_dir(&dir_name)?)?;
397
398 if entry_digest == digest {
399 return Ok(Some(dir_name));
400 }
401 }
402
403 Ok(None)
404}
405
406#[context("Writing BLS entries to disk")]
407fn write_bls_boot_entries_to_disk(
408 boot_dir: &Utf8PathBuf,
409 deployment_id: &Sha512HashValue,
410 entry: &UsrLibModulesVmlinuz<Sha512HashValue>,
411 repo: &crate::store::ComposefsRepository,
412) -> Result<()> {
413 let dir_name = get_type1_dir_name(&deployment_id.to_hex());
414
415 let path = boot_dir.join(&dir_name);
417 create_dir_all(&path)?;
418
419 let entries_dir = Dir::open_ambient_dir(&path, ambient_authority())
420 .with_context(|| format!("Opening {path}"))?;
421
422 entries_dir
423 .atomic_write(
424 VMLINUZ,
425 read_file(&entry.vmlinuz, &repo).context("Reading vmlinuz")?,
426 )
427 .context("Writing vmlinuz to path")?;
428
429 let Some(initramfs) = &entry.initramfs else {
430 anyhow::bail!("initramfs not found");
431 };
432
433 entries_dir
434 .atomic_write(
435 INITRD,
436 read_file(initramfs, &repo).context("Reading initrd")?,
437 )
438 .context("Writing initrd to path")?;
439
440 let owned_fd = entries_dir
442 .reopen_as_ownedfd()
443 .context("Reopen as owned fd")?;
444
445 rustix::fs::fsync(owned_fd).context("fsync")?;
446
447 Ok(())
448}
449
450pub fn parse_os_release(root: &Dir) -> Result<Option<(String, Option<String>, Option<String>)>> {
454 let file = root
456 .open_optional("usr/lib/os-release")
457 .context("Opening usr/lib/os-release")?;
458
459 let Some(mut os_rel_file) = file else {
460 return Ok(None);
461 };
462
463 let mut file_contents = String::new();
464 os_rel_file.read_to_string(&mut file_contents)?;
465
466 let parsed = OsReleaseInfo::parse(&file_contents);
467
468 let os_id = parsed
469 .get_value(&["ID"])
470 .unwrap_or_else(|| "bootc".to_string());
471
472 Ok(Some((
473 os_id,
474 parsed.get_pretty_name(),
475 parsed.get_version(),
476 )))
477}
478
479struct BLSEntryPath {
480 entries_path: Utf8PathBuf,
482 abs_entries_path: Utf8PathBuf,
484 config_path: Utf8PathBuf,
486}
487
488#[context("Setting up BLS boot")]
493pub(crate) fn setup_composefs_bls_boot(
494 setup_type: BootSetupType,
495 repo: crate::store::ComposefsRepository,
496 id: &Sha512HashValue,
497 entry: &ComposefsBootEntry<Sha512HashValue>,
498 mounted_erofs: &Dir,
499) -> Result<String> {
500 let id_hex = id.to_hex();
501
502 let (root_path, esp_device, mut cmdline_refs, bootloader) = match setup_type {
503 BootSetupType::Setup((root_setup, state, postfetch)) => {
504 let mut cmdline_options = Cmdline::new();
506
507 cmdline_options.extend(&root_setup.kargs);
508
509 if let Some(user_kargs) = &state.config_opts.karg {
510 for karg in user_kargs {
511 cmdline_options.extend(karg);
512 }
513 }
514
515 let composefs_cmdline =
516 ComposefsCmdline::build(&id_hex, state.composefs_options.allow_missing_verity);
517 cmdline_options.extend(&Cmdline::from(&composefs_cmdline.to_string()));
518
519 if let Some(boot) = root_setup.boot_mount_spec() {
523 if !boot.source.is_empty() {
524 let mount_extra = format!(
525 "systemd.mount-extra={}:/boot:{}:{}",
526 boot.source,
527 boot.fstype,
528 boot.options.as_deref().unwrap_or("defaults"),
529 );
530 cmdline_options.extend(&Cmdline::from(mount_extra.as_str()));
531 tracing::debug!("Added /boot mount karg: {mount_extra}");
532 }
533 }
534
535 let esp_part = root_setup.device_info.find_first_colocated_esp()?;
537
538 (
539 root_setup.physical_root_path.clone(),
540 esp_part.path(),
541 cmdline_options,
542 postfetch.detected_bootloader.clone(),
543 )
544 }
545
546 BootSetupType::Upgrade((storage, booted_cfs, host)) => {
547 let bootloader = host.require_composefs_booted()?.bootloader.clone();
548
549 let boot_dir = storage.require_boot_dir()?;
550 let current_cfg = get_booted_bls(&boot_dir, booted_cfs)?;
551
552 let mut cmdline = match current_cfg.cfg_type {
553 BLSConfigType::NonEFI { options, .. } => {
554 let options = options
555 .ok_or_else(|| anyhow::anyhow!("No 'options' found in BLS Config"))?;
556
557 Cmdline::from(options)
558 }
559
560 _ => anyhow::bail!("Found NonEFI config"),
561 };
562
563 let cfs_cmdline =
565 ComposefsCmdline::build(&id_hex, booted_cfs.cmdline.allow_missing_fsverity)
566 .to_string();
567
568 let param = Parameter::parse(&cfs_cmdline)
569 .context("Failed to create 'composefs=' parameter")?;
570 cmdline.add_or_modify(¶m);
571
572 let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?;
574 let esp_dev = root_dev.find_first_colocated_esp()?;
575
576 (
577 Utf8PathBuf::from("/sysroot"),
578 esp_dev.path(),
579 cmdline,
580 bootloader,
581 )
582 }
583 };
584
585 let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
586
587 let current_root = if is_upgrade {
588 Some(&Dir::open_ambient_dir("/", ambient_authority()).context("Opening root")? as &Dir)
589 } else {
590 None
591 };
592
593 compute_new_kargs(mounted_erofs, current_root, &mut cmdline_refs)?;
594
595 let (entry_paths, _tmpdir_guard) = match bootloader.kind()? {
596 BootloaderKind::GRUBClassic => {
597 let root = Dir::open_ambient_dir(&root_path, ambient_authority())
598 .context("Opening root path")?;
599
600 let entries_path = match root.is_mountpoint("boot")? {
605 Some(true) => "/",
606 Some(false) | None => "/boot",
608 };
609
610 (
611 BLSEntryPath {
612 entries_path: root_path.join("boot"),
613 config_path: root_path.join("boot"),
614 abs_entries_path: entries_path.into(),
615 },
616 None,
617 )
618 }
619
620 BootloaderKind::BLSCompatible => {
621 let efi_mount = mount_esp(&esp_device).context("Mounting ESP")?;
622
623 let mounted_efi = Utf8PathBuf::from(efi_mount.dir.path().as_str()?);
624 let efi_linux_dir = mounted_efi.join(EFI_LINUX);
625
626 (
627 BLSEntryPath {
628 entries_path: efi_linux_dir,
629 config_path: mounted_efi.clone(),
630 abs_entries_path: Utf8PathBuf::from("/").join(EFI_LINUX),
631 },
632 Some(efi_mount),
633 )
634 }
635 };
636
637 let (bls_config, boot_digest, os_id) = match &entry {
638 ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"),
639 ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"),
640
641 ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => {
642 let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo)
643 .context("Computing boot digest")?;
644
645 let osrel = parse_os_release(mounted_erofs)?;
646
647 let (os_id, title, version, sort_key) = match osrel {
648 Some((id_str, title_opt, version_opt)) => (
649 id_str.clone(),
650 title_opt.unwrap_or_else(|| id.to_hex()),
651 version_opt.unwrap_or_else(|| id.to_hex()),
652 primary_sort_key(&id_str),
653 ),
654 None => {
655 let default_id = "bootc".to_string();
656 (
657 default_id.clone(),
658 id.to_hex(),
659 id.to_hex(),
660 primary_sort_key(&default_id),
661 )
662 }
663 };
664
665 let mut bls_config = BLSConfig::default();
666
667 let entries_dir = get_type1_dir_name(&id_hex);
668
669 bls_config
670 .with_title(title)
671 .with_version(version)
672 .with_sort_key(sort_key)
673 .with_cfg(BLSConfigType::NonEFI {
674 linux: entry_paths
675 .abs_entries_path
676 .join(&entries_dir)
677 .join(VMLINUZ),
678 initrd: vec![entry_paths.abs_entries_path.join(&entries_dir).join(INITRD)],
679 options: Some(cmdline_refs),
680 });
681
682 let shared_entry = match setup_type {
683 BootSetupType::Setup(_) => None,
684 BootSetupType::Upgrade((storage, ..)) => {
685 find_vmlinuz_initrd_duplicate(storage, &boot_digest)?
686 }
687 };
688
689 match shared_entry {
690 Some(shared_entry) => {
691 match bls_config.cfg_type {
697 BLSConfigType::NonEFI {
698 ref mut linux,
699 ref mut initrd,
700 ..
701 } => {
702 *linux = entry_paths
703 .abs_entries_path
704 .join(&shared_entry)
705 .join(VMLINUZ);
706
707 *initrd = vec![
708 entry_paths
709 .abs_entries_path
710 .join(&shared_entry)
711 .join(INITRD),
712 ];
713 }
714
715 _ => unreachable!(),
716 };
717 }
718
719 None => {
720 write_bls_boot_entries_to_disk(
721 &entry_paths.entries_path,
722 id,
723 usr_lib_modules_vmlinuz,
724 &repo,
725 )?;
726 }
727 };
728
729 (bls_config, boot_digest, os_id)
730 }
731 };
732
733 let loader_path = entry_paths.config_path.join("loader");
734
735 let (config_path, booted_bls) = if is_upgrade {
736 let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())?;
737
738 let BootSetupType::Upgrade((_, booted_cfs, ..)) = setup_type else {
739 unreachable!("enum mismatch");
741 };
742
743 let mut booted_bls = get_booted_bls(&boot_dir, booted_cfs)?;
744 booted_bls.sort_key = Some(secondary_sort_key(&os_id));
745
746 let staged_path = loader_path.join(STAGED_BOOT_LOADER_ENTRIES);
747
748 if boot_dir
751 .remove_all_optional(TYPE1_ENT_PATH_STAGED)
752 .context("Failed to remove staged directory")?
753 {
754 tracing::debug!("Removed existing staged entries directory");
755 }
756
757 (staged_path, Some(booted_bls))
759 } else {
760 (loader_path.join(BOOT_LOADER_ENTRIES), None)
761 };
762
763 create_dir_all(&config_path).with_context(|| format!("Creating {:?}", config_path))?;
764
765 let loader_entries_dir = Dir::open_ambient_dir(&config_path, ambient_authority())
766 .with_context(|| format!("Opening {config_path:?}"))?;
767
768 loader_entries_dir.atomic_write(
769 type1_entry_conf_file_name(&os_id, &bls_config.version(), FILENAME_PRIORITY_PRIMARY),
770 bls_config.to_string().as_bytes(),
771 )?;
772
773 if let Some(booted_bls) = booted_bls {
774 loader_entries_dir.atomic_write(
775 type1_entry_conf_file_name(&os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
776 booted_bls.to_string().as_bytes(),
777 )?;
778 }
779
780 let owned_loader_entries_fd = loader_entries_dir
781 .reopen_as_ownedfd()
782 .context("Reopening as owned fd")?;
783
784 rustix::fs::fsync(owned_loader_entries_fd).context("fsync")?;
785
786 Ok(boot_digest)
787}
788
789struct UKIInfo {
790 boot_label: String,
791 version: Option<String>,
792 os_id: Option<String>,
793 boot_digest: String,
794}
795
796#[context("Writing {file_path} to ESP")]
798fn write_pe_to_esp(
799 repo: &crate::store::ComposefsRepository,
800 file: &RegularFile<Sha512HashValue>,
801 file_path: &Utf8Path,
802 pe_type: PEType,
803 uki_id: &Sha512HashValue,
804 missing_fsverity_allowed: bool,
805 mounted_efi: impl AsRef<Path>,
806) -> Result<Option<UKIInfo>> {
807 let mut uki_reader = match file {
808 RegularFile::Inline(..) => {
809 anyhow::bail!("File too small to be UKI/Addon")
811 }
812 RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
813 std::fs::File::from(repo.open_object(id)?)
814 }
815 RegularFile::Sparse(..) => {
816 anyhow::bail!("Sparse file cannot be a UKI/Addon")
817 }
818 };
819
820 let mut boot_label: Option<UKIInfo> = None;
821
822 if matches!(pe_type, PEType::Uki) {
825 let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?;
826
827 let composefs_info = ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(&cmdline)
828 .context("Parsing composefs=")?
829 .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?;
830 let composefs_cmdline = composefs_info.digest();
831 let missing_verity_allowed_cmdline = composefs_info.is_insecure();
832
833 match missing_fsverity_allowed {
836 true if !missing_verity_allowed_cmdline => {
837 tracing::warn!(
838 "--allow-missing-fsverity passed as option but UKI cmdline does not support it"
839 );
840 }
841
842 false if missing_verity_allowed_cmdline => {
843 tracing::warn!("UKI cmdline has composefs set as insecure");
844 }
845
846 _ => { }
847 }
848
849 if *composefs_cmdline != *uki_id {
850 anyhow::bail!(
851 "The UKI has the wrong composefs= parameter (is '{composefs_cmdline:?}', should be {uki_id:?})"
852 );
853 }
854
855 uki_reader.seek(SeekFrom::Start(0))?;
856 let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?;
857
858 let parsed_osrel = OsReleaseInfo::parse(&osrel);
859
860 uki_reader.seek(SeekFrom::Start(0))?;
861 let boot_digest = compute_boot_digest_uki(&mut uki_reader)?;
862
863 uki_reader.seek(SeekFrom::Start(0))?;
864 boot_label = Some(UKIInfo {
865 boot_label: uki::get_boot_label_buffered(&mut uki_reader)
866 .context("Getting UKI boot label")?,
867 version: parsed_osrel.get_version(),
868 os_id: parsed_osrel.get_value(&["ID"]),
869 boot_digest,
870 });
871 }
872
873 let efi_linux_path = mounted_efi.as_ref().join(BOOTC_UKI_DIR);
874 create_dir_all(&efi_linux_path).context("Creating bootc UKI directory")?;
875
876 let final_pe_path = match file_path.parent() {
877 Some(parent) => {
878 let renamed_path = match parent.as_str().ends_with(EFI_ADDON_DIR_EXT) {
879 true => {
880 let dir_name = get_uki_addon_dir_name(&uki_id.to_hex());
881
882 parent
883 .parent()
884 .map(|p| p.join(&dir_name))
885 .unwrap_or(dir_name.into())
886 }
887
888 false => parent.to_path_buf(),
889 };
890
891 let full_path = efi_linux_path.join(renamed_path);
892 create_dir_all(&full_path)?;
893
894 full_path
895 }
896
897 None => efi_linux_path,
898 };
899
900 let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority())
901 .with_context(|| format!("Opening {final_pe_path:?}"))?;
902
903 let pe_name = match pe_type {
904 PEType::Uki => &get_uki_name(&uki_id.to_hex()),
905 PEType::UkiAddon => file_path
906 .components()
907 .last()
908 .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))?
909 .as_str(),
910 };
911
912 uki_reader.seek(SeekFrom::Start(0))?;
913 pe_dir
914 .atomic_replace_with(pe_name, |writer| std::io::copy(&mut uki_reader, writer))
915 .context("Writing UKI")?;
916
917 rustix::fs::fsync(
918 pe_dir
919 .reopen_as_ownedfd()
920 .context("Reopening as owned fd")?,
921 )
922 .context("fsync")?;
923
924 Ok(boot_label)
925}
926
927#[context("Writing Grub menuentry")]
928fn write_grub_uki_menuentry(
929 root_path: Utf8PathBuf,
930 setup_type: &BootSetupType,
931 boot_label: String,
932 id: &Sha512HashValue,
933 esp_device: &String,
934) -> Result<()> {
935 let boot_dir = root_path.join("boot");
936 create_dir_all(&boot_dir).context("Failed to create boot dir")?;
937
938 let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..));
939
940 let efi_uuid_source = get_efi_uuid_source();
941
942 let user_cfg_name = if is_upgrade {
943 USER_CFG_STAGED
944 } else {
945 USER_CFG
946 };
947
948 let grub_dir = Dir::open_ambient_dir(boot_dir.join("grub2"), ambient_authority())
949 .context("opening boot/grub2")?;
950
951 if is_upgrade {
953 let mut str_buf = String::new();
954 let boot_dir =
955 Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?;
956 let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?;
957
958 grub_dir
959 .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
960 f.write_all(efi_uuid_source.as_bytes())?;
961 f.write_all(
962 MenuEntry::new(&boot_label, &id.to_hex())
963 .to_string()
964 .as_bytes(),
965 )?;
966
967 f.write_all(entries[0].to_string().as_bytes())?;
971
972 Ok(())
973 })
974 .with_context(|| format!("Writing to {user_cfg_name}"))?;
975
976 rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
977
978 return Ok(());
979 }
980
981 let esp_uuid = Task::new("blkid for ESP UUID", "blkid")
984 .args(["-s", "UUID", "-o", "value", &esp_device])
985 .read()?;
986
987 grub_dir.atomic_write(
988 EFI_UUID_FILE,
989 format!("set EFI_PART_UUID=\"{}\"", esp_uuid.trim()).as_bytes(),
990 )?;
991
992 grub_dir
994 .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> {
995 f.write_all(efi_uuid_source.as_bytes())?;
996 f.write_all(
997 MenuEntry::new(&boot_label, &id.to_hex())
998 .to_string()
999 .as_bytes(),
1000 )?;
1001
1002 Ok(())
1003 })
1004 .with_context(|| format!("Writing to {user_cfg_name}"))?;
1005
1006 rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?;
1007
1008 Ok(())
1009}
1010
1011#[context("Writing systemd UKI config")]
1012fn write_systemd_uki_config(
1013 esp_dir: &Dir,
1014 setup_type: &BootSetupType,
1015 boot_label: UKIInfo,
1016 id: &Sha512HashValue,
1017 bootloader: &Bootloader,
1018) -> Result<()> {
1019 let os_id = boot_label.os_id.as_deref().unwrap_or("bootc");
1020 let primary_sort_key = primary_sort_key(os_id);
1021
1022 let mut bls_conf = BLSConfig::default();
1023 bls_conf
1024 .with_title(boot_label.boot_label)
1025 .with_cfg(BLSConfigType::EFI {
1026 key: EFIKey::for_bootloader(
1027 format!("/{BOOTC_UKI_DIR}/{}", get_uki_name(&id.to_hex())).into(),
1028 bootloader,
1029 ),
1030 })
1031 .with_sort_key(primary_sort_key.clone())
1032 .with_version(boot_label.version.unwrap_or_else(|| id.to_hex()));
1033
1034 let (entries_dir, booted_bls) = match setup_type {
1035 BootSetupType::Setup(..) => {
1036 esp_dir
1037 .create_dir_all(TYPE1_ENT_PATH)
1038 .with_context(|| format!("Creating {TYPE1_ENT_PATH}"))?;
1039
1040 (esp_dir.open_dir(TYPE1_ENT_PATH)?, None)
1041 }
1042
1043 BootSetupType::Upgrade((_, booted_cfs, ..)) => {
1044 esp_dir
1045 .create_dir_all(TYPE1_ENT_PATH_STAGED)
1046 .with_context(|| format!("Creating {TYPE1_ENT_PATH_STAGED}"))?;
1047
1048 let mut booted_bls = get_booted_bls(&esp_dir, booted_cfs)?;
1049 booted_bls.sort_key = Some(secondary_sort_key(os_id));
1050
1051 (esp_dir.open_dir(TYPE1_ENT_PATH_STAGED)?, Some(booted_bls))
1052 }
1053 };
1054
1055 entries_dir
1056 .atomic_write(
1057 type1_entry_conf_file_name(os_id, &bls_conf.version(), FILENAME_PRIORITY_PRIMARY),
1058 bls_conf.to_string().as_bytes(),
1059 )
1060 .context("Writing conf file")?;
1061
1062 if let Some(booted_bls) = booted_bls {
1063 entries_dir.atomic_write(
1064 type1_entry_conf_file_name(os_id, &booted_bls.version(), FILENAME_PRIORITY_SECONDARY),
1065 booted_bls.to_string().as_bytes(),
1066 )?;
1067 }
1068
1069 if !esp_dir.exists(SYSTEMD_LOADER_CONF_PATH) {
1071 esp_dir
1072 .atomic_write(SYSTEMD_LOADER_CONF_PATH, SYSTEMD_TIMEOUT)
1073 .with_context(|| format!("Writing to {SYSTEMD_LOADER_CONF_PATH}"))?;
1074 }
1075
1076 let esp_dir = esp_dir
1077 .reopen_as_ownedfd()
1078 .context("Reopening as owned fd")?;
1079 rustix::fs::fsync(esp_dir).context("fsync")?;
1080
1081 Ok(())
1082}
1083
1084#[context("Setting up UKI boot")]
1085pub(crate) fn setup_composefs_uki_boot(
1086 setup_type: BootSetupType,
1087 repo: crate::store::ComposefsRepository,
1088 id: &Sha512HashValue,
1089 entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
1090) -> Result<String> {
1091 let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type
1092 {
1093 BootSetupType::Setup((root_setup, state, postfetch)) => {
1094 state.require_no_kargs_for_uki()?;
1095
1096 let esp_part = root_setup.device_info.find_first_colocated_esp()?;
1098
1099 (
1100 root_setup.physical_root_path.clone(),
1101 esp_part.path(),
1102 postfetch.detected_bootloader.clone(),
1103 state.composefs_options.allow_missing_verity,
1104 state.composefs_options.uki_addon.as_ref(),
1105 )
1106 }
1107
1108 BootSetupType::Upgrade((storage, booted_cfs, host)) => {
1109 let sysroot = Utf8PathBuf::from("/sysroot"); let bootloader = host.require_composefs_booted()?.bootloader.clone();
1111
1112 let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?;
1114 let esp_dev = root_dev.find_first_colocated_esp()?;
1115
1116 (
1117 sysroot,
1118 esp_dev.path(),
1119 bootloader,
1120 booted_cfs.cmdline.allow_missing_fsverity,
1121 None,
1122 )
1123 }
1124 };
1125
1126 let esp_mount = mount_esp(&esp_device).context("Mounting ESP")?;
1127
1128 let mut uki_info: Option<UKIInfo> = None;
1129
1130 for entry in entries {
1131 match entry {
1132 ComposefsBootEntry::Type1(..) => tracing::debug!("Skipping Type1 Entry"),
1133 ComposefsBootEntry::UsrLibModulesVmLinuz(..) => {
1134 tracing::debug!("Skipping vmlinuz in /usr/lib/modules")
1135 }
1136
1137 ComposefsBootEntry::Type2(entry) => {
1138 if matches!(entry.pe_type, PEType::UkiAddon) {
1140 let Some(addons) = uki_addons else {
1141 continue;
1142 };
1143
1144 let addon_name = entry
1145 .file_path
1146 .components()
1147 .last()
1148 .ok_or_else(|| anyhow::anyhow!("Could not get UKI addon name"))?;
1149
1150 let addon_name = addon_name.as_str()?;
1151
1152 let addon_name =
1153 addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| {
1154 anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_DIR_EXT}")
1155 })?;
1156
1157 if !addons.iter().any(|passed_addon| passed_addon == addon_name) {
1158 continue;
1159 }
1160 }
1161
1162 let utf8_file_path = Utf8Path::from_path(&entry.file_path)
1163 .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?;
1164
1165 let ret = write_pe_to_esp(
1166 &repo,
1167 &entry.file,
1168 utf8_file_path,
1169 entry.pe_type,
1170 &id,
1171 missing_fsverity_allowed,
1172 esp_mount.dir.path(),
1173 )?;
1174
1175 if let Some(label) = ret {
1176 uki_info = Some(label);
1177 }
1178 }
1179 };
1180 }
1181
1182 let uki_info =
1183 uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?;
1184
1185 let boot_digest = uki_info.boot_digest.clone();
1186
1187 match bootloader.kind()? {
1188 BootloaderKind::GRUBClassic => {
1189 write_grub_uki_menuentry(root_path, &setup_type, uki_info.boot_label, id, &esp_device)?
1190 }
1191
1192 BootloaderKind::BLSCompatible => {
1193 write_systemd_uki_config(&esp_mount.fd, &setup_type, uki_info, id, &bootloader)?
1194 }
1195 };
1196
1197 Ok(boot_digest)
1198}
1199
1200pub(crate) struct MountedImageRoot {
1214 esp_device: Utf8PathBuf,
1225 esp_mounted: Cell<bool>,
1232 _tmp: bootc_mount::tempmount::MountGuard,
1234 composefs: TempMount,
1235 pub(crate) esp_subdir: &'static str,
1236}
1237
1238impl MountedImageRoot {
1239 #[context("Preparing image root for bootloader installation")]
1243 pub(crate) fn new(
1244 composefs_mnt_fd: std::os::fd::OwnedFd,
1245 device: &bootc_blockdev::Device,
1246 ) -> Result<Self> {
1247 let roots = device.find_all_roots()?;
1248 let mut esp_part = None;
1249 for root in &roots {
1250 if let Some(esp) = root.find_partition_of_esp_optional()? {
1251 esp_part = Some(esp);
1252 break;
1253 }
1254 }
1255 let esp_part = esp_part.ok_or_else(|| anyhow!("ESP partition not found"))?;
1256
1257 let composefs = TempMount::mount_fd(composefs_mnt_fd)
1260 .context("Attaching composefs image to temporary directory")?;
1261
1262 let esp_subdir = "boot";
1267
1268 let tmp_path = composefs.dir.path().join("tmp");
1271 let tmp = bootc_mount::tempmount::MountGuard::mount(
1272 "tmpfs",
1273 tmp_path,
1274 "tmpfs",
1275 MountFlags::NOEXEC | MountFlags::NOSUID | MountFlags::NODEV,
1276 None::<&std::ffi::CStr>,
1277 )
1278 .context("Mounting tmpfs into composefs root")?;
1279
1280 Ok(Self {
1281 esp_device: esp_part.path().into(),
1282 esp_mounted: Cell::new(false),
1283 _tmp: tmp,
1284 composefs,
1285 esp_subdir,
1286 })
1287 }
1288
1289 pub(crate) fn dir(&self) -> &Dir {
1291 &self.composefs.fd
1292 }
1293
1294 pub(crate) fn root_path(&self) -> &std::path::Path {
1296 self.composefs.dir.path()
1297 }
1298
1299 pub(crate) fn open_esp_dir(&self) -> Result<Dir> {
1306 if !self.esp_mounted.get() {
1307 bail!("BUG: attempted to open the ESP directory while it is not mounted");
1308 }
1309 self.composefs
1310 .fd
1311 .open_dir(self.esp_subdir)
1312 .with_context(|| format!("Opening ESP at /{}", self.esp_subdir))
1313 }
1314
1315 pub(crate) fn with_esp<T>(&self, f: impl FnOnce(&Dir) -> Result<T>) -> Result<T> {
1322 let esp_path = self.root_path().join(self.esp_subdir);
1323 let _guard = mount_esp_at(self.esp_device.as_str(), esp_path)
1324 .context("Mounting ESP into composefs root")?;
1325
1326 self.esp_mounted.set(true);
1327 struct ResetOnDrop<'a>(&'a Cell<bool>);
1331 impl Drop for ResetOnDrop<'_> {
1332 fn drop(&mut self) {
1333 self.0.set(false);
1334 }
1335 }
1336 let _reset = ResetOnDrop(&self.esp_mounted);
1337
1338 let dir = self.open_esp_dir()?;
1339 f(&dir)
1340 }
1341}
1342
1343pub struct SecurebootKeys {
1344 pub dir: Dir,
1345 pub keys: Vec<Utf8PathBuf>,
1346}
1347
1348fn get_secureboot_keys(fs: &Dir, p: &str) -> Result<Option<SecurebootKeys>> {
1349 let mut entries = vec![];
1350
1351 let keys_dir = match fs.open_dir_optional(p)? {
1353 Some(d) => d,
1354 _ => return Ok(None),
1355 };
1356
1357 for entry in keys_dir.entries()? {
1360 let dir_e = entry?;
1361 let dirname = dir_e.file_name();
1362 if !dir_e.file_type()?.is_dir() {
1363 bail!("/{p}/{dirname:?} is not a directory");
1364 }
1365
1366 let dir_path: Utf8PathBuf = dirname.try_into()?;
1367 let dir = dir_e.open_dir()?;
1368 for entry in dir.entries()? {
1369 let e = entry?;
1370 let local: Utf8PathBuf = e.file_name().try_into()?;
1371 let path = dir_path.join(local);
1372
1373 if path.extension() != Some(AUTH_EXT) {
1374 continue;
1375 }
1376
1377 if !e.file_type()?.is_file() {
1378 bail!("/{p}/{path:?} is not a file");
1379 }
1380 entries.push(path);
1381 }
1382 }
1383 return Ok(Some(SecurebootKeys {
1384 dir: keys_dir,
1385 keys: entries,
1386 }));
1387}
1388
1389#[context("Setting up composefs boot")]
1390pub(crate) async fn setup_composefs_boot(
1391 root_setup: &RootSetup,
1392 state: &State,
1393 pull_result: &composefs_oci::PullResult<Sha512HashValue>,
1394 allow_missing_fsverity: bool,
1395) -> Result<()> {
1396 const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5";
1397
1398 tracing::info!(
1399 message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID,
1400 bootc.operation = "boot_setup",
1401 bootc.config_digest = %pull_result.config_digest,
1402 bootc.allow_missing_fsverity = allow_missing_fsverity,
1403 "Setting up composefs boot",
1404 );
1405
1406 let mut repo = open_composefs_repo(&root_setup.physical_root)?;
1407 if allow_missing_fsverity {
1408 repo.set_insecure();
1409 }
1410
1411 let repo = Arc::new(repo);
1412
1413 let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest)
1415 .context("Generating bootable EROFS image")?;
1416
1417 let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None)
1419 .context("Creating composefs filesystem for boot entry discovery")?;
1420 let entries =
1421 get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?;
1422
1423 let composefs_mnt_fd = repo
1424 .mount(&id.to_hex())
1425 .context("Failed to mount composefs image")?;
1426 let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?;
1427
1428 let postfetch = PostFetchState::new(state, mounted_root.dir())?;
1429
1430 let boot_uuid = root_setup
1431 .get_boot_uuid()?
1432 .or(root_setup.rootfs_uuid.as_deref())
1433 .ok_or_else(|| anyhow!("No uuid for boot/root"))?;
1434
1435 if cfg!(target_arch = "s390x") {
1436 crate::bootloader::install_via_zipl(
1438 &root_setup.device_info.require_single_root()?,
1439 boot_uuid,
1440 )?;
1441 } else if matches!(
1442 postfetch.detected_bootloader,
1443 Bootloader::Grub | Bootloader::GrubCC
1444 ) {
1445 let chroot_target = Utf8Path::from_path(mounted_root.root_path())
1446 .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?;
1447 let bind_boot_path = root_setup.physical_root_path.join("boot");
1455 crate::bootloader::install_via_bootupd(
1456 &root_setup.device_info,
1457 &root_setup.physical_root_path,
1458 &state.config_opts,
1459 Some(chroot_target),
1460 Some(bind_boot_path.as_path()),
1461 )?;
1462
1463 if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) {
1465 root_setup
1468 .physical_root
1469 .remove_all_optional("boot/grub2")
1470 .context("removing grub2")?;
1471
1472 mounted_root.with_esp(|esp_dir| {
1475 let (os_id, ..) = parse_os_release(mounted_root.dir())?
1476 .ok_or_else(|| anyhow::anyhow!("Failed to parse os-release"))?;
1477
1478 let dir = format!("EFI/{os_id}");
1479
1480 let efis_dir = esp_dir
1482 .open_dir(&dir)
1483 .with_context(|| format!("Opening {dir}"))?;
1484
1485 efis_dir
1486 .remove_file_optional("bootuuid.cfg")
1487 .context("Removing bootuuid.cfg")?;
1488 efis_dir
1489 .remove_file_optional("grub.cfg")
1490 .context("Removing grub.cfg")?;
1491
1492 let final_name = match std::env::consts::ARCH {
1493 "x86_64" => "grubx64.efi",
1494 "aarch64" => "grubaa64-cc.efi",
1495 arch => anyhow::bail!("GrubCC not supported for: {arch}"),
1496 };
1497
1498 mounted_root
1499 .dir()
1500 .copy("usr/lib/grub-cc/grub-cc.efi", &efis_dir, final_name)
1501 .context("Copying grub-cc binary")?;
1502
1503 Ok(())
1504 })?;
1505 }
1506 } else {
1507 mounted_root.with_esp(|_esp_dir| {
1508 crate::bootloader::install_systemd_boot(
1509 &mounted_root,
1510 &state.config_opts,
1511 get_secureboot_keys(mounted_root.dir(), BOOTC_AUTOENROLL_PATH)?,
1512 )
1513 })?;
1514 }
1515
1516 let Some(entry) = entries.iter().next() else {
1517 anyhow::bail!("No boot entries!");
1518 };
1519
1520 let boot_type = BootType::from(entry);
1521
1522 let repo = Arc::try_unwrap(repo).map_err(|_| {
1524 anyhow::anyhow!(
1525 "BUG: Arc<Repository> still has other references after boot image generation"
1526 )
1527 })?;
1528
1529 let boot_digest = match boot_type {
1530 BootType::Bls => setup_composefs_bls_boot(
1531 BootSetupType::Setup((&root_setup, &state, &postfetch)),
1532 repo,
1533 &id,
1534 entry,
1535 mounted_root.dir(),
1536 )?,
1537 BootType::Uki => setup_composefs_uki_boot(
1538 BootSetupType::Setup((&root_setup, &state, &postfetch)),
1539 repo,
1540 &id,
1541 entries,
1542 )?,
1543 };
1544
1545 write_composefs_state(
1546 &root_setup.physical_root_path,
1547 &id,
1548 &crate::spec::ImageReference::from(state.target_imgref.clone()),
1549 None,
1550 boot_type,
1551 boot_digest,
1552 &pull_result.manifest_digest.to_string(),
1553 allow_missing_fsverity,
1554 )
1555 .await?;
1556
1557 Ok(())
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562 use super::*;
1563
1564 #[test]
1565 fn test_type1_filename_generation() {
1566 let filename =
1568 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1569 assert_eq!(filename, "bootc_fedora-41.20251125.0-1.conf");
1570
1571 let primary =
1573 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1574 let secondary =
1575 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1576 assert_eq!(primary, "bootc_fedora-41.20251125.0-1.conf");
1577 assert_eq!(secondary, "bootc_fedora-41.20251125.0-0.conf");
1578
1579 let filename =
1581 type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1582 assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
1583
1584 let filename =
1586 type1_entry_conf_file_name("my-custom-os", "1.0.0", FILENAME_PRIORITY_PRIMARY);
1587 assert_eq!(filename, "bootc_my_custom_os-1.0.0-1.conf");
1588
1589 let filename = type1_entry_conf_file_name("rhel", "9.3.0", FILENAME_PRIORITY_SECONDARY);
1591 assert_eq!(filename, "bootc_rhel-9.3.0-0.conf");
1592 }
1593
1594 #[test]
1595 fn test_grub_filename_parsing() {
1596 let filename = type1_entry_conf_file_name("fedora-coreos", "41.20251125.0", "1");
1605 assert_eq!(filename, "bootc_fedora_coreos-41.20251125.0-1.conf");
1606
1607 let without_ext = filename.strip_suffix(".conf").unwrap();
1613 let parts: Vec<&str> = without_ext.rsplitn(3, '-').collect();
1614 assert_eq!(parts.len(), 3);
1615 assert_eq!(parts[0], "1"); assert_eq!(parts[1], "41.20251125.0"); assert_eq!(parts[2], "bootc_fedora_coreos"); }
1619
1620 #[test]
1621 fn test_sort_keys() {
1622 let primary = primary_sort_key("fedora");
1624 let secondary = secondary_sort_key("fedora");
1625
1626 assert_eq!(primary, "bootc-fedora-0");
1627 assert_eq!(secondary, "bootc-fedora-1");
1628
1629 assert!(primary < secondary);
1631
1632 let primary_coreos = primary_sort_key("fedora-coreos");
1634 assert_eq!(primary_coreos, "bootc-fedora-coreos-0");
1635 }
1636
1637 #[test]
1638 fn test_filename_sorting_grub_style() {
1639 let primary =
1643 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1644 let secondary =
1645 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1646
1647 assert!(
1649 primary > secondary,
1650 "Primary should sort before secondary in descending order"
1651 );
1652
1653 let newer =
1655 type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
1656 let older =
1657 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_PRIMARY);
1658
1659 assert!(
1661 newer > older,
1662 "Newer version should sort before older in descending order"
1663 );
1664
1665 let fedora = type1_entry_conf_file_name("fedora", "41.0", FILENAME_PRIORITY_PRIMARY);
1667 let rhel = type1_entry_conf_file_name("rhel", "9.0", FILENAME_PRIORITY_PRIMARY);
1668
1669 assert!(
1671 rhel > fedora,
1672 "RHEL should sort before Fedora in descending order"
1673 );
1674 }
1675}