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