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