1use std::{io::Read, sync::OnceLock};
2
3use anyhow::{Context, Result};
4use bootc_kernel_cmdline::utf8::Cmdline;
5use bootc_mount::inspect_filesystem;
6use composefs_ctl::composefs::fsverity::Sha512HashValue;
7use composefs_ctl::composefs_oci;
8use composefs_oci::OciImage;
9use fn_error_context::context;
10use openssl::sha::Sha256;
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 bootc_composefs::{
15 boot::BootType,
16 selinux::are_selinux_policies_compatible,
17 state::{get_composefs_usr_overlay_status, read_origin},
18 utils::{compute_store_boot_digest_for_uki, get_uki_cmdline},
19 },
20 composefs_consts::{
21 COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST,
22 TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, USER_CFG_STAGED,
23 },
24 install::EFI_LOADER_INFO,
25 parsers::{
26 bls_config::{BLSConfig, BLSConfigType, EFIKey, parse_bls_config},
27 grub_menuconfig::{MenuEntry, parse_grub_menuentry_file},
28 },
29 spec::{BootEntry, BootOrder, BootloaderKind, Host, HostSpec, ImageStatus},
30 store::Storage,
31 utils::{EfiError, read_uefi_var},
32};
33
34use std::str::FromStr;
35
36use bootc_utils::try_deserialize_timestamp;
37use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
38use ostree_container::OstreeImageReference;
39use ostree_ext::container::{self as ostree_container};
40use ostree_ext::containers_image_proxy::{ImageProxy, ImageReference};
41
42use ostree_ext::oci_spec;
43use ostree_ext::{container::deploy::ORIGIN_CONTAINER, oci_spec::image::ImageConfiguration};
44
45use ostree_ext::oci_spec::image::ImageManifest;
46use tokio::io::AsyncReadExt;
47
48use crate::composefs_consts::{
49 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT,
50 ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE,
51};
52use crate::spec::Bootloader;
53
54#[derive(Debug, Serialize, Deserialize)]
56pub(crate) struct ImgConfigManifest {
57 pub(crate) config: ImageConfiguration,
58 pub(crate) manifest: ImageManifest,
59}
60
61#[derive(Clone)]
63pub(crate) struct ComposefsCmdline {
64 pub allow_missing_fsverity: bool,
65 pub digest: Box<str>,
66 pub is_transient: bool,
69}
70
71struct DeploymentBootInfo<'a> {
73 boot_digest: &'a str,
74 full_cmdline: &'a Cmdline<'a>,
75 verity: &'a str,
76}
77
78impl ComposefsCmdline {
79 pub(crate) fn new(s: &str) -> Self {
80 let (allow_missing_fsverity, digest_str) = s
81 .strip_prefix('?')
82 .map(|v| (true, v))
83 .unwrap_or_else(|| (false, s));
84 ComposefsCmdline {
85 allow_missing_fsverity,
86 digest: digest_str.into(),
87 is_transient: false,
88 }
89 }
90
91 pub(crate) fn build(digest: &str, allow_missing_fsverity: bool) -> Self {
92 ComposefsCmdline {
93 allow_missing_fsverity,
94 digest: digest.into(),
95 is_transient: false,
96 }
97 }
98
99 pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Option<Self> {
101 match cmdline.find(COMPOSEFS_CMDLINE) {
102 Some(param) => {
103 let value = param.value()?;
104 Some(Self::new(value))
105 }
106 None => None,
107 }
108 }
109}
110
111impl std::fmt::Display for ComposefsCmdline {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" };
114 write!(
115 f,
116 "{}={}{}",
117 COMPOSEFS_CMDLINE, allow_missing_fsverity, self.digest
118 )
119 }
120}
121
122#[derive(Debug, Serialize, Deserialize)]
125pub(crate) struct StagedDeployment {
126 pub(crate) depl_id: String,
128 pub(crate) finalization_locked: bool,
131}
132
133#[derive(Debug, PartialEq)]
134pub(crate) struct BootloaderEntry {
135 pub(crate) fsverity: String,
138 pub(crate) boot_artifact_name: String,
151}
152
153pub(crate) fn composefs_booted() -> Result<Option<&'static ComposefsCmdline>> {
155 static CACHED_DIGEST_VALUE: OnceLock<Option<ComposefsCmdline>> = OnceLock::new();
156 if let Some(v) = CACHED_DIGEST_VALUE.get() {
157 return Ok(v.as_ref());
158 }
159 let cmdline = Cmdline::from_proc()?;
160 let Some(kv) = cmdline.find(COMPOSEFS_CMDLINE) else {
161 return Ok(None);
162 };
163 let Some(v) = kv.value() else { return Ok(None) };
164 let v = ComposefsCmdline::new(v);
165
166 let root_mnt = inspect_filesystem("/".into())?;
168
169 let (verity_from_mount_src, is_transient) =
176 if let Some(v) = root_mnt.source.strip_prefix("composefs:") {
177 (v, false)
178 } else if let Some(v) = root_mnt.source.strip_prefix("transient:composefs=") {
179 (v, true)
180 } else {
181 anyhow::bail!(
182 "Root not mounted using composefs (source: {})",
183 root_mnt.source
184 )
185 };
186
187 let r = if *verity_from_mount_src != *v.digest {
188 CACHED_DIGEST_VALUE.get_or_init(|| {
190 let mut c = ComposefsCmdline::new(verity_from_mount_src);
191 c.is_transient = is_transient;
192 Some(c)
193 })
194 } else {
195 CACHED_DIGEST_VALUE.get_or_init(|| {
196 let mut c = v;
197 c.is_transient = is_transient;
198 Some(c)
199 })
200 };
201
202 Ok(r.as_ref())
203}
204
205pub(crate) fn get_sorted_grub_uki_boot_entries_staged<'a>(
207 boot_dir: &Dir,
208 str: &'a mut String,
209) -> Result<Vec<MenuEntry<'a>>> {
210 get_sorted_grub_uki_boot_entries_helper(boot_dir, str, true)
211}
212
213pub(crate) fn get_sorted_grub_uki_boot_entries<'a>(
215 boot_dir: &Dir,
216 str: &'a mut String,
217) -> Result<Vec<MenuEntry<'a>>> {
218 get_sorted_grub_uki_boot_entries_helper(boot_dir, str, false)
219}
220
221fn get_sorted_grub_uki_boot_entries_helper<'a>(
223 boot_dir: &Dir,
224 str: &'a mut String,
225 staged: bool,
226) -> Result<Vec<MenuEntry<'a>>> {
227 let file = if staged {
228 boot_dir
229 .open_optional(format!("grub2/{USER_CFG_STAGED}"))
231 .with_context(|| format!("Opening {USER_CFG_STAGED}"))?
232 } else {
233 let f = boot_dir
234 .open(format!("grub2/{USER_CFG}"))
235 .with_context(|| format!("Opening {USER_CFG}"))?;
236
237 Some(f)
238 };
239
240 let Some(mut file) = file else {
241 return Ok(Vec::new());
242 };
243
244 file.read_to_string(str)?;
245 parse_grub_menuentry_file(str)
246}
247
248pub(crate) fn get_sorted_type1_boot_entries(
253 boot_dir: &Dir,
254 ascending: bool,
255) -> Result<Vec<BLSConfig>> {
256 let bootloader = get_bootloader()?;
257 get_sorted_type1_boot_entries_helper(boot_dir, ascending, false, bootloader)
258}
259
260pub(crate) fn get_sorted_staged_type1_boot_entries(
263 boot_dir: &Dir,
264 ascending: bool,
265) -> Result<Vec<BLSConfig>> {
266 let bootloader = get_bootloader()?;
267 get_sorted_type1_boot_entries_helper(boot_dir, ascending, true, bootloader)
268}
269
270#[context("Getting sorted Type1 boot entries")]
271fn get_sorted_type1_boot_entries_helper(
272 boot_dir: &Dir,
273 ascending: bool,
274 get_staged_entries: bool,
275 bootloader: crate::spec::Bootloader,
276) -> Result<Vec<BLSConfig>> {
277 #[derive(Debug)]
278 struct ConfigWithFilename {
279 config: BLSConfig,
280 filename: String,
281 }
282
283 let dir = match get_staged_entries {
284 true => {
285 let dir = boot_dir.open_dir_optional(TYPE1_ENT_PATH_STAGED)?;
286
287 let Some(dir) = dir else {
288 return Ok(vec![]);
289 };
290
291 dir.read_dir(".")?
292 }
293
294 false => boot_dir.read_dir(TYPE1_ENT_PATH)?,
295 };
296
297 let mut configs_with_filenames = vec![];
298
299 for entry in dir {
300 let entry = entry?;
301
302 let file_name = entry.file_name();
303
304 let file_name = file_name
305 .to_str()
306 .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?;
307
308 if !file_name.ends_with(".conf") {
309 continue;
310 }
311
312 let mut file = entry
313 .open()
314 .with_context(|| format!("Failed to open {:?}", file_name))?;
315
316 let mut contents = String::new();
317 file.read_to_string(&mut contents)
318 .with_context(|| format!("Failed to read {:?}", file_name))?;
319
320 let config = parse_bls_config(&contents).context("Parsing bls config")?;
321
322 configs_with_filenames.push(ConfigWithFilename {
323 config,
324 filename: file_name.to_string(),
325 });
326 }
327
328 configs_with_filenames.sort_by(|a, b| {
330 let ord = match bootloader {
331 Bootloader::Systemd => a.config.cmp(&b.config),
333 Bootloader::Grub | Bootloader::GrubCC => b.filename.cmp(&a.filename),
336 Bootloader::None => {
337 unreachable!("Bootloader checked during installation should not have been none")
338 }
339 };
340
341 if ascending { ord } else { ord.reverse() }
342 });
343
344 Ok(configs_with_filenames
345 .into_iter()
346 .map(|c| c.config)
347 .collect())
348}
349
350pub(crate) fn list_type1_entries(boot_dir: &Dir) -> Result<Vec<BootloaderEntry>> {
351 let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?;
353
354 let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?;
357
358 boot_entries
359 .into_iter()
360 .chain(staged_boot_entries)
361 .map(|entry| {
362 Ok(BootloaderEntry {
363 fsverity: entry.get_verity()?,
364 boot_artifact_name: entry.boot_artifact_name()?.to_string(),
365 })
366 })
367 .collect::<Result<Vec<_>, _>>()
368}
369
370#[fn_error_context::context("Listing bootloader entries")]
375pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result<Vec<BootloaderEntry>> {
376 let bootloader = get_bootloader()?;
377 let boot_dir = storage.require_boot_dir()?;
378
379 let entries = match bootloader.kind()? {
380 BootloaderKind::GRUBClassic => {
381 let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?;
383
384 if grub_dir.exists(USER_CFG) {
386 let mut s = String::new();
387 let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?;
388
389 let mut staged = String::new();
390 let boot_entries_staged =
391 get_sorted_grub_uki_boot_entries_staged(boot_dir, &mut staged)?;
392
393 boot_entries
394 .into_iter()
395 .chain(boot_entries_staged)
396 .map(|entry| {
397 Ok(BootloaderEntry {
398 fsverity: entry.get_verity()?,
399 boot_artifact_name: entry.boot_artifact_name()?,
400 })
401 })
402 .collect::<Result<Vec<_>, anyhow::Error>>()?
403 } else {
404 list_type1_entries(boot_dir)?
405 }
406 }
407
408 BootloaderKind::BLSCompatible => list_type1_entries(boot_dir)?,
409 };
410
411 Ok(entries)
412}
413
414#[context("Getting container info")]
416pub(crate) async fn get_container_manifest_and_config(
417 imgref: &ImageReference,
418) -> Result<ImgConfigManifest> {
419 let mut config = crate::deploy::new_proxy_config();
420
421 ostree_ext::container::apply_container_proxy_opts_for_transport(&mut config, imgref.transport)?;
422
423 let proxy = ImageProxy::new_with_config(config).await?;
424
425 let img = proxy
426 .open_image_ref(&imgref)
427 .await
428 .with_context(|| format!("Opening image {imgref}"))?;
429
430 let (_, manifest) = proxy.fetch_manifest(&img).await?;
431 let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?;
432
433 let mut buf = Vec::with_capacity(manifest.config().size() as usize);
434 buf.resize(manifest.config().size() as usize, 0);
435 reader.read_exact(&mut buf).await?;
436 driver.await?;
437
438 let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?;
439
440 Ok(ImgConfigManifest { manifest, config })
441}
442
443#[context("Getting bootloader")]
444pub(crate) fn get_bootloader() -> Result<Bootloader> {
445 static BOOTLOADER: OnceLock<Bootloader> = OnceLock::new();
446
447 if let Some(bootloader) = BOOTLOADER.get() {
448 return Ok(*bootloader);
449 }
450
451 let bootloader = match read_uefi_var(EFI_LOADER_INFO) {
452 Ok(loader) => {
453 if loader.to_lowercase().contains("systemd-boot") {
454 return Ok(Bootloader::Systemd);
455 }
456
457 if loader.to_lowercase().contains("grub cc") {
458 return Ok(Bootloader::GrubCC);
459 }
460
461 return Ok(Bootloader::Grub);
462 }
463
464 Err(efi_error) => match efi_error {
465 EfiError::SystemNotUEFI | EfiError::MissingVar => Bootloader::Grub,
466 e => anyhow::bail!("Failed to read EfiLoaderInfo: {e:?}"),
467 },
468 };
469
470 BOOTLOADER.get_or_init(|| bootloader);
471
472 return Ok(bootloader);
473}
474
475#[context("Reading image info for deployment {deployment_id}")]
484pub(crate) fn get_imginfo(
485 storage: &Storage,
486 deployment_id: &str,
487) -> Result<(ImgConfigManifest, String)> {
488 let ini = read_origin(&storage.physical_root, deployment_id)?
489 .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {deployment_id}"))?;
490
491 if let Some(manifest_digest_str) =
493 ini.get::<String>(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST)
494 {
495 let repo = storage.get_ensure_composefs()?;
496 let manifest_digest: composefs_oci::OciDigest = manifest_digest_str
497 .parse()
498 .with_context(|| format!("Parsing manifest digest {manifest_digest_str}"))?;
499 let oci_image = OciImage::<Sha512HashValue>::open(&repo, &manifest_digest, None)
500 .with_context(|| format!("Opening OCI image for manifest {manifest_digest}"))?;
501
502 let manifest = oci_image.manifest().clone();
503 let config = oci_image
504 .config()
505 .cloned()
506 .ok_or_else(|| anyhow::anyhow!("OCI image has no config (artifact?)"))?;
507
508 return Ok((ImgConfigManifest { config, manifest }, manifest_digest_str));
509 }
510
511 let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id);
514 let imginfo_fname = format!("{deployment_id}.imginfo");
515 let path = depl_state_path.join(&imginfo_fname);
516
517 let mut img_conf = storage
518 .physical_root
519 .open_optional(&path)
520 .with_context(|| format!("Opening legacy {imginfo_fname}"))?;
521
522 let Some(img_conf) = &mut img_conf else {
523 anyhow::bail!(
524 "No manifest_digest in origin and no legacy .imginfo file \
525 for deployment {deployment_id}"
526 );
527 };
528
529 let mut buffer = String::new();
530 img_conf.read_to_string(&mut buffer)?;
531
532 let img_conf = serde_json::from_str::<ImgConfigManifest>(&buffer)
533 .context("Failed to parse .imginfo file as JSON")?;
534
535 let mut hasher = Sha256::new();
537 hasher.update(&serde_json::to_vec(&img_conf.manifest).context("Serializing image manifest")?);
538 let manifest_digest_str = hex::encode(hasher.finish());
539 Ok((img_conf, manifest_digest_str))
540}
541
542#[context("Getting composefs deployment metadata")]
543fn boot_entry_from_composefs_deployment(
544 storage: &Storage,
545 origin: tini::Ini,
546 verity: &str,
547 missing_verity_allowed: bool,
548) -> Result<BootEntry> {
549 let image = match origin.get::<String>("origin", ORIGIN_CONTAINER) {
550 Some(img_name_from_config) => {
551 let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?;
552 let img_ref = crate::spec::ImageReference::from(ostree_img_ref);
553
554 let (img_conf, image_digest) = get_imginfo(storage, &verity)?;
555
556 let architecture = img_conf.config.architecture().to_string();
557 let version = img_conf
558 .manifest
559 .annotations()
560 .as_ref()
561 .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned());
562
563 let created_at = img_conf.config.created().clone();
564 let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x));
565
566 Some(ImageStatus {
567 image: img_ref,
568 version,
569 timestamp,
570 image_digest,
571 architecture,
572 })
573 }
574
575 None => None,
577 };
578
579 let boot_type = match origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) {
580 Some(s) => BootType::try_from(s.as_str())?,
581 None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"),
582 };
583
584 let boot_digest = origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST);
585
586 let e = BootEntry {
587 image,
588 cached_update: None,
589 incompatible: false,
590 pinned: false,
591 download_only: false, store: None,
593 ostree: None,
594 composefs: Some(crate::spec::BootEntryComposefs {
595 verity: verity.into(),
596 boot_type,
597 bootloader: get_bootloader()?,
598 boot_digest,
599 missing_verity_allowed,
600 }),
601 soft_reboot_capable: false,
602 };
603
604 Ok(e)
605}
606
607#[context("Getting composefs deployment status")]
610pub(crate) async fn get_composefs_status(
611 storage: &crate::store::Storage,
612 booted_cfs: &crate::store::BootedComposefs,
613) -> Result<Host> {
614 composefs_deployment_status_from(&storage, booted_cfs.cmdline).await
615}
616
617#[context("Checking soft reboot capability")]
619fn set_soft_reboot_capability(
620 storage: &Storage,
621 host: &mut Host,
622 bls_entries: Option<Vec<BLSConfig>>,
623 booted_cmdline: &ComposefsCmdline,
624) -> Result<()> {
625 let booted = host.require_composefs_booted()?;
626
627 match booted.boot_type {
628 BootType::Bls => {
629 let mut bls_entries =
630 bls_entries.ok_or_else(|| anyhow::anyhow!("BLS entries not provided"))?;
631
632 let staged_entries =
633 get_sorted_staged_type1_boot_entries(storage.require_boot_dir()?, false)?;
634
635 bls_entries.extend(staged_entries);
638
639 set_reboot_capable_type1_deployments(storage, booted_cmdline, host, bls_entries)
640 }
641
642 BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host),
643 }
644}
645
646fn find_bls_entry<'a>(
647 verity: &str,
648 bls_entries: &'a Vec<BLSConfig>,
649) -> Result<Option<&'a BLSConfig>> {
650 for ent in bls_entries {
651 if ent.get_verity()? == *verity {
652 return Ok(Some(ent));
653 }
654 }
655
656 Ok(None)
657}
658
659fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool {
661 for param in first {
662 if param.key() == COMPOSEFS_CMDLINE.into() {
663 continue;
664 }
665
666 let second_param = second.iter().find(|b| *b == param);
667
668 let Some(found_param) = second_param else {
669 return false;
670 };
671
672 if found_param.value() != param.value() {
673 return false;
674 }
675 }
676
677 return true;
678}
679
680#[context("Setting soft reboot capability for Type1 entries")]
681fn set_reboot_capable_type1_deployments(
682 storage: &Storage,
683 booted_cmdline: &ComposefsCmdline,
684 host: &mut Host,
685 bls_entries: Vec<BLSConfig>,
686) -> Result<()> {
687 let booted = host
688 .status
689 .booted
690 .as_ref()
691 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
692
693 let booted_boot_digest = booted.composefs_boot_digest()?;
694
695 let booted_bls_entry = find_bls_entry(&*booted_cmdline.digest, &bls_entries)?
696 .ok_or_else(|| anyhow::anyhow!("Booted BLS entry not found"))?;
697
698 let booted_full_cmdline = booted_bls_entry.get_cmdline()?;
699
700 let booted_info = DeploymentBootInfo {
701 boot_digest: booted_boot_digest,
702 full_cmdline: booted_full_cmdline,
703 verity: &booted_cmdline.digest,
704 };
705
706 for depl in host
707 .status
708 .staged
709 .iter_mut()
710 .chain(host.status.rollback.iter_mut())
711 .chain(host.status.other_deployments.iter_mut())
712 {
713 let depl_verity = &depl.require_composefs()?.verity;
714
715 let entry = find_bls_entry(&depl_verity, &bls_entries)?
716 .ok_or_else(|| anyhow::anyhow!("Entry not found"))?;
717
718 let depl_cmdline = entry.get_cmdline()?;
719
720 let target_info = DeploymentBootInfo {
721 boot_digest: depl.composefs_boot_digest()?,
722 full_cmdline: depl_cmdline,
723 verity: &depl_verity,
724 };
725
726 depl.soft_reboot_capable =
727 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
728 }
729
730 Ok(())
731}
732
733fn is_soft_rebootable(
743 storage: &Storage,
744 booted_cmdline: &ComposefsCmdline,
745 booted: &DeploymentBootInfo,
746 target: &DeploymentBootInfo,
747) -> Result<bool> {
748 if target.boot_digest != booted.boot_digest {
749 tracing::debug!("Soft reboot not allowed due to kernel skew");
750 return Ok(false);
751 }
752
753 if target.full_cmdline.as_bytes().len() != booted.full_cmdline.as_bytes().len() {
754 tracing::debug!("Soft reboot not allowed due to differing cmdline");
755 return Ok(false);
756 }
757
758 let cmdline_eq = compare_cmdline_skip_cfs(target.full_cmdline, booted.full_cmdline)
759 && compare_cmdline_skip_cfs(booted.full_cmdline, target.full_cmdline);
760
761 let selinux_compatible =
762 are_selinux_policies_compatible(storage, booted_cmdline, target.verity)?;
763
764 return Ok(cmdline_eq && selinux_compatible);
765}
766
767#[context("Setting soft reboot capability for UKI deployments")]
768fn set_reboot_capable_uki_deployments(
769 storage: &Storage,
770 booted_cmdline: &ComposefsCmdline,
771 host: &mut Host,
772) -> Result<()> {
773 let booted = host
774 .status
775 .booted
776 .as_ref()
777 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
778
779 let booted_boot_digest = match booted.composefs_boot_digest() {
781 Ok(d) => d,
782 Err(_) => &compute_store_boot_digest_for_uki(storage, &booted_cmdline.digest)?,
783 };
784
785 let booted_full_cmdline = get_uki_cmdline(storage, &booted_cmdline.digest)?;
786
787 let booted_info = DeploymentBootInfo {
788 boot_digest: booted_boot_digest,
789 full_cmdline: &booted_full_cmdline,
790 verity: &booted_cmdline.digest,
791 };
792
793 for deployment in host
794 .status
795 .staged
796 .iter_mut()
797 .chain(host.status.rollback.iter_mut())
798 .chain(host.status.other_deployments.iter_mut())
799 {
800 let depl_verity = &deployment.require_composefs()?.verity;
801
802 let depl_boot_digest = match deployment.composefs_boot_digest() {
804 Ok(d) => d,
805 Err(_) => &compute_store_boot_digest_for_uki(storage, depl_verity)?,
806 };
807
808 let depl_cmdline = get_uki_cmdline(storage, &deployment.require_composefs()?.verity)?;
809
810 let target_info = DeploymentBootInfo {
811 boot_digest: depl_boot_digest,
812 full_cmdline: &depl_cmdline,
813 verity: depl_verity,
814 };
815
816 deployment.soft_reboot_capable =
817 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
818 }
819
820 Ok(())
821}
822
823#[context("Getting composefs deployment status")]
824async fn composefs_deployment_status_from(
825 storage: &Storage,
826 cmdline: &ComposefsCmdline,
827) -> Result<Host> {
828 let booted_composefs_digest = &cmdline.digest;
829
830 let boot_dir = storage.require_boot_dir()?;
831
832 let bootloader_entry_verity = list_bootloader_entries(storage)?;
834
835 let host_spec = HostSpec {
836 image: None,
837 boot_order: BootOrder::Default,
838 };
839
840 let mut host = Host::new(host_spec);
841
842 let staged_deployment = match std::fs::File::open(format!(
843 "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"
844 )) {
845 Ok(mut f) => {
846 let mut s = String::new();
847 f.read_to_string(&mut s)?;
848
849 Ok(Some(s))
850 }
851 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
852 Err(e) => Err(e),
853 }?;
854
855 let mut boot_type: Option<BootType> = None;
856
857 let mut extra_deployment_boot_entries: Vec<BootEntry> = Vec::new();
860
861 for BootloaderEntry {
862 fsverity: verity_digest,
863 ..
864 } in bootloader_entry_verity
865 {
866 let ini = read_origin(&storage.physical_root, &verity_digest)?;
867
868 let Some(ini) = ini else {
869 const STATUS_JOURNAL_ID: &str = "d264f924dadb4c31bff0412107d391fb";
870
871 tracing::warn!(
872 message_id = STATUS_JOURNAL_ID,
873 bootc.operation = "status",
874 "No origin file for deployment {verity_digest}"
875 );
876
877 continue;
878 };
879
880 let mut boot_entry = boot_entry_from_composefs_deployment(
881 storage,
882 ini,
883 &verity_digest,
884 cmdline.allow_missing_fsverity,
885 )?;
886
887 let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type;
889
890 match boot_type {
891 Some(current_type) => {
892 if current_type != boot_type_from_origin {
893 anyhow::bail!("Conflicting boot types")
894 }
895 }
896
897 None => {
898 boot_type = Some(boot_type_from_origin);
899 }
900 };
901
902 if verity_digest == booted_composefs_digest.as_ref() {
903 host.status.booted = Some(boot_entry);
904 continue;
905 }
906
907 if let Some(staged_deployment) = &staged_deployment {
908 let staged_depl = serde_json::from_str::<StagedDeployment>(&staged_deployment)?;
909
910 if verity_digest == staged_depl.depl_id {
911 boot_entry.download_only = staged_depl.finalization_locked;
912 host.status.staged = Some(boot_entry);
913 continue;
914 }
915 }
916
917 extra_deployment_boot_entries.push(boot_entry);
918 }
919
920 host.spec.image = host
922 .status
923 .staged
924 .as_ref()
925 .or(host.status.booted.as_ref())
926 .and_then(|entry| entry.image.as_ref())
927 .map(|img| img.image.clone());
928
929 let Some(boot_type) = boot_type else {
931 anyhow::bail!("Could not determine boot type");
932 };
933
934 let booted_cfs = host.require_composefs_booted()?;
935
936 let mut grub_menu_string = String::new();
937 let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = match booted_cfs
938 .bootloader
939 .kind()?
940 {
941 BootloaderKind::GRUBClassic => match boot_type {
942 BootType::Bls => {
943 let bls_configs = get_sorted_type1_boot_entries(boot_dir, false)?;
944 let bls_config = bls_configs
945 .first()
946 .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?;
947
948 match &bls_config.cfg_type {
949 BLSConfigType::NonEFI { options, .. } => {
950 let is_rollback_queued = !options
951 .as_ref()
952 .ok_or_else(|| anyhow::anyhow!("options key not found in bls config"))?
953 .contains(booted_composefs_digest.as_ref());
954
955 (is_rollback_queued, Some(bls_configs), None)
956 }
957
958 BLSConfigType::EFI { .. } => {
959 anyhow::bail!("Found 'efi' field in Type1 boot entry")
960 }
961
962 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
963 }
964 }
965
966 BootType::Uki => {
967 let menuentries =
968 get_sorted_grub_uki_boot_entries(boot_dir, &mut grub_menu_string)?;
969
970 let is_rollback_queued = !menuentries
971 .first()
972 .ok_or(anyhow::anyhow!("First boot entry not found"))?
973 .body
974 .chainloader
975 .contains(booted_composefs_digest.as_ref());
976
977 (is_rollback_queued, None, Some(menuentries))
978 }
979 },
980
981 BootloaderKind::BLSCompatible => {
983 let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?;
984 let bls_config = bls_configs
985 .first()
986 .ok_or(anyhow::anyhow!("First boot entry not found"))?;
987
988 let is_rollback_queued = match &bls_config.cfg_type {
989 BLSConfigType::EFI { key } => {
991 let path = match key {
992 EFIKey::Efi(path) | EFIKey::Uki(path) => path,
993 };
994 path.as_str().contains(booted_composefs_digest.as_ref())
995 }
996
997 BLSConfigType::NonEFI { options, .. } => !options
999 .as_ref()
1000 .ok_or(anyhow::anyhow!("options key not found in bls config"))?
1001 .contains(booted_composefs_digest.as_ref()),
1002
1003 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
1004 };
1005
1006 (is_rollback_queued, Some(bls_configs), None)
1007 }
1008 };
1009
1010 let bootloader_configured_verity = sorted_bls_config
1015 .iter()
1016 .flatten()
1017 .map(|cfg| cfg.get_verity())
1018 .chain(
1019 grub_menu_entries
1020 .iter()
1021 .flatten()
1022 .map(|menu| menu.get_verity()),
1023 )
1024 .collect::<Result<Vec<_>>>()?;
1025
1026 let mut rollback_candidates: Vec<_> = extra_deployment_boot_entries
1027 .into_iter()
1028 .filter(|entry| {
1029 let verity = &entry
1030 .composefs
1031 .as_ref()
1032 .expect("composefs is always Some for composefs deployments")
1033 .verity;
1034 bootloader_configured_verity.contains(verity)
1035 })
1036 .collect();
1037
1038 rollback_candidates.sort_by_key(|ent| {
1043 bootloader_configured_verity
1044 .iter()
1045 .position(|v| ent.composefs.as_ref().unwrap().verity == *v)
1047 });
1048
1049 if !rollback_candidates.is_empty() {
1050 let mut iter = rollback_candidates.into_iter();
1051
1052 host.status.rollback = iter.next();
1053 host.status.other_deployments = iter.collect();
1054 }
1055
1056 host.status.rollback_queued = is_rollback_queued;
1057
1058 if host.status.rollback_queued {
1059 host.spec.boot_order = BootOrder::Rollback
1060 };
1061
1062 host.status.usr_overlay = get_composefs_usr_overlay_status().ok().flatten();
1063
1064 set_soft_reboot_capability(storage, &mut host, sorted_bls_config, cmdline)?;
1065
1066 Ok(host)
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
1072
1073 use crate::bootc_composefs::boot::{
1074 FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key,
1075 secondary_sort_key, type1_entry_conf_file_name,
1076 };
1077 use crate::parsers::grub_menuconfig::MenuentryBody;
1078
1079 use super::*;
1080
1081 #[test]
1082 fn test_composefs_parsing() {
1083 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
1084 let v = ComposefsCmdline::new(DIGEST);
1085 assert!(!v.allow_missing_fsverity);
1086 assert_eq!(v.digest.as_ref(), DIGEST);
1087 let v = ComposefsCmdline::new(&format!("?{}", DIGEST));
1088 assert!(v.allow_missing_fsverity);
1089 assert_eq!(v.digest.as_ref(), DIGEST);
1090 }
1091
1092 #[test]
1093 fn test_sorted_bls_boot_entries() -> Result<()> {
1094 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1095
1096 let entry1 = r#"
1097 title Fedora 42.20250623.3.1 (CoreOS)
1098 version fedora-42.0
1099 sort-key 1
1100 linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
1101 initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
1102 options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
1103 "#;
1104
1105 let entry2 = r#"
1106 title Fedora 41.20250214.2.0 (CoreOS)
1107 version fedora-42.0
1108 sort-key 2
1109 linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10
1110 initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img
1111 options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01
1112 "#;
1113
1114 tempdir.create_dir_all("loader/entries")?;
1115 tempdir.atomic_write(
1116 "loader/entries/random_file.txt",
1117 "Random file that we won't parse",
1118 )?;
1119 tempdir.atomic_write("loader/entries/entry1.conf", entry1)?;
1120 tempdir.atomic_write("loader/entries/entry2.conf", entry2)?;
1121
1122 let result =
1123 get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)
1124 .unwrap();
1125
1126 assert_eq!(result[0].sort_key.as_ref().unwrap(), "1");
1127 assert_eq!(result[1].sort_key.as_ref().unwrap(), "2");
1128
1129 let result =
1130 get_sorted_type1_boot_entries_helper(&tempdir, false, false, Bootloader::Systemd)
1131 .unwrap();
1132 assert_eq!(result[0].sort_key.as_ref().unwrap(), "2");
1133 assert_eq!(result[1].sort_key.as_ref().unwrap(), "1");
1134
1135 Ok(())
1136 }
1137
1138 #[test]
1139 fn test_sorted_uki_boot_entries() -> Result<()> {
1140 let user_cfg = r#"
1141 if [ -f ${config_directory}/efiuuid.cfg ]; then
1142 source ${config_directory}/efiuuid.cfg
1143 fi
1144
1145 menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" {
1146 insmod fat
1147 insmod chain
1148 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
1149 chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi
1150 }
1151
1152 menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" {
1153 insmod fat
1154 insmod chain
1155 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
1156 chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi
1157 }
1158 "#;
1159
1160 let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1161 bootdir.create_dir_all(format!("grub2"))?;
1162 bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?;
1163
1164 let mut s = String::new();
1165 let result = get_sorted_grub_uki_boot_entries(&bootdir, &mut s)?;
1166
1167 let expected = vec![
1168 MenuEntry {
1169 title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(),
1170 body: MenuentryBody {
1171 insmod: vec!["fat", "chain"],
1172 chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(),
1173 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
1174 version: 0,
1175 extra: vec![],
1176 },
1177 },
1178 MenuEntry {
1179 title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(),
1180 body: MenuentryBody {
1181 insmod: vec!["fat", "chain"],
1182 chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(),
1183 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
1184 version: 0,
1185 extra: vec![],
1186 },
1187 },
1188 ];
1189
1190 assert_eq!(result, expected);
1191
1192 Ok(())
1193 }
1194
1195 #[test]
1196 fn test_find_in_cmdline() {
1197 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
1198
1199 let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs={}", DIGEST));
1201 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1202 assert!(result.is_some());
1203 let cfs = result.unwrap();
1204 assert_eq!(cfs.digest.as_ref(), DIGEST);
1205 assert!(!cfs.allow_missing_fsverity);
1206
1207 let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs=?{}", DIGEST));
1209 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1210 assert!(result.is_some());
1211 let cfs = result.unwrap();
1212 assert_eq!(cfs.digest.as_ref(), DIGEST);
1213 assert!(cfs.allow_missing_fsverity);
1214
1215 let cmdline = Cmdline::from("root=UUID=abc123 rw quiet");
1217 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1218 assert!(result.is_none());
1219
1220 let cmdline = Cmdline::from("");
1222 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1223 assert!(result.is_none());
1224
1225 let cmdline = Cmdline::from(format!("quiet composefs={} loglevel=3", DIGEST));
1227 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1228 assert!(result.is_some());
1229 let cfs = result.unwrap();
1230 assert_eq!(cfs.digest.as_ref(), DIGEST);
1231 assert!(!cfs.allow_missing_fsverity);
1232
1233 let cmdline = Cmdline::from(format!("composefs=?{} root=UUID=abc123 quiet", DIGEST));
1235 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1236 assert!(result.is_some());
1237 let cfs = result.unwrap();
1238 assert_eq!(cfs.digest.as_ref(), DIGEST);
1239 assert!(cfs.allow_missing_fsverity);
1240
1241 let cmdline = Cmdline::from(format!("composefs_backup={} root=UUID=abc123", DIGEST));
1243 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1244 assert!(result.is_none());
1245 }
1246
1247 use crate::testutils::fake_digest_version;
1248
1249 #[test]
1252 fn test_list_type1_entries_includes_staged() -> Result<()> {
1253 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1254
1255 let digest_active = fake_digest_version(0);
1256 let digest_staged = fake_digest_version(1);
1257
1258 let active_entry = format!(
1259 r#"
1260 title Active Deployment
1261 version 2
1262 sort-key 1
1263 linux /boot/bootc_composefs-{digest_active}/vmlinuz
1264 initrd /boot/bootc_composefs-{digest_active}/initramfs.img
1265 options root=UUID=abc123 rw composefs={digest_active}
1266 "#
1267 );
1268
1269 let staged_entry = format!(
1270 r#"
1271 title Staged Deployment
1272 version 3
1273 sort-key 0
1274 linux /boot/bootc_composefs-{digest_staged}/vmlinuz
1275 initrd /boot/bootc_composefs-{digest_staged}/initramfs.img
1276 options root=UUID=abc123 rw composefs={digest_staged}
1277 "#
1278 );
1279
1280 tempdir.create_dir_all("loader/entries")?;
1281 tempdir.create_dir_all("loader/entries.staged")?;
1282 tempdir.atomic_write("loader/entries/active.conf", active_entry)?;
1283 tempdir.atomic_write("loader/entries.staged/staged.conf", staged_entry)?;
1284
1285 let result = list_type1_entries(&tempdir)?;
1286 assert_eq!(result.len(), 2);
1287
1288 let verity_set: std::collections::HashSet<&str> =
1289 result.iter().map(|e| e.fsverity.as_str()).collect();
1290 assert!(
1291 verity_set.contains(digest_active.as_str()),
1292 "Should contain active entry"
1293 );
1294 assert!(
1295 verity_set.contains(digest_staged.as_str()),
1296 "Should contain staged entry"
1297 );
1298
1299 Ok(())
1300 }
1301
1302 #[test]
1303 fn test_get_sorted_type1_boot_entries_helper_systemd() -> Result<()> {
1304 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1305
1306 let entry1 = format!(
1308 r#"
1309 title Fedora Linux (1.0.0)
1310 version 1.0.0
1311 sort-key {}
1312 linux /boot/vmlinuz
1313 initrd /boot/initramfs.img
1314 "#,
1315 secondary_sort_key("fedora")
1316 );
1317
1318 let entry2 = format!(
1319 r#"
1320 title Fedora Linux (2.0.0)
1321 version 2.0.0
1322 sort-key {}
1323 linux /boot/vmlinuz
1324 initrd /boot/initramfs.img
1325 "#,
1326 primary_sort_key("fedora")
1327 );
1328
1329 let entry3 = format!(
1330 r#"
1331 title Fedora Linux (1.5.0)
1332 version 1.5.0
1333 sort-key {}
1334 linux /boot/vmlinuz
1335 initrd /boot/initramfs.img
1336 "#,
1337 primary_sort_key("fedora")
1338 );
1339
1340 tempdir.create_dir_all("loader/entries")?;
1341
1342 let filename1 = type1_entry_conf_file_name("fedora", "1.0.0", FILENAME_PRIORITY_SECONDARY);
1344 let filename2 = type1_entry_conf_file_name("fedora", "2.0.0", FILENAME_PRIORITY_PRIMARY);
1345 let filename3 = type1_entry_conf_file_name("fedora", "1.5.0", FILENAME_PRIORITY_PRIMARY);
1346
1347 tempdir.atomic_write(format!("loader/entries/{}", filename1), entry1)?;
1348 tempdir.atomic_write(format!("loader/entries/{}", filename2), entry2)?;
1349 tempdir.atomic_write(format!("loader/entries/{}", filename3), entry3)?;
1350
1351 let result = get_sorted_type1_boot_entries_helper(
1353 &tempdir,
1354 true,
1355 false,
1356 crate::spec::Bootloader::Systemd,
1357 )?;
1358
1359 assert_eq!(result.len(), 3);
1360 assert_eq!(
1363 result[0].sort_key.as_ref().unwrap(),
1364 &primary_sort_key("fedora")
1365 );
1366 assert_eq!(result[0].version(), "2.0.0".into()); assert_eq!(
1368 result[1].sort_key.as_ref().unwrap(),
1369 &primary_sort_key("fedora")
1370 );
1371 assert_eq!(result[1].version(), "1.5.0".into()); assert_eq!(
1373 result[2].sort_key.as_ref().unwrap(),
1374 &secondary_sort_key("fedora")
1375 );
1376 assert_eq!(result[2].version(), "1.0.0".into()); let result = get_sorted_type1_boot_entries_helper(
1380 &tempdir,
1381 false,
1382 false,
1383 crate::spec::Bootloader::Systemd,
1384 )?;
1385
1386 assert_eq!(result.len(), 3);
1387 assert_eq!(
1389 result[0].sort_key.as_ref().unwrap(),
1390 &secondary_sort_key("fedora")
1391 );
1392 assert_eq!(result[0].version(), "1.0.0".into()); assert_eq!(
1394 result[1].sort_key.as_ref().unwrap(),
1395 &primary_sort_key("fedora")
1396 );
1397 assert_eq!(result[1].version(), "1.5.0".into()); assert_eq!(
1399 result[2].sort_key.as_ref().unwrap(),
1400 &primary_sort_key("fedora")
1401 );
1402 assert_eq!(result[2].version(), "2.0.0".into()); Ok(())
1405 }
1406
1407 #[test]
1408 fn test_get_sorted_type1_boot_entries_helper_grub() -> Result<()> {
1409 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1410
1411 let entry1 = format!(
1413 r#"
1414 title Fedora Linux (41.20251125.0)
1415 version 41.20251125.0
1416 sort-key {}
1417 linux /boot/vmlinuz
1418 initrd /boot/initramfs.img
1419 "#,
1420 secondary_sort_key("fedora")
1421 );
1422
1423 let entry2 = format!(
1424 r#"
1425 title Fedora Linux (42.20251125.0)
1426 version 42.20251125.0
1427 sort-key {}
1428 linux /boot/vmlinuz
1429 initrd /boot/initramfs.img
1430 "#,
1431 primary_sort_key("fedora")
1432 );
1433
1434 tempdir.create_dir_all("loader/entries")?;
1435
1436 let filename1 =
1438 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
1439 let filename2 =
1440 type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
1441
1442 tempdir.atomic_write(format!("loader/entries/{}", filename1), entry1)?;
1443 tempdir.atomic_write(format!("loader/entries/{}", filename2), entry2)?;
1444
1445 let result = get_sorted_type1_boot_entries_helper(
1446 &tempdir,
1447 true,
1448 false,
1449 crate::spec::Bootloader::Grub,
1450 )?;
1451
1452 assert_eq!(result.len(), 2);
1453 assert_eq!(result[0].version(), "42.20251125.0".into());
1457 assert_eq!(result[1].version(), "41.20251125.0".into());
1458
1459 let result = get_sorted_type1_boot_entries_helper(
1461 &tempdir,
1462 false,
1463 false,
1464 crate::spec::Bootloader::Grub,
1465 )?;
1466
1467 assert_eq!(result.len(), 2);
1468 assert_eq!(result[0].version(), "41.20251125.0".into());
1471 assert_eq!(result[1].version(), "42.20251125.0".into());
1472
1473 Ok(())
1474 }
1475}