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 state::get_composefs_usr_overlay_status,
15 utils::{compute_store_boot_digest_for_uki, get_uki_cmdline},
16 },
17 composefs_consts::{
18 COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG,
19 USER_CFG_STAGED,
20 },
21 install::EFI_LOADER_INFO,
22 parsers::{
23 bls_config::{BLSConfig, BLSConfigType, parse_bls_config},
24 grub_menuconfig::{MenuEntry, parse_grub_menuentry_file},
25 },
26 spec::{BootEntry, BootOrder, Host, HostSpec, ImageReference, ImageStatus},
27 store::Storage,
28 utils::{EfiError, read_uefi_var},
29};
30
31use std::str::FromStr;
32
33use bootc_utils::try_deserialize_timestamp;
34use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
35use ostree_container::OstreeImageReference;
36use ostree_ext::container::{self as ostree_container};
37use ostree_ext::containers_image_proxy;
38use ostree_ext::oci_spec;
39use ostree_ext::{container::deploy::ORIGIN_CONTAINER, oci_spec::image::ImageConfiguration};
40
41use ostree_ext::oci_spec::image::ImageManifest;
42use tokio::io::AsyncReadExt;
43
44use crate::composefs_consts::{
45 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT,
46 ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE,
47};
48use crate::spec::Bootloader;
49
50#[derive(Debug, Serialize, Deserialize)]
52pub(crate) struct ImgConfigManifest {
53 pub(crate) config: ImageConfiguration,
54 pub(crate) manifest: ImageManifest,
55}
56
57#[derive(Clone)]
59pub(crate) struct ComposefsCmdline {
60 pub allow_missing_fsverity: bool,
61 pub digest: Box<str>,
62}
63
64struct DeploymentBootInfo<'a> {
66 boot_digest: &'a str,
67 full_cmdline: &'a Cmdline<'a>,
68 verity: &'a str,
69}
70
71impl ComposefsCmdline {
72 pub(crate) fn new(s: &str) -> Self {
73 let (allow_missing_fsverity, digest_str) = s
74 .strip_prefix('?')
75 .map(|v| (true, v))
76 .unwrap_or_else(|| (false, s));
77 ComposefsCmdline {
78 allow_missing_fsverity,
79 digest: digest_str.into(),
80 }
81 }
82
83 pub(crate) fn build(digest: &str, allow_missing_fsverity: bool) -> Self {
84 ComposefsCmdline {
85 allow_missing_fsverity,
86 digest: digest.into(),
87 }
88 }
89
90 pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Option<Self> {
92 match cmdline.find(COMPOSEFS_CMDLINE) {
93 Some(param) => {
94 let value = param.value()?;
95 Some(Self::new(value))
96 }
97 None => None,
98 }
99 }
100}
101
102impl std::fmt::Display for ComposefsCmdline {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" };
105 write!(
106 f,
107 "{}={}{}",
108 COMPOSEFS_CMDLINE, allow_missing_fsverity, self.digest
109 )
110 }
111}
112
113#[derive(Debug, Serialize, Deserialize)]
116pub(crate) struct StagedDeployment {
117 pub(crate) depl_id: String,
119 pub(crate) finalization_locked: bool,
122}
123
124#[derive(Debug, PartialEq)]
125pub(crate) struct BootloaderEntry {
126 pub(crate) fsverity: String,
129 pub(crate) boot_artifact_name: String,
142}
143
144pub(crate) fn composefs_booted() -> Result<Option<&'static ComposefsCmdline>> {
146 static CACHED_DIGEST_VALUE: OnceLock<Option<ComposefsCmdline>> = OnceLock::new();
147 if let Some(v) = CACHED_DIGEST_VALUE.get() {
148 return Ok(v.as_ref());
149 }
150 let cmdline = Cmdline::from_proc()?;
151 let Some(kv) = cmdline.find(COMPOSEFS_CMDLINE) else {
152 return Ok(None);
153 };
154 let Some(v) = kv.value() else { return Ok(None) };
155 let v = ComposefsCmdline::new(v);
156
157 let root_mnt = inspect_filesystem("/".into())?;
159
160 let verity_from_mount_src = root_mnt
162 .source
163 .strip_prefix("composefs:")
164 .ok_or_else(|| anyhow::anyhow!("Root not mounted using composefs"))?;
165
166 let r = if *verity_from_mount_src != *v.digest {
167 CACHED_DIGEST_VALUE.get_or_init(|| Some(ComposefsCmdline::new(verity_from_mount_src)))
169 } else {
170 CACHED_DIGEST_VALUE.get_or_init(|| Some(v))
171 };
172
173 Ok(r.as_ref())
174}
175
176pub(crate) fn get_sorted_grub_uki_boot_entries_staged<'a>(
178 boot_dir: &Dir,
179 str: &'a mut String,
180) -> Result<Vec<MenuEntry<'a>>> {
181 get_sorted_grub_uki_boot_entries_helper(boot_dir, str, true)
182}
183
184pub(crate) fn get_sorted_grub_uki_boot_entries<'a>(
186 boot_dir: &Dir,
187 str: &'a mut String,
188) -> Result<Vec<MenuEntry<'a>>> {
189 get_sorted_grub_uki_boot_entries_helper(boot_dir, str, false)
190}
191
192fn get_sorted_grub_uki_boot_entries_helper<'a>(
194 boot_dir: &Dir,
195 str: &'a mut String,
196 staged: bool,
197) -> Result<Vec<MenuEntry<'a>>> {
198 let file = if staged {
199 boot_dir
200 .open_optional(format!("grub2/{USER_CFG_STAGED}"))
202 .with_context(|| format!("Opening {USER_CFG_STAGED}"))?
203 } else {
204 let f = boot_dir
205 .open(format!("grub2/{USER_CFG}"))
206 .with_context(|| format!("Opening {USER_CFG}"))?;
207
208 Some(f)
209 };
210
211 let Some(mut file) = file else {
212 return Ok(Vec::new());
213 };
214
215 file.read_to_string(str)?;
216 parse_grub_menuentry_file(str)
217}
218
219pub(crate) fn get_sorted_type1_boot_entries(
220 boot_dir: &Dir,
221 ascending: bool,
222) -> Result<Vec<BLSConfig>> {
223 get_sorted_type1_boot_entries_helper(boot_dir, ascending, false)
224}
225
226pub(crate) fn get_sorted_staged_type1_boot_entries(
227 boot_dir: &Dir,
228 ascending: bool,
229) -> Result<Vec<BLSConfig>> {
230 get_sorted_type1_boot_entries_helper(boot_dir, ascending, true)
231}
232
233#[context("Getting sorted Type1 boot entries")]
234fn get_sorted_type1_boot_entries_helper(
235 boot_dir: &Dir,
236 ascending: bool,
237 get_staged_entries: bool,
238) -> Result<Vec<BLSConfig>> {
239 let mut all_configs = vec![];
240
241 let dir = match get_staged_entries {
242 true => {
243 let dir = boot_dir.open_dir_optional(TYPE1_ENT_PATH_STAGED)?;
244
245 let Some(dir) = dir else {
246 return Ok(all_configs);
247 };
248
249 dir.read_dir(".")?
250 }
251
252 false => boot_dir.read_dir(TYPE1_ENT_PATH)?,
253 };
254
255 for entry in dir {
256 let entry = entry?;
257
258 let file_name = entry.file_name();
259
260 let file_name = file_name
261 .to_str()
262 .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?;
263
264 if !file_name.ends_with(".conf") {
265 continue;
266 }
267
268 let mut file = entry
269 .open()
270 .with_context(|| format!("Failed to open {:?}", file_name))?;
271
272 let mut contents = String::new();
273 file.read_to_string(&mut contents)
274 .with_context(|| format!("Failed to read {:?}", file_name))?;
275
276 let config = parse_bls_config(&contents).context("Parsing bls config")?;
277
278 all_configs.push(config);
279 }
280
281 all_configs.sort_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) });
282
283 Ok(all_configs)
284}
285
286fn list_type1_entries(boot_dir: &Dir) -> Result<Vec<BootloaderEntry>> {
287 let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?;
289
290 let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?;
293
294 boot_entries
295 .into_iter()
296 .chain(staged_boot_entries)
297 .map(|entry| {
298 Ok(BootloaderEntry {
299 fsverity: entry.get_verity()?,
300 boot_artifact_name: entry.boot_artifact_name()?.to_string(),
301 })
302 })
303 .collect::<Result<Vec<_>, _>>()
304}
305
306#[fn_error_context::context("Listing bootloader entries")]
311pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result<Vec<BootloaderEntry>> {
312 let bootloader = get_bootloader()?;
313 let boot_dir = storage.require_boot_dir()?;
314
315 let entries = match bootloader {
316 Bootloader::Grub => {
317 let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?;
319
320 if grub_dir.exists(USER_CFG) {
322 let mut s = String::new();
323 let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?;
324
325 let mut staged = String::new();
326 let boot_entries_staged =
327 get_sorted_grub_uki_boot_entries_staged(boot_dir, &mut staged)?;
328
329 boot_entries
330 .into_iter()
331 .chain(boot_entries_staged)
332 .map(|entry| {
333 Ok(BootloaderEntry {
334 fsverity: entry.get_verity()?,
335 boot_artifact_name: entry.boot_artifact_name()?,
336 })
337 })
338 .collect::<Result<Vec<_>, anyhow::Error>>()?
339 } else {
340 list_type1_entries(boot_dir)?
341 }
342 }
343
344 Bootloader::Systemd => list_type1_entries(boot_dir)?,
345
346 Bootloader::None => unreachable!("Checked at install time"),
347 };
348
349 Ok(entries)
350}
351
352#[context("Getting container info")]
354pub(crate) async fn get_container_manifest_and_config(
355 imgref: &String,
356) -> Result<ImgConfigManifest> {
357 let mut config = crate::deploy::new_proxy_config();
358 ostree_ext::container::merge_default_container_proxy_opts(&mut config)?;
359 let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?;
360
361 let img = proxy
362 .open_image(&imgref)
363 .await
364 .with_context(|| format!("Opening image {imgref}"))?;
365
366 let (_, manifest) = proxy.fetch_manifest(&img).await?;
367 let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?;
368
369 let mut buf = Vec::with_capacity(manifest.config().size() as usize);
370 buf.resize(manifest.config().size() as usize, 0);
371 reader.read_exact(&mut buf).await?;
372 driver.await?;
373
374 let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?;
375
376 Ok(ImgConfigManifest { manifest, config })
377}
378
379#[context("Getting bootloader")]
380pub(crate) fn get_bootloader() -> Result<Bootloader> {
381 match read_uefi_var(EFI_LOADER_INFO) {
382 Ok(loader) => {
383 if loader.to_lowercase().contains("systemd-boot") {
384 return Ok(Bootloader::Systemd);
385 }
386
387 return Ok(Bootloader::Grub);
388 }
389
390 Err(efi_error) => match efi_error {
391 EfiError::SystemNotUEFI => return Ok(Bootloader::Grub),
392 EfiError::MissingVar => return Ok(Bootloader::Grub),
393
394 e => return Err(anyhow::anyhow!("Failed to read EfiLoaderInfo: {e:?}")),
395 },
396 }
397}
398
399#[context("Reading imginfo")]
401pub(crate) async fn get_imginfo(
402 storage: &Storage,
403 deployment_id: &str,
404 imgref: Option<&ImageReference>,
405) -> Result<ImgConfigManifest> {
406 let imginfo_fname = format!("{deployment_id}.imginfo");
407
408 let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id);
409 let path = depl_state_path.join(imginfo_fname);
410
411 let mut img_conf = storage
412 .physical_root
413 .open_optional(&path)
414 .context("Failed to open file")?;
415
416 let Some(img_conf) = &mut img_conf else {
417 let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No imgref or imginfo file found"))?;
418
419 let container_details =
420 get_container_manifest_and_config(&get_imgref(&imgref.transport, &imgref.image))
421 .await?;
422
423 let state_dir = storage.physical_root.open_dir(depl_state_path)?;
424
425 state_dir
426 .atomic_write(
427 format!("{}.imginfo", deployment_id),
428 serde_json::to_vec(&container_details)?,
429 )
430 .context("Failed to write to .imginfo file")?;
431
432 let state_dir = state_dir.reopen_as_ownedfd()?;
433
434 rustix::fs::fsync(state_dir).context("fsync")?;
435
436 return Ok(container_details);
437 };
438
439 let mut buffer = String::new();
440 img_conf.read_to_string(&mut buffer)?;
441
442 let img_conf = serde_json::from_str::<ImgConfigManifest>(&buffer)
443 .context("Failed to parse file as JSON")?;
444
445 Ok(img_conf)
446}
447
448#[context("Getting composefs deployment metadata")]
449async fn boot_entry_from_composefs_deployment(
450 storage: &Storage,
451 origin: tini::Ini,
452 verity: &str,
453) -> Result<BootEntry> {
454 let image = match origin.get::<String>("origin", ORIGIN_CONTAINER) {
455 Some(img_name_from_config) => {
456 let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?;
457 let img_ref = ImageReference::from(ostree_img_ref);
458
459 let img_conf = get_imginfo(storage, &verity, Some(&img_ref)).await?;
460
461 let image_digest = img_conf.manifest.config().digest().to_string();
462 let architecture = img_conf.config.architecture().to_string();
463 let version = img_conf
464 .manifest
465 .annotations()
466 .as_ref()
467 .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned());
468
469 let created_at = img_conf.config.created().clone();
470 let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x));
471
472 Some(ImageStatus {
473 image: img_ref,
474 version,
475 timestamp,
476 image_digest,
477 architecture,
478 })
479 }
480
481 None => None,
483 };
484
485 let boot_type = match origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) {
486 Some(s) => BootType::try_from(s.as_str())?,
487 None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"),
488 };
489
490 let boot_digest = origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST);
491
492 let e = BootEntry {
493 image,
494 cached_update: None,
495 incompatible: false,
496 pinned: false,
497 download_only: false, store: None,
499 ostree: None,
500 composefs: Some(crate::spec::BootEntryComposefs {
501 verity: verity.into(),
502 boot_type,
503 bootloader: get_bootloader()?,
504 boot_digest,
505 }),
506 soft_reboot_capable: false,
507 };
508
509 Ok(e)
510}
511
512#[context("Getting composefs deployment status")]
515pub(crate) async fn get_composefs_status(
516 storage: &crate::store::Storage,
517 booted_cfs: &crate::store::BootedComposefs,
518) -> Result<Host> {
519 composefs_deployment_status_from(&storage, booted_cfs.cmdline).await
520}
521
522#[context("Checking soft reboot capability")]
524fn set_soft_reboot_capability(
525 storage: &Storage,
526 host: &mut Host,
527 bls_entries: Option<Vec<BLSConfig>>,
528 booted_cmdline: &ComposefsCmdline,
529) -> Result<()> {
530 let booted = host.require_composefs_booted()?;
531
532 match booted.boot_type {
533 BootType::Bls => {
534 let mut bls_entries =
535 bls_entries.ok_or_else(|| anyhow::anyhow!("BLS entries not provided"))?;
536
537 let staged_entries =
538 get_sorted_staged_type1_boot_entries(storage.require_boot_dir()?, false)?;
539
540 bls_entries.extend(staged_entries);
543
544 set_reboot_capable_type1_deployments(storage, booted_cmdline, host, bls_entries)
545 }
546
547 BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host),
548 }
549}
550
551fn find_bls_entry<'a>(
552 verity: &str,
553 bls_entries: &'a Vec<BLSConfig>,
554) -> Result<Option<&'a BLSConfig>> {
555 for ent in bls_entries {
556 if ent.get_verity()? == *verity {
557 return Ok(Some(ent));
558 }
559 }
560
561 Ok(None)
562}
563
564fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool {
566 for param in first {
567 if param.key() == COMPOSEFS_CMDLINE.into() {
568 continue;
569 }
570
571 let second_param = second.iter().find(|b| *b == param);
572
573 let Some(found_param) = second_param else {
574 return false;
575 };
576
577 if found_param.value() != param.value() {
578 return false;
579 }
580 }
581
582 return true;
583}
584
585#[context("Setting soft reboot capability for Type1 entries")]
586fn set_reboot_capable_type1_deployments(
587 storage: &Storage,
588 booted_cmdline: &ComposefsCmdline,
589 host: &mut Host,
590 bls_entries: Vec<BLSConfig>,
591) -> Result<()> {
592 let booted = host
593 .status
594 .booted
595 .as_ref()
596 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
597
598 let booted_boot_digest = booted.composefs_boot_digest()?;
599
600 let booted_bls_entry = find_bls_entry(&*booted_cmdline.digest, &bls_entries)?
601 .ok_or_else(|| anyhow::anyhow!("Booted BLS entry not found"))?;
602
603 let booted_full_cmdline = booted_bls_entry.get_cmdline()?;
604
605 let booted_info = DeploymentBootInfo {
606 boot_digest: booted_boot_digest,
607 full_cmdline: booted_full_cmdline,
608 verity: &booted_cmdline.digest,
609 };
610
611 for depl in host
612 .status
613 .staged
614 .iter_mut()
615 .chain(host.status.rollback.iter_mut())
616 .chain(host.status.other_deployments.iter_mut())
617 {
618 let depl_verity = &depl.require_composefs()?.verity;
619
620 let entry = find_bls_entry(&depl_verity, &bls_entries)?
621 .ok_or_else(|| anyhow::anyhow!("Entry not found"))?;
622
623 let depl_cmdline = entry.get_cmdline()?;
624
625 let target_info = DeploymentBootInfo {
626 boot_digest: depl.composefs_boot_digest()?,
627 full_cmdline: depl_cmdline,
628 verity: &depl_verity,
629 };
630
631 depl.soft_reboot_capable =
632 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
633 }
634
635 Ok(())
636}
637
638fn is_soft_rebootable(
648 storage: &Storage,
649 booted_cmdline: &ComposefsCmdline,
650 booted: &DeploymentBootInfo,
651 target: &DeploymentBootInfo,
652) -> Result<bool> {
653 if target.boot_digest != booted.boot_digest {
654 tracing::debug!("Soft reboot not allowed due to kernel skew");
655 return Ok(false);
656 }
657
658 if target.full_cmdline.as_bytes().len() != booted.full_cmdline.as_bytes().len() {
659 tracing::debug!("Soft reboot not allowed due to differing cmdline");
660 return Ok(false);
661 }
662
663 let cmdline_eq = compare_cmdline_skip_cfs(target.full_cmdline, booted.full_cmdline)
664 && compare_cmdline_skip_cfs(booted.full_cmdline, target.full_cmdline);
665
666 let selinux_compatible =
667 are_selinux_policies_compatible(storage, booted_cmdline, target.verity)?;
668
669 return Ok(cmdline_eq && selinux_compatible);
670}
671
672#[context("Setting soft reboot capability for UKI deployments")]
673fn set_reboot_capable_uki_deployments(
674 storage: &Storage,
675 booted_cmdline: &ComposefsCmdline,
676 host: &mut Host,
677) -> Result<()> {
678 let booted = host
679 .status
680 .booted
681 .as_ref()
682 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
683
684 let booted_boot_digest = match booted.composefs_boot_digest() {
686 Ok(d) => d,
687 Err(_) => &compute_store_boot_digest_for_uki(storage, &booted_cmdline.digest)?,
688 };
689
690 let booted_full_cmdline = get_uki_cmdline(storage, &booted_cmdline.digest)?;
691
692 let booted_info = DeploymentBootInfo {
693 boot_digest: booted_boot_digest,
694 full_cmdline: &booted_full_cmdline,
695 verity: &booted_cmdline.digest,
696 };
697
698 for deployment in host
699 .status
700 .staged
701 .iter_mut()
702 .chain(host.status.rollback.iter_mut())
703 .chain(host.status.other_deployments.iter_mut())
704 {
705 let depl_verity = &deployment.require_composefs()?.verity;
706
707 let depl_boot_digest = match deployment.composefs_boot_digest() {
709 Ok(d) => d,
710 Err(_) => &compute_store_boot_digest_for_uki(storage, depl_verity)?,
711 };
712
713 let depl_cmdline = get_uki_cmdline(storage, &deployment.require_composefs()?.verity)?;
714
715 let target_info = DeploymentBootInfo {
716 boot_digest: depl_boot_digest,
717 full_cmdline: &depl_cmdline,
718 verity: depl_verity,
719 };
720
721 deployment.soft_reboot_capable =
722 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
723 }
724
725 Ok(())
726}
727
728#[context("Getting composefs deployment status")]
729async fn composefs_deployment_status_from(
730 storage: &Storage,
731 cmdline: &ComposefsCmdline,
732) -> Result<Host> {
733 let booted_composefs_digest = &cmdline.digest;
734
735 let boot_dir = storage.require_boot_dir()?;
736
737 let bootloader_entry_verity = list_bootloader_entries(storage)?;
739
740 let state_dir = storage
741 .physical_root
742 .open_dir(STATE_DIR_RELATIVE)
743 .with_context(|| format!("Opening {STATE_DIR_RELATIVE}"))?;
744
745 let host_spec = HostSpec {
746 image: None,
747 boot_order: BootOrder::Default,
748 };
749
750 let mut host = Host::new(host_spec);
751
752 let staged_deployment = match std::fs::File::open(format!(
753 "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"
754 )) {
755 Ok(mut f) => {
756 let mut s = String::new();
757 f.read_to_string(&mut s)?;
758
759 Ok(Some(s))
760 }
761 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
762 Err(e) => Err(e),
763 }?;
764
765 let mut boot_type: Option<BootType> = None;
767
768 let mut extra_deployment_boot_entries: Vec<BootEntry> = Vec::new();
771
772 for BootloaderEntry {
773 fsverity: verity_digest,
774 ..
775 } in bootloader_entry_verity
776 {
777 let config = state_dir
779 .open_dir(&verity_digest)
780 .with_context(|| format!("Failed to open {verity_digest}"))?
781 .read_to_string(format!("{verity_digest}.origin"))
782 .with_context(|| format!("Reading file {verity_digest}.origin"))?;
783
784 let ini = tini::Ini::from_string(&config)
785 .with_context(|| format!("Failed to parse file {verity_digest}.origin as ini"))?;
786
787 let mut boot_entry =
788 boot_entry_from_composefs_deployment(storage, ini, &verity_digest).await?;
789
790 let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type;
792
793 match boot_type {
794 Some(current_type) => {
795 if current_type != boot_type_from_origin {
796 anyhow::bail!("Conflicting boot types")
797 }
798 }
799
800 None => {
801 boot_type = Some(boot_type_from_origin);
802 }
803 };
804
805 if verity_digest == booted_composefs_digest.as_ref() {
806 host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone());
807 host.status.booted = Some(boot_entry);
808 continue;
809 }
810
811 if let Some(staged_deployment) = &staged_deployment {
812 let staged_depl = serde_json::from_str::<StagedDeployment>(&staged_deployment)?;
813
814 if verity_digest == staged_depl.depl_id {
815 boot_entry.download_only = staged_depl.finalization_locked;
816 host.status.staged = Some(boot_entry);
817 continue;
818 }
819 }
820
821 extra_deployment_boot_entries.push(boot_entry);
822 }
823
824 let Some(boot_type) = boot_type else {
826 anyhow::bail!("Could not determine boot type");
827 };
828
829 let booted_cfs = host.require_composefs_booted()?;
830
831 let mut grub_menu_string = String::new();
832 let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = match booted_cfs.bootloader {
833 Bootloader::Grub => match boot_type {
834 BootType::Bls => {
835 let bls_configs = get_sorted_type1_boot_entries(boot_dir, false)?;
836 let bls_config = bls_configs
837 .first()
838 .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?;
839
840 match &bls_config.cfg_type {
841 BLSConfigType::NonEFI { options, .. } => {
842 let is_rollback_queued = !options
843 .as_ref()
844 .ok_or_else(|| anyhow::anyhow!("options key not found in bls config"))?
845 .contains(booted_composefs_digest.as_ref());
846
847 (is_rollback_queued, Some(bls_configs), None)
848 }
849
850 BLSConfigType::EFI { .. } => {
851 anyhow::bail!("Found 'efi' field in Type1 boot entry")
852 }
853
854 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
855 }
856 }
857
858 BootType::Uki => {
859 let menuentries =
860 get_sorted_grub_uki_boot_entries(boot_dir, &mut grub_menu_string)?;
861
862 let is_rollback_queued = !menuentries
863 .first()
864 .ok_or(anyhow::anyhow!("First boot entry not found"))?
865 .body
866 .chainloader
867 .contains(booted_composefs_digest.as_ref());
868
869 (is_rollback_queued, None, Some(menuentries))
870 }
871 },
872
873 Bootloader::Systemd => {
875 let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?;
876 let bls_config = bls_configs
877 .first()
878 .ok_or(anyhow::anyhow!("First boot entry not found"))?;
879
880 let is_rollback_queued = match &bls_config.cfg_type {
881 BLSConfigType::EFI { efi } => {
883 efi.as_str().contains(booted_composefs_digest.as_ref())
884 }
885
886 BLSConfigType::NonEFI { options, .. } => !options
888 .as_ref()
889 .ok_or(anyhow::anyhow!("options key not found in bls config"))?
890 .contains(booted_composefs_digest.as_ref()),
891
892 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
893 };
894
895 (is_rollback_queued, Some(bls_configs), None)
896 }
897
898 Bootloader::None => unreachable!("Checked at install time"),
899 };
900
901 let bootloader_configured_verity = sorted_bls_config
904 .iter()
905 .flatten()
906 .map(|cfg| cfg.get_verity())
907 .chain(
908 grub_menu_entries
909 .iter()
910 .flatten()
911 .map(|menu| menu.get_verity()),
912 )
913 .collect::<Result<HashSet<_>>>()?;
914
915 let rollback_candidates: Vec<_> = extra_deployment_boot_entries
916 .into_iter()
917 .filter(|entry| {
918 let verity = &entry
919 .composefs
920 .as_ref()
921 .expect("composefs is always Some for composefs deployments")
922 .verity;
923 bootloader_configured_verity.contains(verity)
924 })
925 .collect();
926
927 if rollback_candidates.len() > 1 {
928 anyhow::bail!("Multiple extra entries in /boot, could not determine rollback entry");
929 } else if let Some(rollback_entry) = rollback_candidates.into_iter().next() {
930 host.status.rollback = Some(rollback_entry);
931 }
932
933 host.status.rollback_queued = is_rollback_queued;
934
935 if host.status.rollback_queued {
936 host.spec.boot_order = BootOrder::Rollback
937 };
938
939 host.status.usr_overlay = get_composefs_usr_overlay_status().ok().flatten();
940
941 set_soft_reboot_capability(storage, &mut host, sorted_bls_config, cmdline)?;
942
943 Ok(host)
944}
945
946#[cfg(test)]
947mod tests {
948 use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
949
950 use crate::parsers::{bls_config::BLSConfigType, grub_menuconfig::MenuentryBody};
951
952 use super::*;
953
954 #[test]
955 fn test_composefs_parsing() {
956 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
957 let v = ComposefsCmdline::new(DIGEST);
958 assert!(!v.allow_missing_fsverity);
959 assert_eq!(v.digest.as_ref(), DIGEST);
960 let v = ComposefsCmdline::new(&format!("?{}", DIGEST));
961 assert!(v.allow_missing_fsverity);
962 assert_eq!(v.digest.as_ref(), DIGEST);
963 }
964
965 #[test]
966 fn test_sorted_bls_boot_entries() -> Result<()> {
967 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
968
969 let entry1 = r#"
970 title Fedora 42.20250623.3.1 (CoreOS)
971 version fedora-42.0
972 sort-key 1
973 linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
974 initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
975 options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
976 "#;
977
978 let entry2 = r#"
979 title Fedora 41.20250214.2.0 (CoreOS)
980 version fedora-42.0
981 sort-key 2
982 linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10
983 initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img
984 options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01
985 "#;
986
987 tempdir.create_dir_all("loader/entries")?;
988 tempdir.atomic_write(
989 "loader/entries/random_file.txt",
990 "Random file that we won't parse",
991 )?;
992 tempdir.atomic_write("loader/entries/entry1.conf", entry1)?;
993 tempdir.atomic_write("loader/entries/entry2.conf", entry2)?;
994
995 let result = get_sorted_type1_boot_entries(&tempdir, true).unwrap();
996
997 let mut config1 = BLSConfig::default();
998 config1.title = Some("Fedora 42.20250623.3.1 (CoreOS)".into());
999 config1.sort_key = Some("1".into());
1000 config1.cfg_type = BLSConfigType::NonEFI {
1001 linux: "/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10".into(),
1002 initrd: vec!["/boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img".into()],
1003 options: Some("root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6".into()),
1004 };
1005
1006 let mut config2 = BLSConfig::default();
1007 config2.title = Some("Fedora 41.20250214.2.0 (CoreOS)".into());
1008 config2.sort_key = Some("2".into());
1009 config2.cfg_type = BLSConfigType::NonEFI {
1010 linux: "/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10".into(),
1011 initrd: vec!["/boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img".into()],
1012 options: Some("root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01".into())
1013 };
1014
1015 assert_eq!(result[0].sort_key.as_ref().unwrap(), "1");
1016 assert_eq!(result[1].sort_key.as_ref().unwrap(), "2");
1017
1018 let result = get_sorted_type1_boot_entries(&tempdir, false).unwrap();
1019 assert_eq!(result[0].sort_key.as_ref().unwrap(), "2");
1020 assert_eq!(result[1].sort_key.as_ref().unwrap(), "1");
1021
1022 Ok(())
1023 }
1024
1025 #[test]
1026 fn test_sorted_uki_boot_entries() -> Result<()> {
1027 let user_cfg = r#"
1028 if [ -f ${config_directory}/efiuuid.cfg ]; then
1029 source ${config_directory}/efiuuid.cfg
1030 fi
1031
1032 menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" {
1033 insmod fat
1034 insmod chain
1035 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
1036 chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi
1037 }
1038
1039 menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" {
1040 insmod fat
1041 insmod chain
1042 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
1043 chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi
1044 }
1045 "#;
1046
1047 let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1048 bootdir.create_dir_all(format!("grub2"))?;
1049 bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?;
1050
1051 let mut s = String::new();
1052 let result = get_sorted_grub_uki_boot_entries(&bootdir, &mut s)?;
1053
1054 let expected = vec![
1055 MenuEntry {
1056 title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(),
1057 body: MenuentryBody {
1058 insmod: vec!["fat", "chain"],
1059 chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(),
1060 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
1061 version: 0,
1062 extra: vec![],
1063 },
1064 },
1065 MenuEntry {
1066 title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(),
1067 body: MenuentryBody {
1068 insmod: vec!["fat", "chain"],
1069 chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(),
1070 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
1071 version: 0,
1072 extra: vec![],
1073 },
1074 },
1075 ];
1076
1077 assert_eq!(result, expected);
1078
1079 Ok(())
1080 }
1081
1082 #[test]
1083 fn test_find_in_cmdline() {
1084 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
1085
1086 let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs={}", DIGEST));
1088 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1089 assert!(result.is_some());
1090 let cfs = result.unwrap();
1091 assert_eq!(cfs.digest.as_ref(), DIGEST);
1092 assert!(!cfs.allow_missing_fsverity);
1093
1094 let cmdline = Cmdline::from(format!("root=UUID=abc123 rw composefs=?{}", DIGEST));
1096 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1097 assert!(result.is_some());
1098 let cfs = result.unwrap();
1099 assert_eq!(cfs.digest.as_ref(), DIGEST);
1100 assert!(cfs.allow_missing_fsverity);
1101
1102 let cmdline = Cmdline::from("root=UUID=abc123 rw quiet");
1104 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1105 assert!(result.is_none());
1106
1107 let cmdline = Cmdline::from("");
1109 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1110 assert!(result.is_none());
1111
1112 let cmdline = Cmdline::from(format!("quiet composefs={} loglevel=3", DIGEST));
1114 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1115 assert!(result.is_some());
1116 let cfs = result.unwrap();
1117 assert_eq!(cfs.digest.as_ref(), DIGEST);
1118 assert!(!cfs.allow_missing_fsverity);
1119
1120 let cmdline = Cmdline::from(format!("composefs=?{} root=UUID=abc123 quiet", DIGEST));
1122 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1123 assert!(result.is_some());
1124 let cfs = result.unwrap();
1125 assert_eq!(cfs.digest.as_ref(), DIGEST);
1126 assert!(cfs.allow_missing_fsverity);
1127
1128 let cmdline = Cmdline::from(format!("composefs_backup={} root=UUID=abc123", DIGEST));
1130 let result = ComposefsCmdline::find_in_cmdline(&cmdline);
1131 assert!(result.is_none());
1132 }
1133}