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