Skip to main content

bootc_lib/bootc_composefs/
gc.rs

1//! This module handles the case when deleting a deployment fails midway
2//!
3//! There could be the following cases (See ./delete.rs:delete_composefs_deployment):
4//! - We delete the bootloader entry but fail to delete image
5//! - We delete bootloader + image but fail to delete the state/unrefenced objects etc
6
7use anyhow::{Context, Result};
8use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
9use composefs::fsverity::FsVerityHashValue;
10use composefs::repository::GcResult;
11use composefs_boot::bootloader::EFI_EXT;
12use composefs_ctl::composefs;
13use composefs_ctl::composefs_boot;
14use composefs_ctl::composefs_oci;
15
16use crate::{
17    bootc_composefs::{
18        boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name},
19        delete::{delete_staged, delete_state_dir},
20        repo::bootc_tag_for_manifest,
21        state::read_origin,
22        status::{BootloaderEntry, get_composefs_status, list_bootloader_entries},
23    },
24    composefs_consts::{
25        BOOTC_TAG_PREFIX, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, STATE_DIR_RELATIVE,
26        TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX,
27    },
28    store::{BootedComposefs, Storage},
29};
30
31#[fn_error_context::context("Listing state directories")]
32fn list_state_dirs(sysroot: &Dir) -> Result<Vec<String>> {
33    let state = sysroot
34        .open_dir(STATE_DIR_RELATIVE)
35        .context("Opening state dir")?;
36
37    let mut dirs = vec![];
38
39    for dir in state.entries_utf8()? {
40        let dir = dir?;
41
42        if dir.file_type()?.is_file() {
43            continue;
44        }
45
46        dirs.push(dir.file_name()?);
47    }
48
49    Ok(dirs)
50}
51
52type BootBinary = (BootType, String);
53
54/// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem
55///
56/// Returns a vector of binary type (UKI/Type1) + name of all boot binaries
57#[fn_error_context::context("Collecting boot binaries")]
58fn collect_boot_binaries(storage: &Storage) -> Result<Vec<BootBinary>> {
59    let mut boot_binaries = Vec::new();
60    let boot_dir = storage.bls_boot_binaries_dir()?;
61    let esp = storage.require_esp()?;
62
63    // Scan for UKI binaries in EFI/Linux/bootc
64    collect_uki_binaries(&esp.fd, &mut boot_binaries)?;
65
66    // Scan for Type1 boot binaries (kernels + initrds) in `boot_dir`
67    // depending upon whether systemd-boot is being used, or grub
68    collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?;
69
70    Ok(boot_binaries)
71}
72
73/// Scan for UKI binaries in EFI/Linux/bootc
74#[fn_error_context::context("Collecting UKI binaries")]
75fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec<BootBinary>) -> Result<()> {
76    let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(BOOTC_UKI_DIR) else {
77        return Ok(());
78    };
79
80    for entry in efi_dir.entries_utf8()? {
81        let entry = entry?;
82        let name = entry.file_name()?;
83
84        let Some(efi_name_no_prefix) = name.strip_prefix(UKI_NAME_PREFIX) else {
85            continue;
86        };
87
88        if let Some(verity) = efi_name_no_prefix.strip_suffix(EFI_EXT) {
89            boot_binaries.push((BootType::Uki, verity.into()));
90        }
91    }
92
93    Ok(())
94}
95
96/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with
97/// that start with bootc_composefs-
98///
99/// Strips the prefix and returns the rest of the string
100#[fn_error_context::context("Collecting Type1 boot binaries")]
101fn collect_type1_boot_binaries(boot_dir: &Dir, boot_binaries: &mut Vec<BootBinary>) -> Result<()> {
102    for entry in boot_dir.entries_utf8()? {
103        let entry = entry?;
104        let dir_name = entry.file_name()?;
105
106        if !entry.file_type()?.is_dir() {
107            continue;
108        }
109
110        let Some(verity) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
111            continue;
112        };
113
114        // The directory name starts with our custom prefix
115        boot_binaries.push((BootType::Bls, verity.to_string()));
116    }
117
118    Ok(())
119}
120
121#[fn_error_context::context("Deleting kernel and initrd")]
122fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) -> Result<()> {
123    tracing::debug!("Deleting Type1 entry {dir_to_delete}");
124
125    if dry_run {
126        return Ok(());
127    }
128
129    let boot_dir = storage.bls_boot_binaries_dir()?;
130
131    boot_dir
132        .remove_dir_all(dir_to_delete)
133        .with_context(|| anyhow::anyhow!("Deleting {dir_to_delete}"))
134}
135
136/// Deletes the UKI `uki_id` and any addons specific to it
137#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")]
138fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> {
139    let esp_mnt = storage.require_esp()?;
140
141    // NOTE: We don't delete global addons here
142    // Which is fine as global addons don't belong to any single deployment
143    let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?;
144
145    for entry in uki_dir.entries_utf8()? {
146        let entry = entry?;
147        let entry_name = entry.file_name()?;
148
149        // The actual UKI PE binary
150        if entry_name == get_uki_name(uki_id) {
151            tracing::debug!("Deleting UKI: {}", entry_name);
152
153            if dry_run {
154                continue;
155            }
156
157            entry.remove_file().context("Deleting UKI")?;
158        } else if entry_name == get_uki_addon_dir_name(uki_id) {
159            // Addons dir
160            tracing::debug!("Deleting UKI addons directory: {}", entry_name);
161
162            if dry_run {
163                continue;
164            }
165
166            uki_dir
167                .remove_dir_all(entry_name)
168                .context("Deleting UKI addons dir")?;
169        }
170    }
171
172    Ok(())
173}
174
175/// Find boot binaries on disk that are not referenced by any bootloader entry.
176///
177/// We compare against `boot_artifact_name` (the directory/file name on disk)
178/// rather than `fsverity` (the composefs= cmdline digest), because a shared
179/// entry's directory name may belong to a different deployment than the one
180/// whose composefs digest is in the BLS options line.
181fn unreferenced_boot_binaries<'a>(
182    boot_binaries: &'a [BootBinary],
183    bootloader_entries: &[BootloaderEntry],
184) -> Vec<&'a BootBinary> {
185    boot_binaries
186        .iter()
187        .filter(|bin| {
188            !bootloader_entries
189                .iter()
190                .any(|entry| entry.boot_artifact_name == bin.1)
191        })
192        .collect()
193}
194
195pub(crate) struct GCOpts {
196    pub(crate) dry_run: bool,
197    pub(crate) prune_repo: bool,
198}
199
200/// 1. List all bootloader entries
201/// 2. List all EROFS images
202/// 3. List all state directories
203/// 4. List staged depl if any
204///
205/// If bootloader entry B1 doesn't exist, but EROFS image B1 does exist, then delete the image and
206/// perform GC
207///
208/// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and
209/// perform GC
210//
211// Cases
212// - BLS Entries
213//      - On upgrade/switch, if only two are left, the staged and the current, then no GC
214//          - If there are three - rollback, booted and staged, GC the rollback, so the current
215//          becomes rollback
216#[fn_error_context::context("Running composefs garbage collection")]
217pub(crate) async fn composefs_gc(
218    storage: &Storage,
219    booted_cfs: &BootedComposefs,
220    gc_opts: GCOpts,
221) -> Result<GcResult> {
222    const COMPOSEFS_GC_JOURNAL_ID: &str = "3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7";
223
224    tracing::info!(
225        message_id = COMPOSEFS_GC_JOURNAL_ID,
226        bootc.operation = "gc",
227        bootc.current_deployment = booted_cfs.cmdline.digest,
228        "Starting composefs garbage collection"
229    );
230
231    // Upgrade any old-format OCI images (pre-EROFS-at-pull-time) before GC.
232    //
233    // Old bootc (pre composefs-rs 93634590c) used a seal-based flow that stored
234    // the composefs EROFS hash in an OCI config label but did NOT commit the EROFS
235    // image into the repository's images/ directory.  The GC's additional_roots
236    // mechanism protects deployments by looking up each deployment's EROFS verity
237    // in images/ and walking its object refs — but if no such image exists (old
238    // format), all the layer blob objects for that deployment appear unreferenced
239    // and are incorrectly collected.
240    //
241    // upgrade_repo() walks every tagged OCI image and generates EROFS for any
242    // that lack it, rewriting their config splitstreams.  After this step the
243    // additional_roots lookup succeeds and the rollback deployment's objects are
244    // protected.  This is a no-op for already-upgraded images (idempotent).
245    //
246    // Safety net: upgrade_repo() is also called in pull_composefs_repo() so
247    // that it runs at `bootc upgrade`/`bootc switch` time before any new
248    // deployment is staged.  Running it here too covers the case where GC is
249    // invoked directly (e.g. `bootc internals composefs-gc`) on a system that
250    // skipped the pull path.  upgrade_repo() is idempotent (fast-paths images
251    // that already have EROFS refs) and always runs even in dry-run mode since
252    // it is a format migration, not a deletion.
253    let upgrade_result = composefs_oci::upgrade_repo(&booted_cfs.repo)
254        .context("Upgrading old-format OCI images before GC")?;
255    if upgrade_result.upgraded > 0 {
256        tracing::info!(
257            "Upgraded {} old-format OCI image(s) to current format before GC",
258            upgrade_result.upgraded
259        );
260    }
261
262    let host = get_composefs_status(storage, booted_cfs).await?;
263    let booted_cfs_status = host.require_composefs_booted()?;
264
265    let sysroot = &storage.physical_root;
266
267    let bootloader_entries = list_bootloader_entries(storage)?;
268    let boot_binaries = collect_boot_binaries(storage)?;
269
270    tracing::debug!("bootloader_entries: {bootloader_entries:?}");
271    tracing::debug!("boot_binaries: {boot_binaries:?}");
272
273    let unreferenced_boot_binaries =
274        unreferenced_boot_binaries(&boot_binaries, &bootloader_entries);
275
276    tracing::debug!("unreferenced_boot_binaries: {unreferenced_boot_binaries:?}");
277
278    if unreferenced_boot_binaries
279        .iter()
280        .find(|be| be.1 == booted_cfs_status.verity)
281        .is_some()
282    {
283        anyhow::bail!(
284            "Inconsistent state. Booted binaries '{}' found for cleanup",
285            booted_cfs_status.verity
286        )
287    }
288
289    for (ty, verity) in unreferenced_boot_binaries {
290        match ty {
291            BootType::Bls => {
292                delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)?
293            }
294            BootType::Uki => delete_uki(storage, verity, gc_opts.dry_run)?,
295        }
296    }
297
298    if !gc_opts.prune_repo {
299        return Ok(GcResult::default());
300    }
301
302    // Identify orphaned deployments: state dirs or bootloader entries
303    // that don't correspond to a live deployment. EROFS images in
304    // composefs/images/ are NOT managed here — repo.gc() handles those
305    // via the tag→manifest→config→image ref chain.
306    let state_dirs = list_state_dirs(&sysroot)?;
307
308    let staged = &host.status.staged;
309
310    // State dirs without a bootloader entry are from interrupted deployments.
311    let orphaned_state_dirs: Vec<_> = state_dirs
312        .iter()
313        .filter(|s| !bootloader_entries.iter().any(|entry| &entry.fsverity == *s))
314        .collect();
315
316    // Bootloader entries without a state dir are from interrupted cleanups.
317    let orphaned_boot_entries: Vec<_> = bootloader_entries
318        .iter()
319        .map(|entry| &entry.fsverity)
320        .filter(|verity| !state_dirs.contains(verity))
321        .collect();
322
323    let all_orphans: Vec<_> = orphaned_state_dirs
324        .iter()
325        .chain(orphaned_boot_entries.iter())
326        .copied()
327        .collect();
328
329    if all_orphans.contains(&&booted_cfs_status.verity) {
330        anyhow::bail!(
331            "Inconsistent state. Booted entry '{}' found for cleanup",
332            booted_cfs_status.verity
333        )
334    }
335
336    for verity in &orphaned_state_dirs {
337        tracing::debug!("Cleaning up orphaned state dir: {verity}");
338        delete_staged(staged, &all_orphans, gc_opts.dry_run)?;
339        delete_state_dir(&sysroot, verity, gc_opts.dry_run)?;
340    }
341
342    for verity in &orphaned_boot_entries {
343        tracing::debug!("Cleaning up orphaned bootloader entry: {verity}");
344        delete_staged(staged, &all_orphans, gc_opts.dry_run)?;
345    }
346
347    // Collect the set of manifest digests referenced by live deployments,
348    // and track EROFS image verities as fallback additional_roots for
349    // deployments that predate the manifest→image link.
350    let mut live_manifest_digests: Vec<composefs_oci::OciDigest> = Vec::new();
351    let mut additional_roots = Vec::new();
352    // Container image names for containers-storage pruning.
353    let mut live_container_images: std::collections::HashSet<String> = Default::default();
354
355    // Read existing tags before the deployment loop so we can search
356    // them for deployments that lack manifest_digest in their origin.
357    let existing_tags = composefs_oci::list_refs(&*booted_cfs.repo)
358        .context("Listing OCI tags in composefs repo")?;
359
360    for deployment in host.list_deployments() {
361        let verity = &deployment.require_composefs()?.verity;
362
363        // Skip deployments that are already being GC'd.
364        if all_orphans.contains(&verity) {
365            continue;
366        }
367
368        // Keep the EROFS image as an additional root until all deployments
369        // have manifest→image refs. Once a deployment is pulled with the
370        // new code, its EROFS image is reachable from the manifest and
371        // this entry becomes redundant (but harmless).
372        additional_roots.push(verity.clone());
373
374        if let Some(ini) = read_origin(sysroot, verity)? {
375            // Collect the container image name for containers-storage GC.
376            if let Some(container_ref) =
377                ini.get::<String>("origin", ostree_ext::container::deploy::ORIGIN_CONTAINER)
378            {
379                // Parse the ostree image reference to extract the bare image name
380                // (e.g. "quay.io/foo:tag" from "ostree-unverified-image:docker://quay.io/foo:tag")
381                let image_name = container_ref
382                    .parse::<ostree_ext::container::OstreeImageReference>()
383                    .map(|r| r.imgref.name)
384                    .unwrap_or_else(|_| container_ref.clone());
385                live_container_images.insert(image_name);
386            }
387
388            if let Some(manifest_digest_str) =
389                ini.get::<String>(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST)
390            {
391                let digest: composefs_oci::OciDigest = manifest_digest_str
392                    .parse()
393                    .with_context(|| format!("Parsing manifest digest {manifest_digest_str}"))?;
394                live_manifest_digests.push(digest);
395            } else {
396                // Pre-OCI-metadata deployment: search tagged manifests
397                // for one whose config links to this EROFS image.
398                let mut found_manifest = false;
399                for (_, ref_digest) in &existing_tags {
400                    if let Ok(img) = composefs_oci::oci_image::OciImage::open(
401                        &*booted_cfs.repo,
402                        ref_digest,
403                        None,
404                    ) {
405                        if let Some(img_ref) = img.image_ref(booted_cfs.repo.erofs_version()) {
406                            if img_ref.to_hex() == *verity {
407                                tracing::info!(
408                                    "Deployment {verity} has no manifest_digest in origin; \
409                                     found matching manifest {ref_digest} via image_ref"
410                                );
411                                live_manifest_digests.push(ref_digest.clone());
412                                found_manifest = true;
413                                break;
414                            }
415                        }
416                    }
417                }
418                if !found_manifest {
419                    tracing::warn!(
420                        "Deployment {verity} has no manifest_digest in origin \
421                         and no tagged manifest references it; \
422                         EROFS image is protected but OCI metadata may be collected"
423                    );
424                }
425            }
426        }
427    }
428
429    // Migration: ensure every live deployment has a bootc-owned tag.
430    // Deployments from before the tag-based GC won't have tags yet;
431    // create them now so their OCI metadata survives this GC cycle.
432
433    for manifest_digest in &live_manifest_digests {
434        let expected_tag = bootc_tag_for_manifest(&manifest_digest.to_string());
435        let has_tag = existing_tags
436            .iter()
437            .any(|(tag_name, _)| tag_name == &expected_tag);
438        if !has_tag {
439            tracing::info!("Creating missing bootc tag for live deployment: {expected_tag}");
440            if !gc_opts.dry_run {
441                composefs_oci::tag_image(&*booted_cfs.repo, manifest_digest, &expected_tag)
442                    .with_context(|| format!("Creating migration tag {expected_tag}"))?;
443            }
444        }
445    }
446
447    // Re-read tags after potential migration.
448    let all_tags = composefs_oci::list_refs(&*booted_cfs.repo)
449        .context("Listing OCI tags in composefs repo")?;
450
451    for (tag_name, manifest_digest) in &all_tags {
452        if !tag_name.starts_with(BOOTC_TAG_PREFIX) {
453            // Not a bootc-owned tag; leave it alone (could be an app image).
454            continue;
455        }
456
457        if !live_manifest_digests.iter().any(|d| d == manifest_digest) {
458            tracing::debug!("Removing unreferenced bootc tag: {tag_name}");
459            if !gc_opts.dry_run {
460                composefs_oci::untag_image(&*booted_cfs.repo, tag_name)
461                    .with_context(|| format!("Removing tag {tag_name}"))?;
462            }
463        }
464    }
465
466    let additional_roots = additional_roots
467        .iter()
468        .map(|x| x.as_str())
469        .collect::<Vec<_>>();
470
471    // Prune containers-storage: remove images not backing any live deployment.
472    if !gc_opts.dry_run && !live_container_images.is_empty() {
473        let subpath = crate::podstorage::CStorage::subpath();
474        if sysroot.try_exists(&subpath).unwrap_or(false) {
475            let run = Dir::open_ambient_dir("/run", cap_std_ext::cap_std::ambient_authority())?;
476            let imgstore = crate::podstorage::CStorage::create(&sysroot, &run, None)?;
477            let roots: std::collections::HashSet<&str> =
478                live_container_images.iter().map(|s| s.as_str()).collect();
479            let pruned = imgstore.prune_except_roots(&roots).await?;
480            if !pruned.is_empty() {
481                tracing::info!("Pruned {} images from containers-storage", pruned.len());
482            }
483        }
484    }
485
486    // Run garbage collection. Tags root the OCI metadata chain
487    // (manifest → config → layers). The additional_roots protect EROFS
488    // images for deployments that predate the manifest→image link;
489    // once all deployments have been pulled with the new code, these
490    // become redundant.
491    let gc_result = if gc_opts.dry_run {
492        booted_cfs.repo.gc_dry_run(&additional_roots)?
493    } else {
494        booted_cfs.repo.gc(&additional_roots)?
495    };
496
497    Ok(gc_result)
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::bootc_composefs::status::list_type1_entries;
504    use crate::testutils::{ChangeType, TestRoot};
505
506    /// Reproduce the shared-entry GC bug from issue #2102.
507    ///
508    /// Scenario with both shared and non-shared kernels:
509    ///
510    /// 1. Install deployment A (kernel K1, boot dir "A")
511    /// 2. Upgrade to B, same kernel → shares A's boot dir
512    /// 3. Upgrade to C, new kernel K2 → gets its own boot dir "C"
513    /// 4. Upgrade to D, same kernel as C → shares C's boot dir
514    ///
515    /// After GC of A (the creator of boot dir used by B):
516    /// - A's boot dir must still exist (B references it)
517    /// - C's boot dir must still exist (D references it)
518    ///
519    /// The old code compared `fsverity` instead of `boot_artifact_name`,
520    /// which would incorrectly mark A's boot dir as unreferenced once A's
521    /// BLS entry is gone — even though B still points its linux/initrd
522    /// paths at A's directory.
523    #[test]
524    fn test_gc_shared_boot_binaries_not_deleted() -> anyhow::Result<()> {
525        let mut root = TestRoot::new()?;
526        let digest_a = root.current().verity.clone();
527
528        // B shares A's kernel (userspace-only change)
529        root.upgrade(1, ChangeType::Userspace)?;
530
531        // C gets a new kernel
532        root.upgrade(2, ChangeType::Kernel)?;
533        let digest_c = root.current().verity.clone();
534
535        // D shares C's kernel (userspace-only change)
536        root.upgrade(3, ChangeType::Userspace)?;
537        let digest_d = root.current().verity.clone();
538
539        // Now GC deployment A — the one that *created* the shared boot dir
540        root.gc_deployment(&digest_a)?;
541
542        // At this point only C (secondary) and D (primary) have BLS entries.
543        // But A's boot binary directory is still on disk because B used to
544        // share it and we haven't cleaned up boot binaries yet — that's
545        // what the GC filter decides.
546        let boot_dir = root.boot_dir()?;
547
548        // Collect what's on disk: two boot dirs (A's and C's)
549        let mut on_disk = Vec::new();
550        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
551        assert_eq!(
552            on_disk.len(),
553            2,
554            "should have A's and C's boot dirs on disk"
555        );
556
557        // Collect what the BLS entries reference
558        let bls_entries = list_type1_entries(&boot_dir)?;
559        assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
560
561        // The fix: unreferenced_boot_binaries uses boot_artifact_name.
562        // D's boot_artifact_name points to C's dir, C's points to itself.
563        // A's boot dir is NOT referenced by any current BLS entry's
564        // boot_artifact_name (B was the one referencing it, and B is no
565        // longer in the BLS entries either).
566        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
567
568        // A's boot dir IS unreferenced (only B used it, and B isn't in BLS anymore)
569        assert_eq!(unreferenced.len(), 1);
570        assert_eq!(unreferenced[0].1, digest_a);
571
572        // C's boot dir is still referenced (by both C and D via boot_artifact_name)
573        assert!(
574            !unreferenced.iter().any(|b| b.1 == digest_c),
575            "C's boot dir must not be unreferenced"
576        );
577
578        // Now the more dangerous scenario: GC C, the creator of the boot
579        // dir that D shares. After this, remaining deployments are [B, D].
580        // B still shares A's boot dir, D still shares C's boot dir.
581        root.gc_deployment(&digest_c)?;
582
583        let mut on_disk_2 = Vec::new();
584        collect_type1_boot_binaries(&root.boot_dir()?, &mut on_disk_2)?;
585        // A's dir + C's dir still on disk (boot binary cleanup hasn't run)
586        assert_eq!(on_disk_2.len(), 2);
587
588        let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?;
589        // D (primary) + B (secondary)
590        assert_eq!(bls_entries_2.len(), 2);
591
592        let entry_d = bls_entries_2
593            .iter()
594            .find(|e| e.fsverity == digest_d)
595            .unwrap();
596        assert_eq!(
597            entry_d.boot_artifact_name, digest_c,
598            "D shares C's boot dir"
599        );
600
601        let unreferenced_2 = unreferenced_boot_binaries(&on_disk_2, &bls_entries_2);
602
603        // Both boot dirs are still referenced:
604        // - A's dir via B's boot_artifact_name
605        // - C's dir via D's boot_artifact_name
606        assert!(
607            unreferenced_2.is_empty(),
608            "no boot dirs should be unreferenced when both are shared"
609        );
610
611        // Prove the old buggy logic would fail: if we compared fsverity
612        // instead of boot_artifact_name, BOTH dirs would be wrongly
613        // unreferenced. Neither A nor C has a BLS entry with matching
614        // fsverity — only B (verity=B) and D (verity=D) exist, but their
615        // boot dirs are named after A and C respectively.
616        let buggy_unreferenced: Vec<_> = on_disk_2
617            .iter()
618            .filter(|bin| !bls_entries_2.iter().any(|e| e.fsverity == bin.1))
619            .collect();
620        assert_eq!(
621            buggy_unreferenced.len(),
622            2,
623            "old fsverity-based logic would incorrectly GC both boot dirs"
624        );
625
626        Ok(())
627    }
628
629    /// Verify that list_type1_entries correctly parses legacy (unprefixed) BLS
630    /// entries. This is the code path that composefs_gc actually uses to find
631    /// bootloader entries, so it's critical that it handles both layouts.
632    #[test]
633    fn test_list_type1_entries_handles_legacy_bls() -> anyhow::Result<()> {
634        let mut root = TestRoot::new_legacy()?;
635        let digest_a = root.current().verity.clone();
636
637        root.upgrade(1, ChangeType::Userspace)?;
638        let digest_b = root.current().verity.clone();
639
640        let boot_dir = root.boot_dir()?;
641        let bls_entries = list_type1_entries(&boot_dir)?;
642
643        assert_eq!(bls_entries.len(), 2, "Should find both BLS entries");
644
645        // boot_artifact_name should return the raw digest (no prefix)
646        // because the legacy entries don't have the prefix
647        for entry in &bls_entries {
648            assert_eq!(
649                entry.boot_artifact_name, digest_a,
650                "Both entries should reference A's boot dir (shared kernel)"
651            );
652        }
653
654        // fsverity should differ between the two entries
655        let verity_set: std::collections::HashSet<&str> =
656            bls_entries.iter().map(|e| e.fsverity.as_str()).collect();
657        assert!(verity_set.contains(digest_a.as_str()));
658        assert!(verity_set.contains(digest_b.as_str()));
659
660        Ok(())
661    }
662
663    /// Legacy (unprefixed) boot dirs are invisible to collect_type1_boot_binaries,
664    /// which only looks for the `bootc_composefs-` prefix. This test verifies
665    /// that the GC scanner does not see unprefixed directories.
666    ///
667    /// This is the problem that PR #2128 solves by migrating legacy entries
668    /// to the prefixed format before any GC or status operations run.
669    #[test]
670    fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> {
671        let root = TestRoot::new_legacy()?;
672
673        // The legacy layout creates a boot dir without the prefix
674        let boot_dir = root.boot_dir()?;
675        let mut on_disk = Vec::new();
676        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
677
678        // collect_type1_boot_binaries requires the prefix — legacy dirs
679        // are invisible to it
680        assert!(
681            on_disk.is_empty(),
682            "Legacy (unprefixed) boot dirs should not be found by collect_type1_boot_binaries"
683        );
684
685        Ok(())
686    }
687
688    /// After migration from legacy to prefixed layout, GC should work
689    /// correctly — the boot binary directories become visible and
690    /// the BLS entries reference them properly.
691    #[test]
692    fn test_gc_works_after_legacy_migration() -> anyhow::Result<()> {
693        let mut root = TestRoot::new_legacy()?;
694        let digest_a = root.current().verity.clone();
695
696        // B shares A's kernel (userspace-only change)
697        root.upgrade(1, ChangeType::Userspace)?;
698
699        // C gets a new kernel
700        root.upgrade(2, ChangeType::Kernel)?;
701
702        // Simulate the migration that PR #2128 performs
703        root.migrate_to_prefixed()?;
704
705        // Now GC should see both boot dirs
706        let boot_dir = root.boot_dir()?;
707        let mut on_disk = Vec::new();
708        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
709        assert_eq!(on_disk.len(), 2, "Should see A's and C's boot dirs");
710
711        // BLS entries should correctly reference boot artifact names
712        let bls_entries = list_type1_entries(&boot_dir)?;
713        assert_eq!(bls_entries.len(), 2);
714
715        // No boot dirs should be unreferenced (all are in use)
716        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
717        assert!(
718            unreferenced.is_empty(),
719            "All boot dirs should be referenced after migration"
720        );
721
722        // GC deployment A (the one that created the shared boot dir)
723        root.gc_deployment(&digest_a)?;
724
725        let boot_dir = root.boot_dir()?;
726        let bls_entries = list_type1_entries(&boot_dir)?;
727        assert_eq!(bls_entries.len(), 2, "B (secondary) + C (primary)");
728
729        let mut on_disk = Vec::new();
730        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
731        assert_eq!(on_disk.len(), 2, "Both boot dirs still on disk");
732
733        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
734        // A's boot dir is still referenced by B
735        assert!(
736            unreferenced.is_empty(),
737            "A's boot dir should still be referenced by B after migration"
738        );
739
740        Ok(())
741    }
742
743    /// Test the full upgrade cycle with shared kernels after migration:
744    /// install (legacy) → migrate → upgrade → GC.
745    ///
746    /// This verifies that GC correctly handles a system that was originally
747    /// installed with old bootc, migrated, and then upgraded with new bootc.
748    #[test]
749    fn test_gc_post_migration_upgrade_cycle() -> anyhow::Result<()> {
750        let mut root = TestRoot::new_legacy()?;
751        let digest_a = root.current().verity.clone();
752
753        // B shares A's kernel (still legacy)
754        root.upgrade(1, ChangeType::Userspace)?;
755
756        // Simulate migration
757        root.migrate_to_prefixed()?;
758
759        // Now upgrade with new bootc (creates prefixed entries)
760        root.upgrade(2, ChangeType::Kernel)?;
761        let digest_c = root.current().verity.clone();
762
763        // D shares C's kernel
764        root.upgrade(3, ChangeType::Userspace)?;
765        let digest_d = root.current().verity.clone();
766
767        // GC all old deployments, keeping only C and D
768        root.gc_deployment(&digest_a)?;
769
770        let boot_dir = root.boot_dir()?;
771        let mut on_disk = Vec::new();
772        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
773
774        let bls_entries = list_type1_entries(&boot_dir)?;
775        assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
776
777        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
778        // A's boot dir is unreferenced (B is gone, only C and D remain)
779        assert_eq!(
780            unreferenced.len(),
781            1,
782            "A's boot dir should be unreferenced after GC of A and B is evicted"
783        );
784        assert_eq!(unreferenced[0].1, digest_a);
785
786        // C's boot dir must still be referenced by D
787        assert!(
788            !unreferenced.iter().any(|b| b.1 == digest_c),
789            "C's boot dir must still be referenced by D"
790        );
791
792        // Verify D shares C's boot dir
793        let entry_d = bls_entries
794            .iter()
795            .find(|e| e.fsverity == digest_d)
796            .expect("D should have a BLS entry");
797        assert_eq!(
798            entry_d.boot_artifact_name, digest_c,
799            "D should share C's boot dir"
800        );
801
802        Ok(())
803    }
804
805    /// Test deep transitive sharing: A → B → C → D all share A's boot dir
806    /// via successive userspace-only upgrades. When we GC A (the creator
807    /// of the boot dir), the dir must be kept because the remaining
808    /// deployments still reference it.
809    ///
810    /// This tests that boot_dir_verity propagates correctly through
811    /// a chain of userspace-only upgrades and that the GC filter handles
812    /// the case where no remaining deployment's fsverity matches the
813    /// boot directory name.
814    #[test]
815    fn test_gc_deep_transitive_sharing_chain() -> anyhow::Result<()> {
816        let mut root = TestRoot::new()?;
817        let digest_a = root.current().verity.clone();
818
819        // B, C, D all share A's kernel via userspace-only upgrades
820        root.upgrade(1, ChangeType::Userspace)?;
821        root.upgrade(2, ChangeType::Userspace)?;
822        root.upgrade(3, ChangeType::Userspace)?;
823        let digest_d = root.current().verity.clone();
824
825        // Only one boot dir on disk (all share A's)
826        let boot_dir = root.boot_dir()?;
827        let mut on_disk = Vec::new();
828        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
829        assert_eq!(on_disk.len(), 1, "All deployments share one boot dir");
830        assert_eq!(on_disk[0].1, digest_a, "The boot dir belongs to A");
831
832        // BLS entries: D (primary) + C (secondary), both referencing A's dir
833        let bls_entries = list_type1_entries(&boot_dir)?;
834        assert_eq!(bls_entries.len(), 2);
835        for entry in &bls_entries {
836            assert_eq!(
837                entry.boot_artifact_name, digest_a,
838                "All entries reference A's boot dir"
839            );
840        }
841
842        // GC deployment A (the creator of the shared boot dir)
843        root.gc_deployment(&digest_a)?;
844
845        let boot_dir = root.boot_dir()?;
846        let bls_entries = list_type1_entries(&boot_dir)?;
847        // D (primary) + C (secondary) — A was already evicted from BLS
848        assert_eq!(bls_entries.len(), 2);
849
850        let mut on_disk = Vec::new();
851        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
852
853        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
854        assert!(
855            unreferenced.is_empty(),
856            "A's boot dir must stay — C and D still reference it"
857        );
858
859        // Now also GC B and C, leaving only D
860        let digest_b = crate::testutils::fake_digest_version(1);
861        let digest_c = crate::testutils::fake_digest_version(2);
862        root.gc_deployment(&digest_b)?;
863        root.gc_deployment(&digest_c)?;
864
865        // D is the only deployment left
866        let boot_dir = root.boot_dir()?;
867        let bls_entries = list_type1_entries(&boot_dir)?;
868        assert_eq!(bls_entries.len(), 1, "Only D remains");
869        assert_eq!(bls_entries[0].fsverity, digest_d);
870        assert_eq!(
871            bls_entries[0].boot_artifact_name, digest_a,
872            "D still references A's boot dir"
873        );
874
875        let mut on_disk = Vec::new();
876        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
877        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
878        assert!(
879            unreferenced.is_empty(),
880            "A's boot dir must survive — D is the last deployment and still uses it"
881        );
882
883        Ok(())
884    }
885
886    /// Verify that boot_artifact_info().1 (has_prefix) is the correct
887    /// signal for identifying entries that need migration, and that the
888    /// GC filter works correctly at each stage of the migration pipeline.
889    ///
890    /// This exercises the API that stage_bls_entry_changes() in PR #2128
891    /// uses to decide which entries to migrate.
892    #[test]
893    fn test_boot_artifact_info_drives_migration_decisions() -> anyhow::Result<()> {
894        use crate::bootc_composefs::status::get_sorted_type1_boot_entries;
895
896        let mut root = TestRoot::new_legacy()?;
897        let digest_a = root.current().verity.clone();
898
899        root.upgrade(1, ChangeType::Userspace)?;
900        root.upgrade(2, ChangeType::Kernel)?;
901
902        // -- Pre-migration: all entries lack the prefix --
903        let boot_dir = root.boot_dir()?;
904        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
905        assert_eq!(raw_entries.len(), 2);
906
907        let needs_migration: Vec<_> = raw_entries
908            .iter()
909            .filter(|e| !e.boot_artifact_info().unwrap().1)
910            .collect();
911        assert_eq!(
912            needs_migration.len(),
913            2,
914            "All legacy entries should need migration (has_prefix=false)"
915        );
916
917        // GC scanner can't see the boot dirs (no prefix on disk)
918        let mut on_disk = Vec::new();
919        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
920        assert!(on_disk.is_empty(), "Legacy dirs invisible before migration");
921
922        // -- Migrate --
923        root.migrate_to_prefixed()?;
924
925        // -- Post-migration: all entries have the prefix --
926        let boot_dir = root.boot_dir()?;
927        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
928        assert_eq!(raw_entries.len(), 2);
929
930        let needs_migration: Vec<_> = raw_entries
931            .iter()
932            .filter(|e| !e.boot_artifact_info().unwrap().1)
933            .collect();
934        assert!(
935            needs_migration.is_empty(),
936            "No entries should need migration after migrate_to_prefixed()"
937        );
938
939        // GC scanner can now see the boot dirs
940        let mut on_disk = Vec::new();
941        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
942        assert_eq!(on_disk.len(), 2, "Both dirs visible after migration");
943
944        // GC filter correctly identifies all dirs as referenced
945        let bls_entries = list_type1_entries(&boot_dir)?;
946        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
947        assert!(
948            unreferenced.is_empty(),
949            "All dirs referenced after migration"
950        );
951
952        // -- Upgrade with new bootc (prefixed from creation) --
953        root.upgrade(3, ChangeType::Kernel)?;
954
955        let boot_dir = root.boot_dir()?;
956        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
957        // All entries (both migrated and new) should have the prefix
958        for entry in &raw_entries {
959            let (_, has_prefix) = entry.boot_artifact_info()?;
960            assert!(
961                has_prefix,
962                "All entries should have prefix after migration + upgrade"
963            );
964        }
965
966        // GC should now see 3 boot dirs: A's, C's (from upgrade 2), and
967        // the new one from upgrade 3
968        let mut on_disk = Vec::new();
969        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
970        assert_eq!(on_disk.len(), 3, "Three boot dirs on disk");
971
972        // Only 2 BLS entries (primary + secondary), so one dir is unreferenced
973        let bls_entries = list_type1_entries(&boot_dir)?;
974        assert_eq!(bls_entries.len(), 2);
975        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
976        assert_eq!(
977            unreferenced.len(),
978            1,
979            "A's boot dir should be unreferenced (B evicted from BLS)"
980        );
981        assert_eq!(unreferenced[0].1, digest_a);
982
983        Ok(())
984    }
985}