Skip to main content

bootc_lib/bootc_composefs/
status.rs

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