1use std::{io::Read, sync::OnceLock};
2
3use anyhow::{Context, Result, ensure};
4use bootc_mount::inspect_filesystem;
5use composefs_ctl::composefs::erofs::format::FormatVersion;
6use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
7use composefs_ctl::composefs_boot::cmdline::{
8 ComposefsCmdline as BootComposefsCmdline, KARG_COMPOSEFS_DIGEST, KARG_V2,
9};
10use composefs_ctl::composefs_oci;
11use composefs_oci::OciImage;
12use fn_error_context::context;
13use linux_kernel_cmdline::utf8::Cmdline;
14use openssl::sha::Sha256;
15use serde::{Deserialize, Serialize};
16
17use crate::{
18 bootc_composefs::{
19 aboot,
20 boot::BootType,
21 selinux::are_selinux_policies_compatible,
22 state::{get_usr_overlay_status, read_boot_type, read_origin},
23 utils::{compute_store_boot_digest_for_uki, get_uki_cmdline},
24 },
25 composefs_consts::{
26 ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, TYPE1_ENT_PATH,
27 TYPE1_ENT_PATH_STAGED, USER_CFG, USER_CFG_STAGED,
28 },
29 install::EFI_LOADER_INFO,
30 parsers::{
31 bls_config::{BLSConfig, BLSConfigType, EFIKey, parse_bls_config},
32 grub_menuconfig::{MenuEntry, parse_grub_menuentry_file},
33 },
34 spec::{BootEntry, BootOrder, BootloaderKind, Host, HostSpec, ImageStatus},
35 store::Storage,
36 utils::{EfiError, read_uefi_var},
37};
38
39use std::str::FromStr;
40
41use bootc_utils::try_deserialize_timestamp;
42use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
43use ostree_container::OstreeImageReference;
44use ostree_ext::container::{self as ostree_container};
45use ostree_ext::containers_image_proxy::{ImageProxy, ImageReference};
46
47use ostree_ext::oci_spec;
48use ostree_ext::{container::deploy::ORIGIN_CONTAINER, oci_spec::image::ImageConfiguration};
49
50use ostree_ext::oci_spec::image::ImageManifest;
51use tokio::io::AsyncReadExt;
52
53use crate::composefs_consts::{
54 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, ORIGIN_KEY_BOOT,
55 ORIGIN_KEY_BOOT_TYPE, STATE_DIR_RELATIVE,
56};
57use crate::spec::Bootloader;
58
59#[derive(Debug, Serialize, Deserialize)]
61pub(crate) struct ImgConfigManifest {
62 pub(crate) config: ImageConfiguration,
63 pub(crate) manifest: ImageManifest,
64}
65
66#[derive(Clone)]
68pub(crate) struct ComposefsCmdline {
69 pub allow_missing_fsverity: bool,
70 pub digest: Box<str>,
71 pub is_transient: bool,
74}
75
76struct DeploymentBootInfo<'a> {
78 boot_digest: &'a str,
79 full_cmdline: &'a Cmdline<'a>,
80 verity: &'a str,
81}
82
83impl ComposefsCmdline {
84 pub(crate) fn new(s: &str) -> Self {
85 let (allow_missing_fsverity, digest_str) = s
86 .strip_prefix('?')
87 .map(|v| (true, v))
88 .unwrap_or_else(|| (false, s));
89 ComposefsCmdline {
90 allow_missing_fsverity,
91 digest: digest_str.into(),
92 is_transient: false,
93 }
94 }
95
96 pub(crate) fn find_in_cmdline(cmdline: &Cmdline) -> Result<Option<Self>> {
98 let Some(parsed) = BootComposefsCmdline::<Sha512HashValue>::from_cmdline(cmdline)
99 .context("Parsing composefs kernel command line")?
100 else {
101 return Ok(None);
102 };
103 Ok(Some(Self {
104 allow_missing_fsverity: parsed.is_insecure(),
105 digest: parsed.digest().to_hex().into(),
106 is_transient: false,
107 }))
108 }
109}
110
111fn reconcile_composefs_cmdline_with_mount_source(
112 mut cmdline: ComposefsCmdline,
113 mount_source: &str,
114) -> Result<ComposefsCmdline> {
115 let (verity, is_transient) = if let Some(v) = mount_source.strip_prefix("composefs:") {
116 (v, false)
117 } else if let Some(v) = mount_source.strip_prefix("transient:composefs=") {
118 (v, true)
119 } else {
120 anyhow::bail!("Root not mounted using composefs (source: {mount_source})")
121 };
122
123 let mount_cmdline = ComposefsCmdline::new(verity);
124 if *mount_cmdline.digest != *cmdline.digest {
125 cmdline.digest = mount_cmdline.digest;
126 }
127 cmdline.is_transient = is_transient;
128 Ok(cmdline)
129}
130
131pub(crate) fn build_composefs_karg(
133 digest: Sha512HashValue,
134 format_version: FormatVersion,
135 allow_missing_fsverity: bool,
136) -> String {
137 match format_version {
138 FormatVersion::V0 | FormatVersion::V1 => {
139 BootComposefsCmdline::new_v1(digest, allow_missing_fsverity)
140 }
141 FormatVersion::V2 => BootComposefsCmdline::new_v2(digest, allow_missing_fsverity),
142 }
143 .to_cmdline_arg()
144}
145
146impl std::fmt::Display for ComposefsCmdline {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 let allow_missing_fsverity = if self.allow_missing_fsverity { "?" } else { "" };
149 write!(f, "{}={}{}", KARG_V2, allow_missing_fsverity, self.digest)
150 }
151}
152
153#[derive(Debug, Serialize, Deserialize)]
156pub(crate) struct StagedDeployment {
157 pub(crate) depl_id: String,
159 pub(crate) finalization_locked: bool,
162}
163
164#[derive(Debug, PartialEq)]
165pub(crate) struct BootloaderEntry {
166 pub(crate) fsverity: String,
169 pub(crate) boot_artifact_name: String,
182}
183
184pub(crate) fn composefs_booted() -> Result<Option<&'static ComposefsCmdline>> {
186 static CACHED_DIGEST_VALUE: OnceLock<Option<ComposefsCmdline>> = OnceLock::new();
187 if let Some(v) = CACHED_DIGEST_VALUE.get() {
188 return Ok(v.as_ref());
189 }
190 let cmdline = Cmdline::from_proc()?;
191 let Some(v) = ComposefsCmdline::find_in_cmdline(&cmdline)? else {
192 return Ok(None);
193 };
194
195 let root_mnt = inspect_filesystem("/".into())?;
197
198 let reconciled = reconcile_composefs_cmdline_with_mount_source(v, &root_mnt.source)?;
201 let r = CACHED_DIGEST_VALUE.get_or_init(|| Some(reconciled));
202
203 Ok(r.as_ref())
204}
205
206pub(crate) fn get_sorted_grub_uki_boot_entries_staged<'a>(
208 boot_dir: &Dir,
209 str: &'a mut String,
210) -> Result<Vec<MenuEntry<'a>>> {
211 get_sorted_grub_uki_boot_entries_helper(boot_dir, str, true)
212}
213
214pub(crate) fn get_sorted_grub_uki_boot_entries<'a>(
216 boot_dir: &Dir,
217 str: &'a mut String,
218) -> Result<Vec<MenuEntry<'a>>> {
219 get_sorted_grub_uki_boot_entries_helper(boot_dir, str, false)
220}
221
222fn get_sorted_grub_uki_boot_entries_helper<'a>(
224 boot_dir: &Dir,
225 str: &'a mut String,
226 staged: bool,
227) -> Result<Vec<MenuEntry<'a>>> {
228 let file = if staged {
229 boot_dir
230 .open_optional(format!("grub2/{USER_CFG_STAGED}"))
232 .with_context(|| format!("Opening {USER_CFG_STAGED}"))?
233 } else {
234 let f = boot_dir
235 .open(format!("grub2/{USER_CFG}"))
236 .with_context(|| format!("Opening {USER_CFG}"))?;
237
238 Some(f)
239 };
240
241 let Some(mut file) = file else {
242 return Ok(Vec::new());
243 };
244
245 file.read_to_string(str)?;
246 parse_grub_menuentry_file(str)
247}
248
249pub(crate) fn get_sorted_type1_boot_entries(
254 boot_dir: &Dir,
255 ascending: bool,
256) -> Result<Vec<BLSConfig>> {
257 let bootloader = get_bootloader()?;
258 get_sorted_type1_boot_entries_helper(boot_dir, ascending, false, bootloader)
259}
260
261pub(crate) fn get_sorted_staged_type1_boot_entries(
264 boot_dir: &Dir,
265 ascending: bool,
266) -> Result<Vec<BLSConfig>> {
267 let bootloader = get_bootloader()?;
268 get_sorted_type1_boot_entries_helper(boot_dir, ascending, true, bootloader)
269}
270
271#[context("Getting sorted Type1 boot entries")]
272fn get_sorted_type1_boot_entries_helper(
273 boot_dir: &Dir,
274 ascending: bool,
275 get_staged_entries: bool,
276 bootloader: crate::spec::Bootloader,
277) -> Result<Vec<BLSConfig>> {
278 #[derive(Debug)]
279 struct ConfigWithFilename {
280 config: BLSConfig,
281 filename: String,
282 }
283
284 let dir = match get_staged_entries {
285 true => {
286 let dir = boot_dir.open_dir_optional(TYPE1_ENT_PATH_STAGED)?;
287
288 let Some(dir) = dir else {
289 return Ok(vec![]);
290 };
291
292 dir.read_dir(".")?
293 }
294
295 false => boot_dir.read_dir(TYPE1_ENT_PATH)?,
296 };
297
298 let mut configs_with_filenames = vec![];
299
300 for entry in dir {
301 let entry = entry?;
302
303 let file_name = entry.file_name();
304
305 let file_name = file_name
306 .to_str()
307 .ok_or(anyhow::anyhow!("Found non UTF-8 characters in filename"))?;
308
309 if !file_name.ends_with(".conf") {
310 continue;
311 }
312
313 let mut file = entry
314 .open()
315 .with_context(|| format!("Failed to open {:?}", file_name))?;
316
317 let mut contents = String::new();
318 file.read_to_string(&mut contents)
319 .with_context(|| format!("Failed to read {:?}", file_name))?;
320
321 let config = parse_bls_config(&contents).context("Parsing bls config")?;
322
323 configs_with_filenames.push(ConfigWithFilename {
324 config,
325 filename: file_name.to_string(),
326 });
327 }
328
329 configs_with_filenames.sort_by(|a, b| {
331 let ord = match bootloader {
332 Bootloader::Systemd => a.config.cmp(&b.config),
334 Bootloader::Grub | Bootloader::GrubCC => b.filename.cmp(&a.filename),
337 Bootloader::Ukiboot | Bootloader::None => {
338 unreachable!("Bootloader checked during installation does not use BLS entries")
339 }
340 };
341
342 if ascending { ord } else { ord.reverse() }
343 });
344
345 Ok(configs_with_filenames
346 .into_iter()
347 .map(|c| c.config)
348 .collect())
349}
350
351pub(crate) fn list_type1_entries(boot_dir: &Dir) -> Result<Vec<BootloaderEntry>> {
352 let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?;
354
355 let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?;
358
359 boot_entries
360 .into_iter()
361 .chain(staged_boot_entries)
362 .map(|entry| {
363 Ok(BootloaderEntry {
364 fsverity: entry.get_verity()?,
365 boot_artifact_name: entry.boot_artifact_name()?.to_string(),
366 })
367 })
368 .collect::<Result<Vec<_>, _>>()
369}
370
371#[fn_error_context::context("Listing bootloader entries")]
376pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result<Vec<BootloaderEntry>> {
377 let bootloader = get_bootloader()?;
378 let boot_dir = storage.require_boot_dir()?;
379
380 let entries = match bootloader.kind()? {
381 BootloaderKind::GRUBClassic => {
382 let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?;
384
385 if grub_dir.exists(USER_CFG) {
387 let mut s = String::new();
388 let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?;
389
390 let mut staged = String::new();
391 let boot_entries_staged =
392 get_sorted_grub_uki_boot_entries_staged(boot_dir, &mut staged)?;
393
394 boot_entries
395 .into_iter()
396 .chain(boot_entries_staged)
397 .map(|entry| {
398 Ok(BootloaderEntry {
399 fsverity: entry.get_verity()?,
400 boot_artifact_name: entry.boot_artifact_name()?,
401 })
402 })
403 .collect::<Result<Vec<_>, anyhow::Error>>()?
404 } else {
405 list_type1_entries(boot_dir)?
406 }
407 }
408
409 BootloaderKind::BLSCompatible => list_type1_entries(boot_dir)?,
410 };
411
412 Ok(entries)
413}
414
415#[context("Getting container info")]
420pub(crate) async fn get_container_manifest_and_config(
421 imgref: &ImageReference,
422) -> Result<(ImgConfigManifest, String)> {
423 let mut config = crate::deploy::new_proxy_config();
424
425 ostree_ext::container::merge_default_container_proxy_opts(&mut config)?;
426
427 let proxy = ImageProxy::new_with_config(config).await?;
428
429 let img = proxy
430 .open_image_ref(&imgref)
431 .await
432 .with_context(|| format!("Opening image {imgref}"))?;
433
434 let (manifest_digest, manifest) = proxy.fetch_manifest(&img).await?;
435 let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?;
436
437 let mut buf = Vec::with_capacity(manifest.config().size() as usize);
438 buf.resize(manifest.config().size() as usize, 0);
439 reader.read_exact(&mut buf).await?;
440 driver.await?;
441
442 let config: oci_spec::image::ImageConfiguration = serde_json::from_slice(&buf)?;
443
444 Ok((ImgConfigManifest { manifest, config }, manifest_digest))
445}
446
447const BLS_ENTRIES_DIR: &str = "/boot/loader/entries";
455
456const GRUB_DIRS: [&str; 2] = ["/boot/grub2", "/boot/grub"];
463
464fn classify_bootloader(
486 efi_loader_info: Result<String, EfiError>,
487 bls_entries_dir_present: bool,
488 grub_dir_present: bool,
489) -> Result<Bootloader> {
490 match efi_loader_info {
491 Ok(loader) => {
492 let loader = loader.to_lowercase();
493 if loader.contains("systemd-boot") {
494 Ok(Bootloader::Systemd)
495 } else if loader.contains("grub cc") {
496 Ok(Bootloader::GrubCC)
497 } else {
498 Ok(Bootloader::Grub)
499 }
500 }
501 Err(EfiError::SystemNotUEFI) | Err(EfiError::MissingVar) => {
502 if grub_dir_present {
503 tracing::debug!(
504 "No EFI vars and a GRUB directory is present; treating \
505 bootloader as GRUB even if BLS entries also exist \
506 (GRUB reads them via blscfg)"
507 );
508 Ok(Bootloader::Grub)
509 } else if bls_entries_dir_present {
510 tracing::debug!(
511 "No EFI vars, no GRUB directory, and {BLS_ENTRIES_DIR} is \
512 a directory; treating bootloader as BLS-compatible \
513 (systemd-boot)"
514 );
515 Ok(Bootloader::Systemd)
516 } else {
517 Ok(Bootloader::Grub)
518 }
519 }
520 Err(e) => anyhow::bail!("Failed to read EfiLoaderInfo: {e:?}"),
521 }
522}
523
524#[context("Getting bootloader")]
525pub(crate) fn get_bootloader() -> Result<Bootloader> {
526 static BOOTLOADER: OnceLock<Bootloader> = OnceLock::new();
527
528 if let Some(bootloader) = BOOTLOADER.get() {
529 return Ok(*bootloader);
530 }
531
532 let efi_result = read_uefi_var(EFI_LOADER_INFO);
533 let non_efi = matches!(
534 &efi_result,
535 Err(EfiError::SystemNotUEFI) | Err(EfiError::MissingVar),
536 );
537
538 let bootloader = classify_bootloader(
539 efi_result,
540 non_efi && std::path::Path::new(BLS_ENTRIES_DIR).is_dir(),
543 non_efi && GRUB_DIRS.iter().any(|d| std::path::Path::new(d).is_dir()),
544 )?;
545
546 BOOTLOADER.get_or_init(|| bootloader);
549
550 Ok(bootloader)
551}
552
553#[context("Reading image info for deployment {deployment_id}")]
562pub(crate) fn get_imginfo(
563 storage: &Storage,
564 deployment_id: &str,
565) -> Result<(ImgConfigManifest, String)> {
566 let ini = read_origin(&storage.physical_root, deployment_id)?
567 .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {deployment_id}"))?;
568
569 if let Some(manifest_digest_str) =
571 ini.get::<String>(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST)
572 {
573 let repo = storage.get_ensure_composefs()?;
574 let manifest_digest: composefs_oci::OciDigest = manifest_digest_str
575 .parse()
576 .with_context(|| format!("Parsing manifest digest {manifest_digest_str}"))?;
577 let oci_image = OciImage::<Sha512HashValue>::open(&repo, &manifest_digest, None)
578 .with_context(|| format!("Opening OCI image for manifest {manifest_digest}"))?;
579
580 let manifest = oci_image.manifest().clone();
581 let config = oci_image
582 .config()
583 .cloned()
584 .ok_or_else(|| anyhow::anyhow!("OCI image has no config (artifact?)"))?;
585
586 return Ok((ImgConfigManifest { config, manifest }, manifest_digest_str));
587 }
588
589 let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id);
592 let imginfo_fname = format!("{deployment_id}.imginfo");
593 let path = depl_state_path.join(&imginfo_fname);
594
595 let mut img_conf = storage
596 .physical_root
597 .open_optional(&path)
598 .with_context(|| format!("Opening legacy {imginfo_fname}"))?;
599
600 let Some(img_conf) = &mut img_conf else {
601 anyhow::bail!(
602 "No manifest_digest in origin and no legacy .imginfo file \
603 for deployment {deployment_id}"
604 );
605 };
606
607 let mut buffer = String::new();
608 img_conf.read_to_string(&mut buffer)?;
609
610 let img_conf = serde_json::from_str::<ImgConfigManifest>(&buffer)
611 .context("Failed to parse .imginfo file as JSON")?;
612
613 let mut hasher = Sha256::new();
615 hasher.update(&serde_json::to_vec(&img_conf.manifest).context("Serializing image manifest")?);
616 let manifest_digest_str = hex::encode(hasher.finish());
617 Ok((img_conf, manifest_digest_str))
618}
619
620fn image_version_and_created(
627 manifest: &ImageManifest,
628 config: &ImageConfiguration,
629) -> (Option<String>, Option<chrono::DateTime<chrono::Utc>>) {
630 let version = manifest
631 .annotations()
632 .as_ref()
633 .and_then(|a| a.get(oci_spec::image::ANNOTATION_VERSION).cloned())
634 .or_else(|| ostree_container::version_for_config(config).map(ToOwned::to_owned));
635
636 let created_at = config.created().clone();
637 let timestamp = created_at.and_then(|x| try_deserialize_timestamp(&x));
638 (version, timestamp)
639}
640
641#[context("Getting composefs deployment metadata")]
642fn boot_entry_from_composefs_deployment(
643 storage: &Storage,
644 origin: tini::Ini,
645 verity: &str,
646 missing_verity_allowed: bool,
647 bootloader: Bootloader,
648) -> Result<BootEntry> {
649 let image = match origin.get::<String>("origin", ORIGIN_CONTAINER) {
650 Some(img_name_from_config) => {
651 let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?;
652 let img_ref = crate::spec::ImageReference::from(ostree_img_ref);
653
654 let (img_conf, image_digest) = get_imginfo(storage, &verity)?;
655
656 let architecture = img_conf.config.architecture().to_string();
657 let (version, timestamp) =
658 image_version_and_created(&img_conf.manifest, &img_conf.config);
659
660 Some(ImageStatus {
661 image: img_ref,
662 version,
663 timestamp,
664 image_digest,
665 architecture,
666 })
667 }
668
669 None => None,
671 };
672
673 let boot_type = match origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) {
674 Some(s) => BootType::try_from(s.as_str())?,
675 None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"),
676 };
677
678 let boot_digest = origin.get::<String>(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST);
679
680 let e = BootEntry {
681 image,
682 cached_update: None,
683 incompatible: false,
684 pinned: false,
685 download_only: false, store: None,
687 ostree: None,
688 composefs: Some(crate::spec::BootEntryComposefs {
689 verity: verity.into(),
690 boot_type,
691 bootloader,
692 boot_digest,
693 missing_verity_allowed,
694 }),
695 soft_reboot_capable: false,
696 };
697
698 Ok(e)
699}
700
701#[context("Getting composefs deployment status")]
704pub(crate) async fn get_composefs_status(
705 storage: &crate::store::Storage,
706 booted_cfs: &crate::store::BootedComposefs,
707) -> Result<Host> {
708 composefs_deployment_status_from(&storage, booted_cfs.cmdline).await
709}
710
711#[context("Checking soft reboot capability")]
713fn set_soft_reboot_capability(
714 storage: &Storage,
715 host: &mut Host,
716 bls_entries: Option<Vec<BLSConfig>>,
717 booted_cmdline: &ComposefsCmdline,
718) -> Result<()> {
719 let booted = host.require_composefs_booted()?;
720
721 match booted.boot_type {
722 BootType::Bls => {
723 let mut bls_entries =
724 bls_entries.ok_or_else(|| anyhow::anyhow!("BLS entries not provided"))?;
725
726 let staged_entries =
727 get_sorted_staged_type1_boot_entries(storage.require_boot_dir()?, false)?;
728
729 bls_entries.extend(staged_entries);
732
733 set_reboot_capable_type1_deployments(storage, booted_cmdline, host, bls_entries)
734 }
735
736 BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host),
737 BootType::Aboot => Ok(()),
738 }
739}
740
741fn find_bls_entry<'a>(
742 verity: &str,
743 bls_entries: &'a Vec<BLSConfig>,
744) -> Result<Option<&'a BLSConfig>> {
745 for ent in bls_entries {
746 if ent.get_verity()? == *verity {
747 return Ok(Some(ent));
748 }
749 }
750
751 Ok(None)
752}
753
754fn compare_cmdline_skip_cfs(first: &Cmdline<'_>, second: &Cmdline<'_>) -> bool {
756 for param in first {
757 if param.key() == KARG_V2.into() || param.key() == KARG_COMPOSEFS_DIGEST.into() {
758 continue;
759 }
760
761 let second_param = second.iter().find(|b| *b == param);
762
763 let Some(found_param) = second_param else {
764 return false;
765 };
766
767 if found_param.value() != param.value() {
768 return false;
769 }
770 }
771
772 return true;
773}
774
775#[context("Setting soft reboot capability for Type1 entries")]
776fn set_reboot_capable_type1_deployments(
777 storage: &Storage,
778 booted_cmdline: &ComposefsCmdline,
779 host: &mut Host,
780 bls_entries: Vec<BLSConfig>,
781) -> Result<()> {
782 let booted = host
783 .status
784 .booted
785 .as_ref()
786 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
787
788 let booted_boot_digest = booted.composefs_boot_digest()?;
789
790 let booted_bls_entry = find_bls_entry(&*booted_cmdline.digest, &bls_entries)?
791 .ok_or_else(|| anyhow::anyhow!("Booted BLS entry not found"))?;
792
793 let booted_full_cmdline = booted_bls_entry.get_cmdline()?;
794
795 let booted_info = DeploymentBootInfo {
796 boot_digest: booted_boot_digest,
797 full_cmdline: booted_full_cmdline,
798 verity: &booted_cmdline.digest,
799 };
800
801 for depl in host
802 .status
803 .staged
804 .iter_mut()
805 .chain(host.status.rollback.iter_mut())
806 .chain(host.status.other_deployments.iter_mut())
807 {
808 let depl_verity = &depl.require_composefs()?.verity;
809
810 let entry = find_bls_entry(&depl_verity, &bls_entries)?
811 .ok_or_else(|| anyhow::anyhow!("Entry not found"))?;
812
813 let depl_cmdline = entry.get_cmdline()?;
814
815 let target_info = DeploymentBootInfo {
816 boot_digest: depl.composefs_boot_digest()?,
817 full_cmdline: depl_cmdline,
818 verity: &depl_verity,
819 };
820
821 depl.soft_reboot_capable =
822 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
823 }
824
825 Ok(())
826}
827
828fn is_soft_rebootable(
838 storage: &Storage,
839 booted_cmdline: &ComposefsCmdline,
840 booted: &DeploymentBootInfo,
841 target: &DeploymentBootInfo,
842) -> Result<bool> {
843 if target.boot_digest != booted.boot_digest {
844 tracing::debug!("Soft reboot not allowed due to kernel skew");
845 return Ok(false);
846 }
847
848 if target.full_cmdline.as_bytes().len() != booted.full_cmdline.as_bytes().len() {
849 tracing::debug!("Soft reboot not allowed due to differing cmdline");
850 return Ok(false);
851 }
852
853 let cmdline_eq = compare_cmdline_skip_cfs(target.full_cmdline, booted.full_cmdline)
854 && compare_cmdline_skip_cfs(booted.full_cmdline, target.full_cmdline);
855
856 let selinux_compatible =
857 are_selinux_policies_compatible(storage, booted_cmdline, target.verity)?;
858
859 return Ok(cmdline_eq && selinux_compatible);
860}
861
862#[context("Setting soft reboot capability for UKI deployments")]
863fn set_reboot_capable_uki_deployments(
864 storage: &Storage,
865 booted_cmdline: &ComposefsCmdline,
866 host: &mut Host,
867) -> Result<()> {
868 let booted = host
869 .status
870 .booted
871 .as_ref()
872 .ok_or_else(|| anyhow::anyhow!("Failed to find booted entry"))?;
873
874 let booted_boot_digest = match booted.composefs_boot_digest() {
876 Ok(d) => d,
877 Err(_) => &compute_store_boot_digest_for_uki(storage, &booted_cmdline.digest)?,
878 };
879
880 let booted_full_cmdline = get_uki_cmdline(storage, &booted_cmdline.digest)?;
881
882 let booted_info = DeploymentBootInfo {
883 boot_digest: booted_boot_digest,
884 full_cmdline: &booted_full_cmdline,
885 verity: &booted_cmdline.digest,
886 };
887
888 for deployment in host
889 .status
890 .staged
891 .iter_mut()
892 .chain(host.status.rollback.iter_mut())
893 .chain(host.status.other_deployments.iter_mut())
894 {
895 let depl_verity = &deployment.require_composefs()?.verity;
896
897 let depl_boot_digest = match deployment.composefs_boot_digest() {
899 Ok(d) => d,
900 Err(_) => &compute_store_boot_digest_for_uki(storage, depl_verity)?,
901 };
902
903 let depl_cmdline = get_uki_cmdline(storage, &deployment.require_composefs()?.verity)?;
904
905 let target_info = DeploymentBootInfo {
906 boot_digest: depl_boot_digest,
907 full_cmdline: &depl_cmdline,
908 verity: depl_verity,
909 };
910
911 deployment.soft_reboot_capable =
912 is_soft_rebootable(storage, booted_cmdline, &booted_info, &target_info)?;
913 }
914
915 Ok(())
916}
917
918#[context("Determining if rollback is queued")]
921fn rollback_queued_from_first_entry(
922 bls_config: &BLSConfig,
923 booted_composefs_digest: &str,
924) -> Result<bool> {
925 match &bls_config.cfg_type {
926 BLSConfigType::EFI { key } => {
928 let path = match key {
929 EFIKey::Efi(path) | EFIKey::Uki(path) => path,
930 };
931 Ok(!path.as_str().contains(booted_composefs_digest))
932 }
933
934 BLSConfigType::NonEFI { options, .. } => Ok(!options
936 .as_ref()
937 .ok_or_else(|| anyhow::anyhow!("options key not found in bls config"))?
938 .contains(booted_composefs_digest)),
939
940 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
941 }
942}
943
944fn read_staged_deployment() -> Result<Option<StagedDeployment>> {
945 let path = format!("{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}");
946 let contents = match std::fs::read_to_string(&path) {
947 Ok(contents) => contents,
948 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
949 Err(err) => return Err(err).with_context(|| format!("Reading {path}")),
950 };
951 serde_json::from_str(&contents)
952 .with_context(|| format!("Parsing {path}"))
953 .map(Some)
954}
955
956fn aboot_entry(
957 storage: &Storage,
958 verity: &str,
959 missing_verity_allowed: bool,
960 bootloader: Bootloader,
961) -> Result<BootEntry> {
962 let origin = read_origin(&storage.physical_root, verity)?
963 .ok_or_else(|| anyhow::anyhow!("No origin file for aboot deployment {verity}"))?;
964 let entry = boot_entry_from_composefs_deployment(
965 storage,
966 origin,
967 verity,
968 missing_verity_allowed,
969 bootloader,
970 )?;
971 ensure!(
972 entry.require_composefs()?.boot_type == BootType::Aboot,
973 "Slot references non-aboot deployment {verity}"
974 );
975 Ok(entry)
976}
977
978fn composefs_aboot_status(
979 storage: &Storage,
980 cmdline: &ComposefsCmdline,
981 bootloader: Bootloader,
982) -> Result<Host> {
983 let state = aboot::AbootState::open(&storage.physical_root)?;
984 let staged = match state.read_pending()? {
985 Some(pending) => Some(pending.staged()),
986 None => read_staged_deployment()?,
987 };
988 composefs_aboot_status_from(storage, &state, cmdline, staged.as_ref(), bootloader)
989}
990
991fn composefs_aboot_status_from(
992 storage: &Storage,
993 state: &aboot::AbootState<'_>,
994 cmdline: &ComposefsCmdline,
995 staged: Option<&StagedDeployment>,
996 bootloader: Bootloader,
997) -> Result<Host> {
998 let mut host = Host::new(HostSpec {
999 image: None,
1000 boot_order: BootOrder::Default,
1001 });
1002
1003 host.status.booted = Some(aboot_entry(
1004 storage,
1005 &cmdline.digest,
1006 cmdline.allow_missing_fsverity,
1007 bootloader,
1008 )?);
1009
1010 if let Some(staged) = staged.filter(|s| s.depl_id != *cmdline.digest) {
1011 let mut entry = aboot_entry(
1012 storage,
1013 &staged.depl_id,
1014 cmdline.allow_missing_fsverity,
1015 bootloader,
1016 )?;
1017 entry.download_only = staged.finalization_locked;
1018 host.status.staged = Some(entry);
1019 }
1020
1021 let mut seen = vec![cmdline.digest.to_string()];
1022 if let Some(staged) = host.status.staged.as_ref() {
1023 seen.push(staged.require_composefs()?.verity.clone());
1024 }
1025 for deployment in state.slot_deployments()? {
1026 let verity = deployment.to_hex();
1027 if seen.contains(&verity) {
1028 continue;
1029 }
1030 seen.push(verity.clone());
1031 let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity, bootloader)?;
1032 if host.status.rollback.is_none() {
1033 host.status.rollback = Some(entry);
1034 } else {
1035 host.status.other_deployments.push(entry);
1036 }
1037 }
1038
1039 if let Some(queued) = state.queued_rollback()? {
1040 let rollback =
1041 host.status.rollback.as_ref().ok_or_else(|| {
1042 anyhow::anyhow!("Queued aboot rollback deployment is unavailable")
1043 })?;
1044 ensure!(
1045 rollback.require_composefs()?.verity == queued,
1046 "Queued aboot rollback does not match the rollback deployment"
1047 );
1048 host.status.rollback_queued = true;
1049 host.spec.boot_order = BootOrder::Rollback;
1050 }
1051
1052 host.spec.image = host
1053 .status
1054 .staged
1055 .as_ref()
1056 .or(host.status.booted.as_ref())
1057 .and_then(|entry| entry.image.as_ref())
1058 .map(|image| image.image.clone());
1059 host.status.usr_overlay = get_usr_overlay_status().ok().flatten();
1060 Ok(host)
1061}
1062
1063#[context("Getting composefs deployment status")]
1064async fn composefs_deployment_status_from(
1065 storage: &Storage,
1066 cmdline: &ComposefsCmdline,
1067) -> Result<Host> {
1068 let booted_composefs_digest = &cmdline.digest;
1069
1070 if read_boot_type(&storage.physical_root, booted_composefs_digest)? == Some(BootType::Aboot) {
1071 return composefs_aboot_status(storage, cmdline, aboot::get_bootloader()?);
1072 }
1073
1074 let boot_dir = storage.require_boot_dir()?;
1075
1076 let bootloader_entry_verity = list_bootloader_entries(storage)?;
1078
1079 let host_spec = HostSpec {
1080 image: None,
1081 boot_order: BootOrder::Default,
1082 };
1083
1084 let mut host = Host::new(host_spec);
1085
1086 let staged_deployment = read_staged_deployment()?;
1087
1088 let mut boot_type: Option<BootType> = None;
1089 let bootloader = get_bootloader()?;
1090
1091 let mut extra_deployment_boot_entries: Vec<BootEntry> = Vec::new();
1094
1095 for BootloaderEntry {
1096 fsverity: verity_digest,
1097 ..
1098 } in bootloader_entry_verity
1099 {
1100 let ini = read_origin(&storage.physical_root, &verity_digest)?;
1101
1102 let Some(ini) = ini else {
1103 const STATUS_JOURNAL_ID: &str = "d264f924dadb4c31bff0412107d391fb";
1104
1105 tracing::warn!(
1106 message_id = STATUS_JOURNAL_ID,
1107 bootc.operation = "status",
1108 "No origin file for deployment {verity_digest}"
1109 );
1110
1111 continue;
1112 };
1113
1114 let mut boot_entry = boot_entry_from_composefs_deployment(
1115 storage,
1116 ini,
1117 &verity_digest,
1118 cmdline.allow_missing_fsverity,
1119 bootloader,
1120 )?;
1121
1122 let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type;
1124
1125 match boot_type {
1126 Some(current_type) => {
1127 if current_type != boot_type_from_origin {
1128 anyhow::bail!("Conflicting boot types")
1129 }
1130 }
1131
1132 None => {
1133 boot_type = Some(boot_type_from_origin);
1134 }
1135 };
1136
1137 if verity_digest == booted_composefs_digest.as_ref() {
1138 host.status.booted = Some(boot_entry);
1139 continue;
1140 }
1141
1142 if let Some(staged_deployment) = &staged_deployment {
1143 if verity_digest == staged_deployment.depl_id {
1144 boot_entry.download_only = staged_deployment.finalization_locked;
1145 host.status.staged = Some(boot_entry);
1146 continue;
1147 }
1148 }
1149
1150 extra_deployment_boot_entries.push(boot_entry);
1151 }
1152
1153 host.spec.image = host
1155 .status
1156 .staged
1157 .as_ref()
1158 .or(host.status.booted.as_ref())
1159 .and_then(|entry| entry.image.as_ref())
1160 .map(|img| img.image.clone());
1161
1162 let Some(boot_type) = boot_type else {
1164 anyhow::bail!("Could not determine boot type");
1165 };
1166
1167 let booted_cfs = host.require_composefs_booted()?;
1168
1169 let mut grub_menu_string = String::new();
1170 let (is_rollback_queued, sorted_bls_config, grub_menu_entries) =
1171 match booted_cfs.bootloader.kind()? {
1172 BootloaderKind::GRUBClassic => match boot_type {
1173 BootType::Bls => {
1174 let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?;
1175 let bls_config = bls_configs
1176 .first()
1177 .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?;
1178
1179 match &bls_config.cfg_type {
1180 BLSConfigType::NonEFI { .. } => {
1181 let is_rollback_queued = rollback_queued_from_first_entry(
1182 bls_config,
1183 booted_composefs_digest.as_ref(),
1184 )?;
1185
1186 (is_rollback_queued, Some(bls_configs), None)
1187 }
1188
1189 BLSConfigType::EFI { .. } => {
1190 anyhow::bail!("Found 'efi' field in Type1 boot entry")
1191 }
1192
1193 BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"),
1194 }
1195 }
1196
1197 BootType::Uki => {
1198 let menuentries =
1199 get_sorted_grub_uki_boot_entries(boot_dir, &mut grub_menu_string)?;
1200
1201 let is_rollback_queued = !menuentries
1202 .first()
1203 .ok_or(anyhow::anyhow!("First boot entry not found"))?
1204 .body
1205 .chainloader
1206 .contains(booted_composefs_digest.as_ref());
1207
1208 (is_rollback_queued, None, Some(menuentries))
1209 }
1210 BootType::Aboot => {
1211 anyhow::bail!("Aboot deployment unexpectedly reached GRUB status handling")
1212 }
1213 },
1214
1215 BootloaderKind::BLSCompatible => {
1217 let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?;
1218 let bls_config = bls_configs
1219 .first()
1220 .ok_or(anyhow::anyhow!("First boot entry not found"))?;
1221
1222 let is_rollback_queued =
1223 rollback_queued_from_first_entry(bls_config, booted_composefs_digest.as_ref())?;
1224
1225 (is_rollback_queued, Some(bls_configs), None)
1226 }
1227 };
1228
1229 let bootloader_configured_verity = sorted_bls_config
1234 .iter()
1235 .flatten()
1236 .map(|cfg| cfg.get_verity())
1237 .chain(
1238 grub_menu_entries
1239 .iter()
1240 .flatten()
1241 .map(|menu| menu.get_verity()),
1242 )
1243 .collect::<Result<Vec<_>>>()?;
1244
1245 let mut rollback_candidates: Vec<_> = extra_deployment_boot_entries
1246 .into_iter()
1247 .filter(|entry| {
1248 let verity = &entry
1249 .composefs
1250 .as_ref()
1251 .expect("composefs is always Some for composefs deployments")
1252 .verity;
1253 bootloader_configured_verity.contains(verity)
1254 })
1255 .collect();
1256
1257 rollback_candidates.sort_by_key(|ent| {
1262 bootloader_configured_verity
1263 .iter()
1264 .position(|v| ent.composefs.as_ref().unwrap().verity == *v)
1266 });
1267
1268 if !rollback_candidates.is_empty() {
1269 let mut iter = rollback_candidates.into_iter();
1270
1271 host.status.rollback = iter.next();
1272 host.status.other_deployments = iter.collect();
1273 }
1274
1275 host.status.rollback_queued = is_rollback_queued;
1276
1277 if host.status.rollback_queued {
1278 host.spec.boot_order = BootOrder::Rollback
1279 };
1280
1281 host.status.usr_overlay = get_usr_overlay_status().ok().flatten();
1282
1283 set_soft_reboot_capability(storage, &mut host, sorted_bls_config, cmdline)?;
1284
1285 Ok(host)
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use cap_std_ext::{cap_std, dirext::CapStdExtDirExt};
1291
1292 use crate::bootc_composefs::aboot;
1293 use crate::bootc_composefs::boot::{
1294 FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key,
1295 secondary_sort_key, type1_entry_conf_file_name,
1296 };
1297 use crate::composefs_consts::{ABOOT_STATE_DIR, STATE_DIR_RELATIVE};
1298 use crate::parsers::grub_menuconfig::MenuentryBody;
1299
1300 use super::*;
1301
1302 fn write_aboot_origin(root: &Dir, digest: &str) -> Result<()> {
1303 let path = format!("{STATE_DIR_RELATIVE}/{digest}");
1304 root.create_dir_all(&path)?;
1305 root.atomic_write(
1306 format!("{path}/{digest}.origin"),
1307 "[boot]\nboot_type=aboot\ndigest=boot-digest\n",
1308 )?;
1309 Ok(())
1310 }
1311
1312 #[test]
1313 fn aboot_status_uses_host_bootloader() -> Result<()> {
1314 let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1315 root.create_dir("run")?;
1316 let run = root.open_dir("run")?;
1317 let storage = Storage::new_composefs_for_test(&root, &run)?;
1318 let a = "aa".repeat(64);
1319 let b = "bb".repeat(64);
1320 let staged_id = "cc".repeat(64);
1321 for digest in [&a, &b, &staged_id] {
1322 write_aboot_origin(&root, digest)?;
1323 }
1324 let mut state = aboot::AbootState::open(&root)?;
1325 state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?;
1326 state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?;
1327 let cmdline = ComposefsCmdline::new(&a);
1328 let staged = StagedDeployment {
1329 depl_id: staged_id,
1330 finalization_locked: false,
1331 };
1332 for bootloader in [Bootloader::None, Bootloader::Ukiboot] {
1333 let host =
1334 composefs_aboot_status_from(&storage, &state, &cmdline, Some(&staged), bootloader)?;
1335 assert_eq!(host.list_deployments().len(), 3);
1336 for entry in host.list_deployments() {
1337 assert_eq!(entry.require_composefs()?.bootloader, bootloader);
1338 }
1339 }
1340 Ok(())
1341 }
1342
1343 #[test]
1344 fn aboot_status_from_slots() -> Result<()> {
1345 let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1346 root.create_dir("run")?;
1347 let run = root.open_dir("run")?;
1348 let storage = Storage::new_composefs_for_test(&root, &run)?;
1349 let a = "aa".repeat(64);
1350 let b = "bb".repeat(64);
1351 let staged_id = "cc".repeat(64);
1352 for digest in [&a, &b, &staged_id] {
1353 write_aboot_origin(&root, digest)?;
1354 }
1355 let mut aboot_state = aboot::AbootState::open(&root)?;
1356 aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?;
1357 aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?;
1358
1359 let cmdline = ComposefsCmdline::new(&a);
1360 let host =
1361 composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?;
1362 let booted = host.require_composefs_booted()?;
1363 assert_eq!(booted.verity, a);
1364 assert_eq!(booted.boot_type, BootType::Aboot);
1365 assert_eq!(booted.bootloader, Bootloader::None);
1366 assert_eq!(
1367 host.status
1368 .rollback
1369 .as_ref()
1370 .unwrap()
1371 .require_composefs()?
1372 .verity,
1373 b
1374 );
1375 assert!(host.status.staged.is_none());
1376 assert!(host.status.other_deployments.is_empty());
1377 assert!(!host.status.rollback_queued);
1378
1379 aboot_state.queue_rollback(&b)?;
1380 let host =
1381 composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?;
1382 assert!(host.status.rollback_queued);
1383 assert_eq!(host.spec.boot_order, BootOrder::Rollback);
1384 aboot_state.clear_rollback()?;
1385
1386 let staged = StagedDeployment {
1387 depl_id: staged_id.clone(),
1388 finalization_locked: true,
1389 };
1390 let host = composefs_aboot_status_from(
1391 &storage,
1392 &aboot_state,
1393 &cmdline,
1394 Some(&staged),
1395 Bootloader::None,
1396 )?;
1397 let staged_entry = host.status.staged.as_ref().unwrap();
1398 assert_eq!(staged_entry.require_composefs()?.verity, staged_id);
1399 assert!(staged_entry.download_only);
1400 assert_eq!(
1401 host.status
1402 .rollback
1403 .as_ref()
1404 .unwrap()
1405 .require_composefs()?
1406 .verity,
1407 b
1408 );
1409
1410 let staged = StagedDeployment {
1411 depl_id: b.clone(),
1412 finalization_locked: false,
1413 };
1414 let host = composefs_aboot_status_from(
1415 &storage,
1416 &aboot_state,
1417 &cmdline,
1418 Some(&staged),
1419 Bootloader::None,
1420 )?;
1421 assert_eq!(
1422 host.status
1423 .staged
1424 .as_ref()
1425 .unwrap()
1426 .require_composefs()?
1427 .verity,
1428 b
1429 );
1430 assert!(host.status.rollback.is_none());
1431
1432 root.atomic_write(format!("{ABOOT_STATE_DIR}/slots/b"), "invalid")?;
1433 let host =
1434 composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None, Bootloader::None)?;
1435 assert!(host.status.rollback.is_none());
1436 assert_eq!(host.list_deployments().len(), 1);
1437
1438 let pending = aboot_state.stage_artifacts(&staged_id, true, b"boot", None)?;
1439 aboot_state.write_pending(&pending)?;
1440 let host = composefs_aboot_status(&storage, &cmdline, Bootloader::None)?;
1441 assert!(host.status.staged.as_ref().unwrap().download_only);
1442 Ok(())
1443 }
1444
1445 #[test]
1446 fn test_image_version_and_created() {
1447 use std::collections::HashMap;
1448 use std::str::FromStr;
1449
1450 use ostree_ext::container::{LABEL_OCI_VERSION, LABEL_VERSION};
1451 use ostree_ext::oci_spec::image::{
1452 ANNOTATION_CREATED, ANNOTATION_VERSION, ConfigBuilder, Descriptor, Digest,
1453 ImageConfigurationBuilder, ImageManifestBuilder, MediaType, SCHEMA_VERSION,
1454 };
1455
1456 const CREATED: &str = "2024-06-01T12:00:00Z";
1457 const ANNOTATION_CREATED_DECOY: &str = "1999-01-01T00:00:00Z";
1458 const LABEL_CREATED_DECOY: &str = "1980-01-01T00:00:00Z";
1459
1460 struct Case {
1461 desc: &'static str,
1462 annotation_version: Option<&'static str>,
1463 labels: &'static [(&'static str, &'static str)],
1464 created: Option<&'static str>,
1465 expect_version: Option<&'static str>,
1466 expect_timestamp: Option<&'static str>,
1467 }
1468 let cases = [
1469 Case {
1470 desc: "annotation only",
1471 annotation_version: Some("ann-1"),
1472 labels: &[(ANNOTATION_CREATED, LABEL_CREATED_DECOY)],
1473 created: Some(CREATED),
1474 expect_version: Some("ann-1"),
1475 expect_timestamp: Some(CREATED),
1476 },
1477 Case {
1478 desc: "configuration label only",
1479 annotation_version: None,
1480 labels: &[(LABEL_OCI_VERSION, "label-1")],
1481 created: Some(CREATED),
1482 expect_version: Some("label-1"),
1483 expect_timestamp: Some(CREATED),
1484 },
1485 Case {
1486 desc: "legacy version label only",
1487 annotation_version: None,
1488 labels: &[(LABEL_VERSION, "legacy-1")],
1489 created: Some(CREATED),
1490 expect_version: Some("legacy-1"),
1491 expect_timestamp: Some(CREATED),
1492 },
1493 Case {
1494 desc: "annotation precedes configuration labels",
1495 annotation_version: Some("ann-wins"),
1496 labels: &[
1497 (LABEL_OCI_VERSION, "label-loses"),
1498 (LABEL_VERSION, "legacy-loses"),
1499 ],
1500 created: Some(CREATED),
1501 expect_version: Some("ann-wins"),
1502 expect_timestamp: Some(CREATED),
1503 },
1504 Case {
1505 desc: "missing version keeps a config created timestamp",
1506 annotation_version: None,
1507 labels: &[],
1508 created: Some(CREATED),
1509 expect_version: None,
1510 expect_timestamp: Some(CREATED),
1511 },
1512 Case {
1513 desc: "invalid config created timestamp",
1514 annotation_version: None,
1515 labels: &[],
1516 created: Some("not-a-timestamp"),
1517 expect_version: None,
1518 expect_timestamp: None,
1519 },
1520 ];
1521
1522 for case in cases {
1523 let mut annotations = HashMap::new();
1524 if let Some(version) = case.annotation_version {
1525 annotations.insert(ANNOTATION_VERSION.to_owned(), version.to_owned());
1526 }
1527 annotations.insert(
1528 ANNOTATION_CREATED.to_owned(),
1529 ANNOTATION_CREATED_DECOY.to_owned(),
1530 );
1531 let config_desc = Descriptor::new(
1532 MediaType::ImageConfig,
1533 0,
1534 Digest::from_str(
1535 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1536 )
1537 .unwrap(),
1538 );
1539 let manifest = ImageManifestBuilder::default()
1540 .schema_version(SCHEMA_VERSION)
1541 .config(config_desc)
1542 .layers(Vec::<Descriptor>::new())
1543 .annotations(annotations)
1544 .build()
1545 .unwrap();
1546
1547 let mut image = ImageConfigurationBuilder::default();
1548 if !case.labels.is_empty() {
1549 let labels: HashMap<String, String> = case
1550 .labels
1551 .iter()
1552 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1553 .collect();
1554 let config = ConfigBuilder::default().labels(labels).build().unwrap();
1555 image = image.config(config);
1556 }
1557 if let Some(created) = case.created {
1558 image = image.created(created);
1559 }
1560 let image = image.build().unwrap();
1561
1562 let (version, timestamp) = image_version_and_created(&manifest, &image);
1563 assert_eq!(version.as_deref(), case.expect_version, "{}", case.desc);
1564 assert_eq!(
1565 timestamp,
1566 case.expect_timestamp.and_then(try_deserialize_timestamp),
1567 "{}",
1568 case.desc
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn test_composefs_parsing() {
1575 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
1576 let v = ComposefsCmdline::new(DIGEST);
1577 assert!(!v.allow_missing_fsverity);
1578 assert_eq!(v.digest.as_ref(), DIGEST);
1579 let v = ComposefsCmdline::new(&format!("?{}", DIGEST));
1580 assert!(v.allow_missing_fsverity);
1581 assert_eq!(v.digest.as_ref(), DIGEST);
1582 }
1583
1584 #[test]
1585 fn test_build_composefs_karg() -> Result<()> {
1586 let hex = "ab".repeat(64);
1587 let digest = || Sha512HashValue::from_hex(&hex).unwrap();
1588
1589 assert_eq!(
1590 build_composefs_karg(digest(), FormatVersion::V1, false),
1591 format!("composefs.digest=v1-sha512-12:{hex}")
1592 );
1593 assert_eq!(
1594 build_composefs_karg(digest(), FormatVersion::V2, true),
1595 format!("composefs=?{hex}")
1596 );
1597
1598 let cmdline = Cmdline::from(format!("composefs.digest=v1-sha512-12:{hex}"));
1599 assert_eq!(
1600 ComposefsCmdline::find_in_cmdline(&cmdline)?
1601 .expect("composefs argument should be present")
1602 .digest
1603 .as_ref(),
1604 hex
1605 );
1606
1607 Ok(())
1608 }
1609
1610 #[test]
1611 fn classify_bootloader_cases() {
1612 struct Case {
1613 desc: &'static str,
1614 efi: Result<String, EfiError>,
1615 bls: bool,
1616 grub_dir: bool,
1617 expected: Bootloader,
1618 }
1619 let cases = [
1620 Case {
1621 desc: "UEFI, EFI_LOADER_INFO advertises systemd-boot",
1622 efi: Ok("systemd-boot 261.2".into()),
1623 bls: false,
1624 grub_dir: false,
1625 expected: Bootloader::Systemd,
1626 },
1627 Case {
1628 desc: "UEFI, EFI_LOADER_INFO advertises GRUB CC",
1629 efi: Ok("GRUB CC 2.12".into()),
1630 bls: false,
1631 grub_dir: false,
1632 expected: Bootloader::GrubCC,
1633 },
1634 Case {
1635 desc: "UEFI, EFI_LOADER_INFO advertises unknown; default GRUB",
1636 efi: Ok("something else 1.0".into()),
1637 bls: false,
1638 grub_dir: false,
1639 expected: Bootloader::Grub,
1640 },
1641 Case {
1642 desc: "Non-EFI + BLS layout present: BLS (regression fix)",
1643 efi: Err(EfiError::SystemNotUEFI),
1644 bls: true,
1645 grub_dir: false,
1646 expected: Bootloader::Systemd,
1647 },
1648 Case {
1649 desc: "Non-EFI + no BLS layout: fall back to GRUB",
1650 efi: Err(EfiError::SystemNotUEFI),
1651 bls: false,
1652 grub_dir: false,
1653 expected: Bootloader::Grub,
1654 },
1655 Case {
1656 desc: "EFI mounted but EFI_LOADER_INFO missing, BLS present",
1657 efi: Err(EfiError::MissingVar),
1658 bls: true,
1659 grub_dir: false,
1660 expected: Bootloader::Systemd,
1661 },
1662 Case {
1663 desc: "EFI mounted but EFI_LOADER_INFO missing, no BLS: GRUB",
1664 efi: Err(EfiError::MissingVar),
1665 bls: false,
1666 grub_dir: false,
1667 expected: Bootloader::Grub,
1668 },
1669 Case {
1674 desc: "Non-EFI + BLS layout + GRUB dir: GRUB wins (blscfg)",
1675 efi: Err(EfiError::SystemNotUEFI),
1676 bls: true,
1677 grub_dir: true,
1678 expected: Bootloader::Grub,
1679 },
1680 Case {
1681 desc: "Non-EFI + GRUB dir, no BLS: GRUB",
1682 efi: Err(EfiError::SystemNotUEFI),
1683 bls: false,
1684 grub_dir: true,
1685 expected: Bootloader::Grub,
1686 },
1687 Case {
1688 desc: "EFI_LOADER_INFO missing + BLS + GRUB dir: GRUB wins",
1689 efi: Err(EfiError::MissingVar),
1690 bls: true,
1691 grub_dir: true,
1692 expected: Bootloader::Grub,
1693 },
1694 Case {
1697 desc: "Non-EFI + BLS, no GRUB dir: still BLS (Pi 5, U-Boot)",
1698 efi: Err(EfiError::SystemNotUEFI),
1699 bls: true,
1700 grub_dir: false,
1701 expected: Bootloader::Systemd,
1702 },
1703 Case {
1705 desc: "UEFI systemd-boot with a stray GRUB dir: still systemd",
1706 efi: Ok("systemd-boot 261.2".into()),
1707 bls: true,
1708 grub_dir: true,
1709 expected: Bootloader::Systemd,
1710 },
1711 ];
1712 for case in cases {
1713 let got = classify_bootloader(case.efi, case.bls, case.grub_dir)
1714 .unwrap_or_else(|e| panic!("{}: {e}", case.desc));
1715 assert_eq!(got, case.expected, "{}", case.desc);
1716 }
1717 }
1718
1719 #[test]
1720 fn classify_bootloader_propagates_other_efi_errors() {
1721 let result = classify_bootloader(
1722 Err(EfiError::InvalidData("test-only synthetic error")),
1723 false,
1724 false,
1725 );
1726 assert!(result.is_err(), "InvalidData should propagate as an error");
1727 }
1728
1729 #[test]
1730 fn test_sorted_bls_boot_entries() -> Result<()> {
1731 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1732
1733 let entry1 = r#"
1734 title Fedora 42.20250623.3.1 (CoreOS)
1735 version fedora-42.0
1736 sort-key 1
1737 linux /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/vmlinuz-5.14.10
1738 initrd /boot/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6/initramfs-5.14.10.img
1739 options root=UUID=abc123 rw composefs=7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6
1740 "#;
1741
1742 let entry2 = r#"
1743 title Fedora 41.20250214.2.0 (CoreOS)
1744 version fedora-42.0
1745 sort-key 2
1746 linux /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/vmlinuz-5.14.10
1747 initrd /boot/febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01/initramfs-5.14.10.img
1748 options root=UUID=abc123 rw composefs=febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01
1749 "#;
1750
1751 tempdir.create_dir_all("loader/entries")?;
1752 tempdir.atomic_write(
1753 "loader/entries/random_file.txt",
1754 "Random file that we won't parse",
1755 )?;
1756 tempdir.atomic_write("loader/entries/entry1.conf", entry1)?;
1757 tempdir.atomic_write("loader/entries/entry2.conf", entry2)?;
1758
1759 let result =
1760 get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)
1761 .unwrap();
1762
1763 assert_eq!(result[0].sort_key.as_ref().unwrap(), "1");
1764 assert_eq!(result[1].sort_key.as_ref().unwrap(), "2");
1765
1766 let result =
1767 get_sorted_type1_boot_entries_helper(&tempdir, false, false, Bootloader::Systemd)
1768 .unwrap();
1769 assert_eq!(result[0].sort_key.as_ref().unwrap(), "2");
1770 assert_eq!(result[1].sort_key.as_ref().unwrap(), "1");
1771
1772 Ok(())
1773 }
1774
1775 #[test]
1776 fn test_rollback_queued_from_first_entry_uki() -> Result<()> {
1777 const BOOTED: &str = "1111111111111111111111111111111111111111111111111111111111111111";
1778 const ROLLBACK: &str = "2222222222222222222222222222222222222222222222222222222222222222";
1779
1780 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1781 tempdir.create_dir_all("loader/entries")?;
1782
1783 let default_entry = format!(
1784 "title Fedora Bootc\nversion 44\nsort-key {}\nuki /EFI/Linux/bootc/bootc_composefs-{BOOTED}.efi\n",
1785 primary_sort_key("fedora")
1786 );
1787 let other_entry = format!(
1788 "title Fedora Bootc\nversion 44\nsort-key {}\nuki /EFI/Linux/bootc/bootc_composefs-{ROLLBACK}.efi\n",
1789 secondary_sort_key("fedora")
1790 );
1791
1792 tempdir.atomic_write(
1796 format!(
1797 "loader/entries/{}",
1798 type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_PRIMARY)
1799 ),
1800 default_entry,
1801 )?;
1802 tempdir.atomic_write(
1803 format!(
1804 "loader/entries/{}",
1805 type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_SECONDARY)
1806 ),
1807 other_entry,
1808 )?;
1809
1810 let sorted =
1811 get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)?;
1812 let first = sorted.first().unwrap();
1813
1814 assert_eq!(
1816 first.sort_key.as_ref().unwrap(),
1817 &primary_sort_key("fedora")
1818 );
1819
1820 assert!(!rollback_queued_from_first_entry(first, BOOTED)?);
1822 assert!(rollback_queued_from_first_entry(first, ROLLBACK)?);
1824
1825 Ok(())
1826 }
1827
1828 #[test]
1829 fn test_rollback_queued_from_first_entry_type1() -> Result<()> {
1830 const BOOTED: &str = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6";
1831 const ROLLBACK: &str = "febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01";
1832
1833 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1834 tempdir.create_dir_all("loader/entries")?;
1835
1836 let default_entry = format!(
1837 "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{BOOTED}/vmlinuz\ninitrd /boot/{BOOTED}/initramfs.img\noptions root=UUID=abc123 rw composefs={BOOTED}\n",
1838 primary_sort_key("fedora")
1839 );
1840 let other_entry = format!(
1841 "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{ROLLBACK}/vmlinuz\ninitrd /boot/{ROLLBACK}/initramfs.img\noptions root=UUID=abc123 rw composefs={ROLLBACK}\n",
1842 secondary_sort_key("fedora")
1843 );
1844
1845 tempdir.atomic_write(
1847 format!(
1848 "loader/entries/{}",
1849 type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_PRIMARY)
1850 ),
1851 default_entry,
1852 )?;
1853 tempdir.atomic_write(
1854 format!(
1855 "loader/entries/{}",
1856 type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_SECONDARY)
1857 ),
1858 other_entry,
1859 )?;
1860
1861 let sorted =
1862 get_sorted_type1_boot_entries_helper(&tempdir, true, false, Bootloader::Systemd)?;
1863 let first = sorted.first().unwrap();
1864
1865 assert_eq!(
1866 first.sort_key.as_ref().unwrap(),
1867 &primary_sort_key("fedora")
1868 );
1869
1870 assert!(!rollback_queued_from_first_entry(first, BOOTED)?);
1871 assert!(rollback_queued_from_first_entry(first, ROLLBACK)?);
1872
1873 Ok(())
1874 }
1875
1876 #[test]
1877 fn test_rollback_queued_from_first_entry_grub_type1() -> Result<()> {
1878 const BOOTED: &str = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6";
1879 const ROLLBACK: &str = "febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01";
1880
1881 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1882 tempdir.create_dir_all("loader/entries")?;
1883
1884 let default_entry = format!(
1885 "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{BOOTED}/vmlinuz\ninitrd /boot/{BOOTED}/initramfs.img\noptions root=UUID=abc123 rw composefs={BOOTED}\n",
1886 primary_sort_key("fedora")
1887 );
1888 let other_entry = format!(
1889 "title Fedora Bootc\nversion 44\nsort-key {}\nlinux /boot/{ROLLBACK}/vmlinuz\ninitrd /boot/{ROLLBACK}/initramfs.img\noptions root=UUID=abc123 rw composefs={ROLLBACK}\n",
1890 secondary_sort_key("fedora")
1891 );
1892
1893 tempdir.atomic_write(
1894 format!(
1895 "loader/entries/{}",
1896 type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_PRIMARY)
1897 ),
1898 default_entry,
1899 )?;
1900 tempdir.atomic_write(
1901 format!(
1902 "loader/entries/{}",
1903 type1_entry_conf_file_name("fedora", 44, FILENAME_PRIORITY_SECONDARY)
1904 ),
1905 other_entry,
1906 )?;
1907
1908 let sorted = get_sorted_type1_boot_entries_helper(
1912 &tempdir,
1913 true,
1914 false,
1915 crate::spec::Bootloader::Grub,
1916 )?;
1917 let first = sorted.first().unwrap();
1918 assert_eq!(
1919 first.sort_key.as_ref().unwrap(),
1920 &primary_sort_key("fedora")
1921 );
1922
1923 assert!(!rollback_queued_from_first_entry(first, BOOTED)?);
1924 assert!(rollback_queued_from_first_entry(first, ROLLBACK)?);
1925
1926 Ok(())
1927 }
1928
1929 #[test]
1930 fn test_sorted_uki_boot_entries() -> Result<()> {
1931 let user_cfg = r#"
1932 if [ -f ${config_directory}/efiuuid.cfg ]; then
1933 source ${config_directory}/efiuuid.cfg
1934 fi
1935
1936 menuentry "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)" {
1937 insmod fat
1938 insmod chain
1939 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
1940 chainloader /EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi
1941 }
1942
1943 menuentry "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)" {
1944 insmod fat
1945 insmod chain
1946 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
1947 chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi
1948 }
1949 "#;
1950
1951 let bootdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1952 bootdir.create_dir_all(format!("grub2"))?;
1953 bootdir.atomic_write(format!("grub2/{USER_CFG}"), user_cfg)?;
1954
1955 let mut s = String::new();
1956 let result = get_sorted_grub_uki_boot_entries(&bootdir, &mut s)?;
1957
1958 let expected = vec![
1959 MenuEntry {
1960 title: "Fedora Bootc UKI: (f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346)".into(),
1961 body: MenuentryBody {
1962 insmod: vec!["fat", "chain"],
1963 chainloader: "/EFI/Linux/f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346.efi".into(),
1964 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
1965 version: 0,
1966 extra: vec![],
1967 },
1968 },
1969 MenuEntry {
1970 title: "Fedora Bootc UKI: (7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6)".into(),
1971 body: MenuentryBody {
1972 insmod: vec!["fat", "chain"],
1973 chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(),
1974 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
1975 version: 0,
1976 extra: vec![],
1977 },
1978 },
1979 ];
1980
1981 assert_eq!(result, expected);
1982
1983 Ok(())
1984 }
1985
1986 #[test]
1987 fn test_find_in_cmdline() -> Result<()> {
1988 const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52";
1989
1990 let cases = [
1991 ("absent", "root=UUID=abc123 rw quiet", None, false),
1992 ("legacy", "composefs={DIGEST}", Some(DIGEST), false),
1993 (
1994 "v1",
1995 "composefs.digest=v1-sha512-12:{DIGEST}",
1996 Some(DIGEST),
1997 false,
1998 ),
1999 (
2000 "dual",
2001 "composefs={DIGEST} composefs.digest=v1-sha512-12:{DIGEST}",
2002 Some(DIGEST),
2003 false,
2004 ),
2005 ("legacy-insecure", "composefs=?{DIGEST}", Some(DIGEST), true),
2006 ("malformed", "composefs=not-a-digest", None, false),
2007 ];
2008
2009 for (name, input, expected_digest, insecure) in cases {
2010 let input = input.replace("{DIGEST}", DIGEST);
2011 let result = ComposefsCmdline::find_in_cmdline(&Cmdline::from(input));
2012 if name == "malformed" {
2013 let error = match result {
2014 Err(error) => error,
2015 Ok(_) => panic!("malformed composefs argument must fail"),
2016 };
2017 assert!(
2018 format!("{error:#}").contains("Parsing composefs kernel command line"),
2019 "{error:#}"
2020 );
2021 assert!(format!("{error:#}").contains("composefs="), "{error:#}");
2022 } else if let Some(expected_digest) = expected_digest {
2023 let cfs = result?.expect("composefs argument should be present");
2024 assert_eq!(cfs.digest.as_ref(), expected_digest);
2025 assert_eq!(cfs.allow_missing_fsverity, insecure);
2026 } else {
2027 assert!(result?.is_none(), "{name} should not match");
2028 }
2029 }
2030
2031 Ok(())
2032 }
2033
2034 #[test]
2035 fn test_reconcile_composefs_cmdline_with_mount_source() -> Result<()> {
2036 let digest_v1 = "ab".repeat(64);
2037 let digest_v2 = "cd".repeat(64);
2038 let cases = [
2039 (
2040 "preferred V1, actual V2, strict",
2041 format!("composefs.digest=v1-sha512-12:{digest_v1}"),
2042 format!("composefs:{digest_v2}"),
2043 digest_v2.as_str(),
2044 false,
2045 false,
2046 ),
2047 (
2048 "preferred V2, actual V2, allow missing, transient",
2049 format!("composefs=?{digest_v1}"),
2050 format!("transient:composefs={digest_v2}"),
2051 digest_v2.as_str(),
2052 true,
2053 true,
2054 ),
2055 (
2056 "matching digest, strict, normal",
2057 format!("composefs={digest_v1}"),
2058 format!("composefs:{digest_v1}"),
2059 digest_v1.as_str(),
2060 false,
2061 false,
2062 ),
2063 (
2064 "matching digest, allow missing, transient",
2065 format!("composefs=?{digest_v1}"),
2066 format!("transient:composefs={digest_v1}"),
2067 digest_v1.as_str(),
2068 true,
2069 true,
2070 ),
2071 ];
2072
2073 for (
2074 name,
2075 cmdline,
2076 mount_source,
2077 expected_digest,
2078 expected_allow_missing,
2079 expected_transient,
2080 ) in cases
2081 {
2082 let parsed = ComposefsCmdline::find_in_cmdline(&Cmdline::from(cmdline))?
2083 .unwrap_or_else(|| panic!("{name}: composefs argument should be present"));
2084 let reconciled = reconcile_composefs_cmdline_with_mount_source(parsed, &mount_source)?;
2085 assert_eq!(reconciled.digest.as_ref(), expected_digest, "{name}");
2086 assert_eq!(
2087 reconciled.allow_missing_fsverity, expected_allow_missing,
2088 "{name}"
2089 );
2090 assert_eq!(reconciled.is_transient, expected_transient, "{name}");
2091 }
2092
2093 let parsed = ComposefsCmdline::new(&digest_v1);
2094 let error = match reconcile_composefs_cmdline_with_mount_source(parsed, "ext4") {
2095 Ok(_) => panic!("invalid mount source must fail"),
2096 Err(error) => error,
2097 };
2098 assert!(
2099 format!("{error:#}").contains("Root not mounted using composefs (source: ext4)"),
2100 "{error:#}"
2101 );
2102
2103 Ok(())
2104 }
2105
2106 use crate::testutils::fake_digest_version;
2107
2108 #[test]
2111 fn test_list_type1_entries_includes_staged() -> Result<()> {
2112 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
2113
2114 let digest_active = fake_digest_version(0);
2115 let digest_staged = fake_digest_version(1);
2116
2117 let active_entry = format!(
2118 r#"
2119 title Active Deployment
2120 version 2
2121 sort-key 1
2122 linux /boot/bootc_composefs-{digest_active}/vmlinuz
2123 initrd /boot/bootc_composefs-{digest_active}/initramfs.img
2124 options root=UUID=abc123 rw composefs={digest_active}
2125 "#
2126 );
2127
2128 let staged_entry = format!(
2129 r#"
2130 title Staged Deployment
2131 version 3
2132 sort-key 0
2133 linux /boot/bootc_composefs-{digest_staged}/vmlinuz
2134 initrd /boot/bootc_composefs-{digest_staged}/initramfs.img
2135 options root=UUID=abc123 rw composefs={digest_staged}
2136 "#
2137 );
2138
2139 tempdir.create_dir_all("loader/entries")?;
2140 tempdir.create_dir_all("loader/entries.staged")?;
2141 tempdir.atomic_write("loader/entries/active.conf", active_entry)?;
2142 tempdir.atomic_write("loader/entries.staged/staged.conf", staged_entry)?;
2143
2144 let result = list_type1_entries(&tempdir)?;
2145 assert_eq!(result.len(), 2);
2146
2147 let verity_set: std::collections::HashSet<&str> =
2148 result.iter().map(|e| e.fsverity.as_str()).collect();
2149 assert!(
2150 verity_set.contains(digest_active.as_str()),
2151 "Should contain active entry"
2152 );
2153 assert!(
2154 verity_set.contains(digest_staged.as_str()),
2155 "Should contain staged entry"
2156 );
2157
2158 Ok(())
2159 }
2160
2161 #[test]
2162 fn test_get_sorted_type1_boot_entries_helper_systemd() -> Result<()> {
2163 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
2164
2165 let entry1 = format!(
2167 r#"
2168 title Fedora Linux (1.0.0)
2169 version 1.0.0
2170 sort-key {}
2171 linux /boot/vmlinuz
2172 initrd /boot/initramfs.img
2173 "#,
2174 secondary_sort_key("fedora")
2175 );
2176
2177 let entry2 = format!(
2178 r#"
2179 title Fedora Linux (2.0.0)
2180 version 2.0.0
2181 sort-key {}
2182 linux /boot/vmlinuz
2183 initrd /boot/initramfs.img
2184 "#,
2185 primary_sort_key("fedora")
2186 );
2187
2188 let entry3 = format!(
2189 r#"
2190 title Fedora Linux (1.5.0)
2191 version 1.5.0
2192 sort-key {}
2193 linux /boot/vmlinuz
2194 initrd /boot/initramfs.img
2195 "#,
2196 primary_sort_key("fedora")
2197 );
2198
2199 tempdir.create_dir_all("loader/entries")?;
2200
2201 let filename1 = type1_entry_conf_file_name("fedora", "1.0.0", FILENAME_PRIORITY_SECONDARY);
2203 let filename2 = type1_entry_conf_file_name("fedora", "2.0.0", FILENAME_PRIORITY_PRIMARY);
2204 let filename3 = type1_entry_conf_file_name("fedora", "1.5.0", FILENAME_PRIORITY_PRIMARY);
2205
2206 tempdir.atomic_write(format!("loader/entries/{}", filename1), entry1)?;
2207 tempdir.atomic_write(format!("loader/entries/{}", filename2), entry2)?;
2208 tempdir.atomic_write(format!("loader/entries/{}", filename3), entry3)?;
2209
2210 let result = get_sorted_type1_boot_entries_helper(
2212 &tempdir,
2213 true,
2214 false,
2215 crate::spec::Bootloader::Systemd,
2216 )?;
2217
2218 assert_eq!(result.len(), 3);
2219 assert_eq!(
2222 result[0].sort_key.as_ref().unwrap(),
2223 &primary_sort_key("fedora")
2224 );
2225 assert_eq!(result[0].version(), "2.0.0".into()); assert_eq!(
2227 result[1].sort_key.as_ref().unwrap(),
2228 &primary_sort_key("fedora")
2229 );
2230 assert_eq!(result[1].version(), "1.5.0".into()); assert_eq!(
2232 result[2].sort_key.as_ref().unwrap(),
2233 &secondary_sort_key("fedora")
2234 );
2235 assert_eq!(result[2].version(), "1.0.0".into()); let result = get_sorted_type1_boot_entries_helper(
2239 &tempdir,
2240 false,
2241 false,
2242 crate::spec::Bootloader::Systemd,
2243 )?;
2244
2245 assert_eq!(result.len(), 3);
2246 assert_eq!(
2248 result[0].sort_key.as_ref().unwrap(),
2249 &secondary_sort_key("fedora")
2250 );
2251 assert_eq!(result[0].version(), "1.0.0".into()); assert_eq!(
2253 result[1].sort_key.as_ref().unwrap(),
2254 &primary_sort_key("fedora")
2255 );
2256 assert_eq!(result[1].version(), "1.5.0".into()); assert_eq!(
2258 result[2].sort_key.as_ref().unwrap(),
2259 &primary_sort_key("fedora")
2260 );
2261 assert_eq!(result[2].version(), "2.0.0".into()); Ok(())
2264 }
2265
2266 #[test]
2267 fn test_get_sorted_type1_boot_entries_helper_grub() -> Result<()> {
2268 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
2269
2270 let entry1 = format!(
2272 r#"
2273 title Fedora Linux (41.20251125.0)
2274 version 41.20251125.0
2275 sort-key {}
2276 linux /boot/vmlinuz
2277 initrd /boot/initramfs.img
2278 "#,
2279 secondary_sort_key("fedora")
2280 );
2281
2282 let entry2 = format!(
2283 r#"
2284 title Fedora Linux (42.20251125.0)
2285 version 42.20251125.0
2286 sort-key {}
2287 linux /boot/vmlinuz
2288 initrd /boot/initramfs.img
2289 "#,
2290 primary_sort_key("fedora")
2291 );
2292
2293 tempdir.create_dir_all("loader/entries")?;
2294
2295 let filename1 =
2297 type1_entry_conf_file_name("fedora", "41.20251125.0", FILENAME_PRIORITY_SECONDARY);
2298 let filename2 =
2299 type1_entry_conf_file_name("fedora", "42.20251125.0", FILENAME_PRIORITY_PRIMARY);
2300
2301 tempdir.atomic_write(format!("loader/entries/{}", filename1), entry1)?;
2302 tempdir.atomic_write(format!("loader/entries/{}", filename2), entry2)?;
2303
2304 let result = get_sorted_type1_boot_entries_helper(
2305 &tempdir,
2306 true,
2307 false,
2308 crate::spec::Bootloader::Grub,
2309 )?;
2310
2311 assert_eq!(result.len(), 2);
2312 assert_eq!(result[0].version(), "42.20251125.0".into());
2316 assert_eq!(result[1].version(), "41.20251125.0".into());
2317
2318 let result = get_sorted_type1_boot_entries_helper(
2320 &tempdir,
2321 false,
2322 false,
2323 crate::spec::Bootloader::Grub,
2324 )?;
2325
2326 assert_eq!(result.len(), 2);
2327 assert_eq!(result[0].version(), "41.20251125.0".into());
2330 assert_eq!(result[1].version(), "42.20251125.0".into());
2331
2332 Ok(())
2333 }
2334}