1use std::{collections::HashSet, io::Read, sync::OnceLock};
2
3use anyhow::{Context, Result};
4use bootc_kernel_cmdline::utf8::Cmdline;
5use bootc_mount::inspect_filesystem;
6use fn_error_context::context;
7use serde::{Deserialize, Serialize};
8
9use crate::{
10 bootc_composefs::{
11 boot::BootType,
12 repo::get_imgref,
13 selinux::are_selinux_policies_compatible,
14 utils::{compute_store_boot_digest_for_uki, get_uki_cmdline},
15 },
16 composefs_consts::{
17 COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG,
18 },
19 install::EFI_LOADER_INFO,
20 parsers::{
21 bls_config::{BLSConfig, BLSConfigType, parse_bls_config},
22 grub_menuconfig::{MenuEntry, parse_grub_menuentry_file},
23 },
24 spec::{BootEntry, BootOrder, Host, HostSpec, ImageReference, ImageStatus},
25 store::Storage,
26 utils::{EfiError, read_uefi_var},
27};
28
29use std::str::FromStr;
30
31use bootc_utils::try_deserialize_timestamp;
32use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
33use ostree_container::OstreeImageReference;
34use ostree_ext::container::{self as ostree_container};
35use ostree_ext::containers_image_proxy;
36use ostree_ext::oci_spec;
37use ostree_ext::{container::deploy::ORIGIN_CONTAINER, oci_spec::image::ImageConfiguration};
38
39use ostree_ext::oci_spec::image::ImageManifest;
40use tokio::io::AsyncReadExt;
41
42use crate::composefs_consts::{
43 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT,
44 ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE,
45};
46use crate::spec::Bootloader;
47
48#[derive(Debug, Serialize, Deserialize)]
50pub(crate) struct ImgConfigManifest {
51 pub(crate) config: ImageConfiguration,
52 pub(crate) manifest: ImageManifest,
53}
54
55#[derive(Clone)]
57pub(crate) struct ComposefsCmdline {
58 #[allow(dead_code)]
59 pub insecure: bool,
60 pub digest: Box<str>,
61}
62
63struct DeploymentBootInfo<'a> {
65 boot_digest: &'a str,
66 full_cmdline: &'a Cmdline<'a>,
67 verity: &'a str,
68}
69
70impl ComposefsCmdline {
71 pub(crate) fn new(s: &str) -> Self {
72 let (insecure, digest_str) = s
73 .strip_prefix('?')
74 .map(|v| (true, v))
75 .unwrap_or_else(|| (false, s));
76 ComposefsCmdline {
77 insecure,
78 digest: digest_str.into(),
79 }
80 }
81}
82
83impl std::fmt::Display for ComposefsCmdline {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 let insecure = if self.insecure { "?" } else { "" };
86 write!(f, "{}={}{}", COMPOSEFS_CMDLINE, insecure, self.digest)
87 }
88}
89
90#[derive(Debug, Serialize, Deserialize)]
93pub(crate) struct StagedDeployment {
94 pub(crate) depl_id: String,
96 pub(crate) finalization_locked: bool,
99}
100
101pub(crate) fn composefs_booted() -> Result<Option<&'static ComposefsCmdline>> {
103 static CACHED_DIGEST_VALUE: OnceLock<Option<ComposefsCmdline>> = OnceLock::new();
104 if let Some(v) = CACHED_DIGEST_VALUE.get() {
105 return Ok(v.as_ref());
106 }
107 let cmdline = Cmdline::from_proc()?;
108 let Some(kv) = cmdline.find(COMPOSEFS_CMDLINE) else {
109 return Ok(None);
110 };
111 let Some(v) = kv.value() else { return Ok(None) };
112 let v = ComposefsCmdline::new(v);
113
114 let root_mnt = inspect_filesystem("/".into())?;
116
117 let verity_from_mount_src = root_mnt
119 .source
120 .strip_prefix("composefs:")
121 .ok_or_else(|| anyhow::anyhow!("Root not mounted using composefs"))?;
122
123 let r = if *verity_from_mount_src != *v.digest {
124 CACHED_DIGEST_VALUE.get_or_init(|| Some(ComposefsCmdline::new(verity_from_mount_src)))
126 } else {
127 CACHED_DIGEST_VALUE.get_or_init(|| Some(v))
128 };
129
130 Ok(r.as_ref())
131}
132
133pub(crate) fn get_sorted_grub_uki_boot_entries<'a>(
135 boot_dir: &Dir,
136 str: &'a mut String,
137) -> Result<Vec<MenuEntry<'a>>> {
138 let mut file = boot_dir
139 .open(format!("grub2/{USER_CFG}"))
140 .with_context(|| format!("Opening {USER_CFG}"))?;
141 file.read_to_string(str)?;
142 parse_grub_menuentry_file(str)
143}
144
145pub(crate) fn get_sorted_type1_boot_entries(
146 boot_dir: &Dir,
147 ascending: bool,
148) -> Result<Vec<BLSConfig>> {
149 get_sorted_type1_boot_entries_helper(boot_dir, ascending, false)
150}
151
152pub(crate) fn get_sorted_staged_type1_boot_entries(
153 boot_dir: &Dir,
154 ascending: bool,
155) -> Result<Vec<BLSConfig>> {
156 get_sorted_type1_boot_entries_helper(boot_dir, ascending, true)
157}
158
159#[context("Getting sorted Type1 boot entries")]
160fn get_sorted_type1_boot_entries_helper(
161 boot_dir: &Dir,
162 ascending: bool,
163 get_staged_entries: bool,
164) -> Result<Vec<BLSConfig>> {
165 let mut all_configs = vec![];
166
167 let dir = match get_staged_entries {
168 true => {
169 let dir = boot_dir.open_dir_optional(TYPE1_ENT_PATH_STAGED)?;
170
171 let Some(dir) = dir else {
172 return Ok(all_configs);
173 };
174
175 dir.read_dir(".")?
176 }
177
178 false => boot_dir.read_dir(TYPE1_ENT_PATH)?,
179 };
180
181 for entry in dir {
182 let entry = entry?;
183
184 let file_name = entry.file_name();
185
186 let file_name = file_name
187 .to_str()
188 .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?;
189
190 if !file_name.ends_with(".conf") {
191 continue;
192 }
193
194 let mut file = entry
195 .open()
196 .with_context(|| format!("Failed to open {:?}", file_name))?;
197
198 let mut contents = String::new();
199 file.read_to_string(&mut contents)
200 .with_context(|| format!("Failed to read {:?}", file_name))?;
201
202 let config = parse_bls_config(&contents).context("Parsing bls config")?;
203
204 all_configs.push(config);
205 }
206
207 all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) });
208
209 Ok(all_configs)
210}
211
212#[context("Getting container info")]
214pub(crate) async fn get_container_manifest_and_config(
215 imgref: &String,
216) -> Result<ImgConfigManifest> {
217 let config = containers_image_proxy::ImageProxyConfig::default();
218 let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?;
219
220 let img = proxy
221 .open_image(&imgref)
222 .await
223 .with_context(|| format!("Opening image {imgref}"))?;
224
225 let (_, manifest) = proxy.fetch_manifest(&img).await?;
226 let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?;
227
228 let mut buf = Vec::with_capacity(manifest.config().size() as usize);
229 buf.resize(manifest.config().size() as usize, 0);
230 reader.read_exact(&mut buf).await?;
231 driver.await?;
232
233 let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?;
234
235 Ok(ImgConfigManifest { manifest, config })
236}
237
238#[context("Getting bootloader")]
239pub(crate) fn get_bootloader() -> Result<Bootloader> {
240 match read_uefi_var(EFI_LOADER_INFO) {
241 Ok(loader) => {
242 if loader.to_lowercase().contains("systemd-boot") {
243 return Ok(Bootloader::Systemd);
244 }
245
246 return Ok(Bootloader::Grub);
247 }
248
249 Err(efi_error) => match efi_error {
250 EfiError::SystemNotUEFI => return Ok(Bootloader::Grub),
251 EfiError::MissingVar => return Ok(Bootloader::Grub),
252
253 e => return Err(anyhow::anyhow!("Failed to read EfiLoaderInfo: {e:?}")),
254 },
255 }
256}
257
258#[context("Reading imginfo")]
260pub(crate) async fn get_imginfo(
261 storage: &Storage,
262 deployment_id: &str,
263 imgref: Option<&ImageReference>,
264) -> Result<ImgConfigManifest> {
265 let imginfo_fname = format!("{deployment_id}.imginfo");
266
267 let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id);
268 let path = depl_state_path.join(imginfo_fname);
269
270 let mut img_conf = storage
271 .physical_root
272 .open_optional(&path)
273 .context("Failed to open file")?;
274
275 let Some(img_conf) = &mut img_conf else {
276 let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No imgref or imginfo file found"))?;
277
278 let container_details =
279 get_container_manifest_and_config(&get_imgref(&imgref.transport, &imgref.image))
280 .await?;
281
282 let state_dir = storage.physical_root.open_dir(depl_state_path)?;
283
284 state_dir
285 .atomic_write(
286 format!("{}.imginfo", deployment_id),
287 serde_json::to_vec(&container_details)?,
288 )
289 .context("Failed to write to .imginfo file")?;
290
291 let state_dir = state_dir.reopen_as_ownedfd()?;
292
293 rustix::fs::fsync(state_dir).context("fsync")?;
294
295 return Ok(container_details);
296 };
297
298 let mut buffer = String::new();
299 img_conf.read_to_string(&mut buffer)?;
300
301 let img_conf = serde_json::from_str::<ImgConfigManifest>(&buffer)
302 .context("Failed to parse file as JSON")?;
303
304 Ok(img_conf)
305}
306
307#[context("Getting composefs deployment metadata")]
308async fn boot_entry_from_composefs_deployment(
309 storage: &Storage,
310 origin: tini::Ini,
311 verity: String,
312) -> Result<BootEntry> {
313 let image = match origin.get::<String>("origin", ORIGIN_CONTAINER) {
314 Some(img_name_from_config) => {
315 let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?;
316 let img_ref = ImageReference::from(ostree_img_ref);
317
318 let img_conf = get_imginfo(storage, &verity, Some(&img_ref)).await?;
319
320 let image_digest = img_conf.manifest.config().digest().to_string();
321 let architecture = img_conf.config.architecture().to_string();
322 let version = img_conf
323 .manifest
324 .annotations()
325 .as_ref()
326 .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned());
327
328 let created_at = img_conf.config.created().clone();
329 let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x));
330
331 Some(ImageStatus {
332 image: img_ref,
333 version,
334 timestamp,
335 image_digest,
336 architecture,
337 })
338 }
339
340 None => None,
342 };
343
344 let boot_type = match origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) {
345 Some(s) => BootType::try_from(s.as_str())?,
346 None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"),
347 };
348
349 let boot_digest = origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST);
350
351 let e = BootEntry {
352 image,
353 cached_update: None,
354 incompatible: false,
355 pinned: false,
356 download_only: false, store: None,
358 ostree: None,
359 composefs: Some(crate::spec::BootEntryComposefs {
360 verity,
361 boot_type,
362 bootloader: get_bootloader()?,
363 boot_digest,
364 }),
365 soft_reboot_capable: false,
366 };
367
368 Ok(e)
369}
370
371#[context("Getting composefs deployment status")]
374pub(crate) async fn get_composefs_status(
375 storage: &crate::store::Storage,
376 booted_cfs: &crate::store::BootedComposefs,
377) -> Result<Host> {
378 composefs_deployment_status_from(&storage, booted_cfs.cmdline).await
379}
380
381#[context("Checking soft reboot capability")]
383fn set_soft_reboot_capability(
384 storage: &Storage,
385 host: &mut Host,
386 bls_entries: Option<Vec<BLSConfig>>,
387 booted_cmdline: &ComposefsCmdline,
388) -> Result<()> {
389 let booted = host.require_composefs_booted()?;
390
391 match booted.boot_type {
392 BootType::Bls => {
393 let mut bls_entries =
394 bls_entries.ok_or_else(|| anyhow::anyhow!("BLS entries not provided"))?;
395
396 let staged_entries =
397 get_sorted_staged_type1_boot_entries(storage.require_boot_dir()?, false)?;
398
399 bls_entries.extend(staged_entries);
402
403 set_reboot_capable_type1_deployments(storage, booted_cmdline, host, bls_entries)
404 }
405
406 BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host),
407 }
408}
409
410fn find_bls_entry<'a>(
411 verity: &str,
412 bls_entries: &'a Vec<BLSConfig>,
413) -> Result<Option<&'a BLSConfig>> {
414 for ent in bls_entries {
415 if ent.get_verity()? == *verity {
416 return Ok(Some(ent));
417 }
418 }
419
420 Ok(None)
421}
422
423fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool {
425 for param in first {
426 if param.key() == COMPOSEFS_CMDLINE.into() {
427 continue;
428 }
429
430 let second_param = second.iter().find(|b| *b == param);
431
432 let Some(found_param) = second_param else {
433 return false;
434 };
435
436 if found_param.value() != param.value() {
437 return false;
438 }
439 }
440
441 return true;
442}
443
444#[context("Setting soft reboot capability for Type1 entries")]
445fn set_reboot_capable_type1_deployments(
446 storage: &Storage,
447 booted_cmdline: &ComposefsCmdline,
448 host: &mut Host,
449 bls_entries: Vec<BLSConfig>,
450) -> Result<()> {
451 let booted = host
452 .status
453 .booted
454 .as_ref()
455 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
456
457 let booted_boot_digest = booted.composefs_boot_digest()?;
458
459 let booted_bls_entry = find_bls_entry(&*booted_cmdline.digest, &bls_entries)?
460 .ok_or_else(|| anyhow::anyhow!("Booted BLS entry not found"))?;
461
462 let booted_full_cmdline = booted_bls_entry.get_cmdline()?;
463
464 let booted_info = DeploymentBootInfo {
465 boot_digest: booted_boot_digest,
466 full_cmdline: booted_full_cmdline,
467 verity: &booted_cmdline.digest,
468 };
469
470 for depl in host
471 .status
472 .staged
473 .iter_mut()
474 .chain(host.status.rollback.iter_mut())
475 .chain(host.status.other_deployments.iter_mut())
476 {
477 let depl_verity = &depl.require_composefs()?.verity;
478
479 let entry = find_bls_entry(&depl_verity, &bls_entries)?
480 .ok_or_else(|| anyhow::anyhow!("Entry not found"))?;
481
482 let depl_cmdline = entry.get_cmdline()?;
483
484 let target_info = DeploymentBootInfo {
485 boot_digest: depl.composefs_boot_digest()?,
486 full_cmdline: depl_cmdline,
487 verity: &depl_verity,
488 };
489
490 depl.soft_reboot_capable =
491 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
492 }
493
494 Ok(())
495}
496
497fn is_soft_rebootable(
507 storage: &Storage,
508 booted_cmdline: &ComposefsCmdline,
509 booted: &DeploymentBootInfo,
510 target: &DeploymentBootInfo,
511) -> Result<bool> {
512 if target.boot_digest != booted.boot_digest {
513 tracing::debug!("Soft reboot not allowed due to kernel skew");
514 return Ok(false);
515 }
516
517 if target.full_cmdline.as_bytes().len() != booted.full_cmdline.as_bytes().len() {
518 tracing::debug!("Soft reboot not allowed due to differing cmdline");
519 return Ok(false);
520 }
521
522 let cmdline_eq = compare_cmdline_skip_cfs(target.full_cmdline, booted.full_cmdline)
523 && compare_cmdline_skip_cfs(booted.full_cmdline, target.full_cmdline);
524
525 let selinux_compatible =
526 are_selinux_policies_compatible(storage, booted_cmdline, target.verity)?;
527
528 return Ok(cmdline_eq && selinux_compatible);
529}
530
531#[context("Setting soft reboot capability for UKI deployments")]
532fn set_reboot_capable_uki_deployments(
533 storage: &Storage,
534 booted_cmdline: &ComposefsCmdline,
535 host: &mut Host,
536) -> Result<()> {
537 let booted = host
538 .status
539 .booted
540 .as_ref()
541 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
542
543 let booted_boot_digest = match booted.composefs_boot_digest() {
545 Ok(d) => d,
546 Err(_) => &compute_store_boot_digest_for_uki(storage, &booted_cmdline.digest)?,
547 };
548
549 let booted_full_cmdline = get_uki_cmdline(storage, &booted_cmdline.digest)?;
550
551 let booted_info = DeploymentBootInfo {
552 boot_digest: booted_boot_digest,
553 full_cmdline: &booted_full_cmdline,
554 verity: &booted_cmdline.digest,
555 };
556
557 for deployment in host
558 .status
559 .staged
560 .iter_mut()
561 .chain(host.status.rollback.iter_mut())
562 .chain(host.status.other_deployments.iter_mut())
563 {
564 let depl_verity = &deployment.require_composefs()?.verity;
565
566 let depl_boot_digest = match deployment.composefs_boot_digest() {
568 Ok(d) => d,
569 Err(_) => &compute_store_boot_digest_for_uki(storage, depl_verity)?,
570 };
571
572 let depl_cmdline = get_uki_cmdline(storage, &deployment.require_composefs()?.verity)?;
573
574 let target_info = DeploymentBootInfo {
575 boot_digest: depl_boot_digest,
576 full_cmdline: &depl_cmdline,
577 verity: depl_verity,
578 };
579
580 deployment.soft_reboot_capable =
581 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
582 }
583
584 Ok(())
585}
586
587#[context("Getting composefs deployment status")]
588pub(crate) async fn composefs_deployment_status_from(
589 storage: &Storage,
590 cmdline: &ComposefsCmdline,
591) -> Result<Host> {
592 let booted_composefs_digest = &cmdline.digest;
593
594 let boot_dir = storage.require_boot_dir()?;
595
596 let deployments = storage
597 .physical_root
598 .read_dir(STATE_DIR_RELATIVE)
599 .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?;
600
601 let host_spec = HostSpec {
602 image: None,
603 boot_order: BootOrder::Default,
604 };
605
606 let mut host = Host::new(host_spec);
607
608 let staged_deployment = match std::fs::File::open(format!(
609 "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"
610 )) {
611 Ok(mut f) => {
612 let mut s = String::new();
613 f.read_to_string(&mut s)?;
614
615 Ok(Some(s))
616 }
617 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
618 Err(e) => Err(e),
619 }?;
620
621 let mut boot_type: Option<BootType> = None;
623
624 let mut extra_deployment_boot_entries: Vec<BootEntry> = Vec::new();
627
628 for depl in deployments {
629 let depl = depl?;
630
631 let depl_file_name = depl.file_name();
632 let depl_file_name = depl_file_name.to_string_lossy();
633
634 let config = depl
636 .open_dir()
637 .with_context(|| format!("Failed to open {depl_file_name}"))?
638 .read_to_string(format!("{depl_file_name}.origin"))
639 .with_context(|| format!("Reading file {depl_file_name}.origin"))?;
640
641 let ini = tini::Ini::from_string(&config)
642 .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?;
643
644 let mut boot_entry =
645 boot_entry_from_composefs_deployment(storage, ini, depl_file_name.to_string()).await?;
646
647 let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type;
649
650 match boot_type {
651 Some(current_type) => {
652 if current_type != boot_type_from_origin {
653 anyhow::bail!("Conflicting boot types")
654 }
655 }
656
657 None => {
658 boot_type = Some(boot_type_from_origin);
659 }
660 };
661
662 if depl.file_name() == booted_composefs_digest.as_ref() {
663 host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone());
664 host.status.booted = Some(boot_entry);
665 continue;
666 }
667
668 if let Some(staged_deployment) = &staged_deployment {
669 let staged_depl = serde_json::from_str::<StagedDeployment>(&staged_deployment)?;
670
671 if depl_file_name == staged_depl.depl_id {
672 boot_entry.download_only = staged_depl.finalization_locked;
673 host.status.staged = Some(boot_entry);
674 continue;
675 }
676 }
677
678 extra_deployment_boot_entries.push(boot_entry);
679 }
680
681 let Some(boot_type) = boot_type else {
683 anyhow::bail!("Could not determine boot type");
684 };
685
686 let booted_cfs = host.require_composefs_booted()?;
687
688 let mut grub_menu_string = String::new();
689 let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = match booted_cfs.bootloader {
690 Bootloader::Grub => match boot_type {
691 BootType::Bls => {
692 let bls_configs = get_sorted_type1_boot_entries(boot_dir, false)?;
693 let bls_config = bls_configs
694 .first()
695 .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?;
696
697 match &bls_config.cfg_type {
698 BLSConfigType::NonEFI { options, .. } => {
699 let is_rollback_queued = !options
700 .as_ref()
701 .ok_or_else(|| anyhow::anyhow!("options key not found in bls config"))?
702 .contains(booted_composefs_digest.as_ref());
703
704 (is_rollback_queued, Some(bls_configs), None)
705 }
706
707 BLSConfigType::EFI { .. } => {
708 anyhow::bail!("Found 'efi' field in Type1 boot entry")
709 }
710
711 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
712 }
713 }
714
715 BootType::Uki => {
716 let menuentries =
717 get_sorted_grub_uki_boot_entries(boot_dir, &mut grub_menu_string)?;
718
719 let is_rollback_queued = !menuentries
720 .first()
721 .ok_or(anyhow::anyhow!("First boot entry not found"))?
722 .body
723 .chainloader
724 .contains(booted_composefs_digest.as_ref());
725
726 (is_rollback_queued, None, Some(menuentries))
727 }
728 },
729
730 Bootloader::Systemd => {
732 let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?;
733 let bls_config = bls_configs
734 .first()
735 .ok_or(anyhow::anyhow!("First boot entry not found"))?;
736
737 let is_rollback_queued = match &bls_config.cfg_type {
738 BLSConfigType::EFI { efi } => {
740 efi.as_str().contains(booted_composefs_digest.as_ref())
741 }
742
743 BLSConfigType::NonEFI { options, .. } => !options
745 .as_ref()
746 .ok_or(anyhow::anyhow!("options key not found in bls config"))?
747 .contains(booted_composefs_digest.as_ref()),
748
749 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
750 };
751
752 (is_rollback_queued, Some(bls_configs), None)
753 }
754 };
755
756 let bootloader_configured_verity = sorted_bls_config
759 .iter()
760 .flatten()
761 .map(|cfg| cfg.get_verity())
762 .chain(
763 grub_menu_entries
764 .iter()
765 .flatten()
766 .map(|menu| menu.get_verity()),
767 )
768 .collect::<Result<HashSet<_>>>()?;
769 let rollback_candidates: Vec<_> = extra_deployment_boot_entries
770 .into_iter()
771 .filter(|entry| {
772 let verity = &entry
773 .composefs
774 .as_ref()
775 .expect("composefs is always Some for composefs deployments")
776 .verity;
777 bootloader_configured_verity.contains(verity)
778 })
779 .collect();
780
781 if rollback_candidates.len() > 1 {
782 anyhow::bail!("Multiple extra entries in /boot, could not determine rollback entry");
783 } else if let Some(rollback_entry) = rollback_candidates.into_iter().next() {
784 host.status.rollback = Some(rollback_entry);
785 }
786
787 host.status.rollback_queued = is_rollback_queued;
788
789 if host.status.rollback_queued {
790 host.spec.boot_order = BootOrder::Rollback
791 };
792
793 set_soft_reboot_capability(storage, &mut host, sorted_bls_config, cmdline)?;
794
795 Ok(host)
796}
797
798#[cfg(test)]
799mod tests {
800 use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
801
802 use crate::parsers::{bls_config::BLSConfigType, grub_menuconfig::MenuentryBody};
803
804 use super::*;
805
806 #[test]
807 fn test_composefs_parsing() {
808 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
809 let v = ComposefsCmdline::new(DIGEST);
810 assert!(!v.insecure);
811 assert_eq!(v.digest.as_ref(), DIGEST);
812 let v = ComposefsCmdline::new(&format!("?{}", DIGEST));
813 assert!(v.insecure);
814 assert_eq!(v.digest.as_ref(), DIGEST);
815 }
816
817 #[test]
818 fn test_sorted_bls_boot_entries() -> Result<()> {
819 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
820
821 let entry1 = r#"
822 title Fedora 42.20250623.3.1 (CoreOS)
823 version fedora-42.0
824 sort-key 1
825 linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
826 initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
827 options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
828 "#;
829
830 let entry2 = r#"
831 title Fedora 41.20250214.2.0 (CoreOS)
832 version fedora-42.0
833 sort-key 2
834 linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10
835 initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img
836 options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01
837 "#;
838
839 tempdir.create_dir_all("loader/entries")?;
840 tempdir.atomic_write(
841 "loader/entries/random_file.txt",
842 "Random file that we won't parse",
843 )?;
844 tempdir.atomic_write("loader/entries/entry1.conf", entry1)?;
845 tempdir.atomic_write("loader/entries/entry2.conf", entry2)?;
846
847 let result = get_sorted_type1_boot_entries(&tempdir, true).unwrap();
848
849 let mut config1 = BLSConfig::default();
850 config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into());
851 config1.sort_key = Some("1".into());
852 config1.cfg_type = BLSConfigType::NonEFI {
853 linux: "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(),
854 initrd: vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()],
855 options: Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()),
856 };
857
858 let mut config2 = BLSConfig::default();
859 config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into());
860 config2.sort_key = Some("2".into());
861 config2.cfg_type = BLSConfigType::NonEFI {
862 linux: "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(),
863 initrd: vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()],
864 options: Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into())
865 };
866
867 assert_eq!(result[0].sort_key.as_ref().unwrap(), "1");
868 assert_eq!(result[1].sort_key.as_ref().unwrap(), "2");
869
870 let result = get_sorted_type1_boot_entries(&tempdir, false).unwrap();
871 assert_eq!(result[0].sort_key.as_ref().unwrap(), "2");
872 assert_eq!(result[1].sort_key.as_ref().unwrap(), "1");
873
874 Ok(())
875 }
876
877 #[test]
878 fn test_sorted_uki_boot_entries() -> Result<()> {
879 let user_cfg = r#"
880 if [ -f ${config_directory}/efiuuid.cfg ]; then
881 source ${config_directory}/efiuuid.cfg
882 fi
883
884 menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" {
885 insmod fat
886 insmod chain
887 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
888 chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi
889 }
890
891 menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" {
892 insmod fat
893 insmod chain
894 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
895 chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi
896 }
897 "#;
898
899 let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
900 bootdir.create_dir_all(format!("grub2"))?;
901 bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?;
902
903 let mut s = String::new();
904 let result = get_sorted_grub_uki_boot_entries(&bootdir, &mut s)?;
905
906 let expected = vec![
907 MenuEntry {
908 title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(),
909 body: MenuentryBody {
910 insmod: vec!["fat", "chain"],
911 chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(),
912 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
913 version: 0,
914 extra: vec![],
915 },
916 },
917 MenuEntry {
918 title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(),
919 body: MenuentryBody {
920 insmod: vec!["fat", "chain"],
921 chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(),
922 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
923 version: 0,
924 extra: vec![],
925 },
926 },
927 ];
928
929 assert_eq!(result, expected);
930
931 Ok(())
932 }
933}