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