Skip to main content

bootc_lib/bootc_composefs/
status.rs

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