Skip to main content

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