bootc_lib/bootc_composefs/
status.rs

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