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    // `Repository::gc()` takes an exclusive flock on the repository file as its
492    // first action. Callers must ensure no other `Repository` handle on that
493    // same underlying file is still alive when this runs, or it will deadlock
494    // (see update.rs's do_upgrade() for an example of getting this wrong).
495    let gc_result = if gc_opts.dry_run {
496        booted_cfs.repo.gc_dry_run(&additional_roots)?
497    } else {
498        booted_cfs.repo.gc(&additional_roots)?
499    };
500
501    Ok(gc_result)
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::bootc_composefs::status::list_type1_entries;
508    use crate::testutils::{ChangeType, TestRoot};
509
510    /// Reproduce the shared-entry GC bug from issue #2102.
511    ///
512    /// Scenario with both shared and non-shared kernels:
513    ///
514    /// 1. Install deployment A (kernel K1, boot dir "A")
515    /// 2. Upgrade to B, same kernel → shares A's boot dir
516    /// 3. Upgrade to C, new kernel K2 → gets its own boot dir "C"
517    /// 4. Upgrade to D, same kernel as C → shares C's boot dir
518    ///
519    /// After GC of A (the creator of boot dir used by B):
520    /// - A's boot dir must still exist (B references it)
521    /// - C's boot dir must still exist (D references it)
522    ///
523    /// The old code compared `fsverity` instead of `boot_artifact_name`,
524    /// which would incorrectly mark A's boot dir as unreferenced once A's
525    /// BLS entry is gone — even though B still points its linux/initrd
526    /// paths at A's directory.
527    #[test]
528    fn test_gc_shared_boot_binaries_not_deleted() -> anyhow::Result<()> {
529        let mut root = TestRoot::new()?;
530        let digest_a = root.current().verity.clone();
531
532        // B shares A's kernel (userspace-only change)
533        root.upgrade(1, ChangeType::Userspace)?;
534
535        // C gets a new kernel
536        root.upgrade(2, ChangeType::Kernel)?;
537        let digest_c = root.current().verity.clone();
538
539        // D shares C's kernel (userspace-only change)
540        root.upgrade(3, ChangeType::Userspace)?;
541        let digest_d = root.current().verity.clone();
542
543        // Now GC deployment A — the one that *created* the shared boot dir
544        root.gc_deployment(&digest_a)?;
545
546        // At this point only C (secondary) and D (primary) have BLS entries.
547        // But A's boot binary directory is still on disk because B used to
548        // share it and we haven't cleaned up boot binaries yet — that's
549        // what the GC filter decides.
550        let boot_dir = root.boot_dir()?;
551
552        // Collect what's on disk: two boot dirs (A's and C's)
553        let mut on_disk = Vec::new();
554        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
555        assert_eq!(
556            on_disk.len(),
557            2,
558            "should have A's and C's boot dirs on disk"
559        );
560
561        // Collect what the BLS entries reference
562        let bls_entries = list_type1_entries(&boot_dir)?;
563        assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
564
565        // The fix: unreferenced_boot_binaries uses boot_artifact_name.
566        // D's boot_artifact_name points to C's dir, C's points to itself.
567        // A's boot dir is NOT referenced by any current BLS entry's
568        // boot_artifact_name (B was the one referencing it, and B is no
569        // longer in the BLS entries either).
570        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
571
572        // A's boot dir IS unreferenced (only B used it, and B isn't in BLS anymore)
573        assert_eq!(unreferenced.len(), 1);
574        assert_eq!(unreferenced[0].1, digest_a);
575
576        // C's boot dir is still referenced (by both C and D via boot_artifact_name)
577        assert!(
578            !unreferenced.iter().any(|b| b.1 == digest_c),
579            "C's boot dir must not be unreferenced"
580        );
581
582        // Now the more dangerous scenario: GC C, the creator of the boot
583        // dir that D shares. After this, remaining deployments are [B, D].
584        // B still shares A's boot dir, D still shares C's boot dir.
585        root.gc_deployment(&digest_c)?;
586
587        let mut on_disk_2 = Vec::new();
588        collect_type1_boot_binaries(&root.boot_dir()?, &mut on_disk_2)?;
589        // A's dir + C's dir still on disk (boot binary cleanup hasn't run)
590        assert_eq!(on_disk_2.len(), 2);
591
592        let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?;
593        // D (primary) + B (secondary)
594        assert_eq!(bls_entries_2.len(), 2);
595
596        let entry_d = bls_entries_2
597            .iter()
598            .find(|e| e.fsverity == digest_d)
599            .unwrap();
600        assert_eq!(
601            entry_d.boot_artifact_name, digest_c,
602            "D shares C's boot dir"
603        );
604
605        let unreferenced_2 = unreferenced_boot_binaries(&on_disk_2, &bls_entries_2);
606
607        // Both boot dirs are still referenced:
608        // - A's dir via B's boot_artifact_name
609        // - C's dir via D's boot_artifact_name
610        assert!(
611            unreferenced_2.is_empty(),
612            "no boot dirs should be unreferenced when both are shared"
613        );
614
615        // Prove the old buggy logic would fail: if we compared fsverity
616        // instead of boot_artifact_name, BOTH dirs would be wrongly
617        // unreferenced. Neither A nor C has a BLS entry with matching
618        // fsverity — only B (verity=B) and D (verity=D) exist, but their
619        // boot dirs are named after A and C respectively.
620        let buggy_unreferenced: Vec<_> = on_disk_2
621            .iter()
622            .filter(|bin| !bls_entries_2.iter().any(|e| e.fsverity == bin.1))
623            .collect();
624        assert_eq!(
625            buggy_unreferenced.len(),
626            2,
627            "old fsverity-based logic would incorrectly GC both boot dirs"
628        );
629
630        Ok(())
631    }
632
633    /// Verify that list_type1_entries correctly parses legacy (unprefixed) BLS
634    /// entries. This is the code path that composefs_gc actually uses to find
635    /// bootloader entries, so it's critical that it handles both layouts.
636    #[test]
637    fn test_list_type1_entries_handles_legacy_bls() -> anyhow::Result<()> {
638        let mut root = TestRoot::new_legacy()?;
639        let digest_a = root.current().verity.clone();
640
641        root.upgrade(1, ChangeType::Userspace)?;
642        let digest_b = root.current().verity.clone();
643
644        let boot_dir = root.boot_dir()?;
645        let bls_entries = list_type1_entries(&boot_dir)?;
646
647        assert_eq!(bls_entries.len(), 2, "Should find both BLS entries");
648
649        // boot_artifact_name should return the raw digest (no prefix)
650        // because the legacy entries don't have the prefix
651        for entry in &bls_entries {
652            assert_eq!(
653                entry.boot_artifact_name, digest_a,
654                "Both entries should reference A's boot dir (shared kernel)"
655            );
656        }
657
658        // fsverity should differ between the two entries
659        let verity_set: std::collections::HashSet<&str> =
660            bls_entries.iter().map(|e| e.fsverity.as_str()).collect();
661        assert!(verity_set.contains(digest_a.as_str()));
662        assert!(verity_set.contains(digest_b.as_str()));
663
664        Ok(())
665    }
666
667    /// Legacy (unprefixed) boot dirs are invisible to collect_type1_boot_binaries,
668    /// which only looks for the `bootc_composefs-` prefix. This test verifies
669    /// that the GC scanner does not see unprefixed directories.
670    ///
671    /// This is the problem that PR #2128 solves by migrating legacy entries
672    /// to the prefixed format before any GC or status operations run.
673    #[test]
674    fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> {
675        let root = TestRoot::new_legacy()?;
676
677        // The legacy layout creates a boot dir without the prefix
678        let boot_dir = root.boot_dir()?;
679        let mut on_disk = Vec::new();
680        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
681
682        // collect_type1_boot_binaries requires the prefix — legacy dirs
683        // are invisible to it
684        assert!(
685            on_disk.is_empty(),
686            "Legacy (unprefixed) boot dirs should not be found by collect_type1_boot_binaries"
687        );
688
689        Ok(())
690    }
691
692    /// After migration from legacy to prefixed layout, GC should work
693    /// correctly — the boot binary directories become visible and
694    /// the BLS entries reference them properly.
695    #[test]
696    fn test_gc_works_after_legacy_migration() -> anyhow::Result<()> {
697        let mut root = TestRoot::new_legacy()?;
698        let digest_a = root.current().verity.clone();
699
700        // B shares A's kernel (userspace-only change)
701        root.upgrade(1, ChangeType::Userspace)?;
702
703        // C gets a new kernel
704        root.upgrade(2, ChangeType::Kernel)?;
705
706        // Simulate the migration that PR #2128 performs
707        root.migrate_to_prefixed()?;
708
709        // Now GC should see both boot dirs
710        let boot_dir = root.boot_dir()?;
711        let mut on_disk = Vec::new();
712        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
713        assert_eq!(on_disk.len(), 2, "Should see A's and C's boot dirs");
714
715        // BLS entries should correctly reference boot artifact names
716        let bls_entries = list_type1_entries(&boot_dir)?;
717        assert_eq!(bls_entries.len(), 2);
718
719        // No boot dirs should be unreferenced (all are in use)
720        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
721        assert!(
722            unreferenced.is_empty(),
723            "All boot dirs should be referenced after migration"
724        );
725
726        // GC deployment A (the one that created the shared boot dir)
727        root.gc_deployment(&digest_a)?;
728
729        let boot_dir = root.boot_dir()?;
730        let bls_entries = list_type1_entries(&boot_dir)?;
731        assert_eq!(bls_entries.len(), 2, "B (secondary) + C (primary)");
732
733        let mut on_disk = Vec::new();
734        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
735        assert_eq!(on_disk.len(), 2, "Both boot dirs still on disk");
736
737        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
738        // A's boot dir is still referenced by B
739        assert!(
740            unreferenced.is_empty(),
741            "A's boot dir should still be referenced by B after migration"
742        );
743
744        Ok(())
745    }
746
747    /// Test the full upgrade cycle with shared kernels after migration:
748    /// install (legacy) → migrate → upgrade → GC.
749    ///
750    /// This verifies that GC correctly handles a system that was originally
751    /// installed with old bootc, migrated, and then upgraded with new bootc.
752    #[test]
753    fn test_gc_post_migration_upgrade_cycle() -> anyhow::Result<()> {
754        let mut root = TestRoot::new_legacy()?;
755        let digest_a = root.current().verity.clone();
756
757        // B shares A's kernel (still legacy)
758        root.upgrade(1, ChangeType::Userspace)?;
759
760        // Simulate migration
761        root.migrate_to_prefixed()?;
762
763        // Now upgrade with new bootc (creates prefixed entries)
764        root.upgrade(2, ChangeType::Kernel)?;
765        let digest_c = root.current().verity.clone();
766
767        // D shares C's kernel
768        root.upgrade(3, ChangeType::Userspace)?;
769        let digest_d = root.current().verity.clone();
770
771        // GC all old deployments, keeping only C and D
772        root.gc_deployment(&digest_a)?;
773
774        let boot_dir = root.boot_dir()?;
775        let mut on_disk = Vec::new();
776        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
777
778        let bls_entries = list_type1_entries(&boot_dir)?;
779        assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
780
781        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
782        // A's boot dir is unreferenced (B is gone, only C and D remain)
783        assert_eq!(
784            unreferenced.len(),
785            1,
786            "A's boot dir should be unreferenced after GC of A and B is evicted"
787        );
788        assert_eq!(unreferenced[0].1, digest_a);
789
790        // C's boot dir must still be referenced by D
791        assert!(
792            !unreferenced.iter().any(|b| b.1 == digest_c),
793            "C's boot dir must still be referenced by D"
794        );
795
796        // Verify D shares C's boot dir
797        let entry_d = bls_entries
798            .iter()
799            .find(|e| e.fsverity == digest_d)
800            .expect("D should have a BLS entry");
801        assert_eq!(
802            entry_d.boot_artifact_name, digest_c,
803            "D should share C's boot dir"
804        );
805
806        Ok(())
807    }
808
809    /// Test deep transitive sharing: A → B → C → D all share A's boot dir
810    /// via successive userspace-only upgrades. When we GC A (the creator
811    /// of the boot dir), the dir must be kept because the remaining
812    /// deployments still reference it.
813    ///
814    /// This tests that boot_dir_verity propagates correctly through
815    /// a chain of userspace-only upgrades and that the GC filter handles
816    /// the case where no remaining deployment's fsverity matches the
817    /// boot directory name.
818    #[test]
819    fn test_gc_deep_transitive_sharing_chain() -> anyhow::Result<()> {
820        let mut root = TestRoot::new()?;
821        let digest_a = root.current().verity.clone();
822
823        // B, C, D all share A's kernel via userspace-only upgrades
824        root.upgrade(1, ChangeType::Userspace)?;
825        root.upgrade(2, ChangeType::Userspace)?;
826        root.upgrade(3, ChangeType::Userspace)?;
827        let digest_d = root.current().verity.clone();
828
829        // Only one boot dir on disk (all share A's)
830        let boot_dir = root.boot_dir()?;
831        let mut on_disk = Vec::new();
832        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
833        assert_eq!(on_disk.len(), 1, "All deployments share one boot dir");
834        assert_eq!(on_disk[0].1, digest_a, "The boot dir belongs to A");
835
836        // BLS entries: D (primary) + C (secondary), both referencing A's dir
837        let bls_entries = list_type1_entries(&boot_dir)?;
838        assert_eq!(bls_entries.len(), 2);
839        for entry in &bls_entries {
840            assert_eq!(
841                entry.boot_artifact_name, digest_a,
842                "All entries reference A's boot dir"
843            );
844        }
845
846        // GC deployment A (the creator of the shared boot dir)
847        root.gc_deployment(&digest_a)?;
848
849        let boot_dir = root.boot_dir()?;
850        let bls_entries = list_type1_entries(&boot_dir)?;
851        // D (primary) + C (secondary) — A was already evicted from BLS
852        assert_eq!(bls_entries.len(), 2);
853
854        let mut on_disk = Vec::new();
855        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
856
857        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
858        assert!(
859            unreferenced.is_empty(),
860            "A's boot dir must stay — C and D still reference it"
861        );
862
863        // Now also GC B and C, leaving only D
864        let digest_b = crate::testutils::fake_digest_version(1);
865        let digest_c = crate::testutils::fake_digest_version(2);
866        root.gc_deployment(&digest_b)?;
867        root.gc_deployment(&digest_c)?;
868
869        // D is the only deployment left
870        let boot_dir = root.boot_dir()?;
871        let bls_entries = list_type1_entries(&boot_dir)?;
872        assert_eq!(bls_entries.len(), 1, "Only D remains");
873        assert_eq!(bls_entries[0].fsverity, digest_d);
874        assert_eq!(
875            bls_entries[0].boot_artifact_name, digest_a,
876            "D still references A's boot dir"
877        );
878
879        let mut on_disk = Vec::new();
880        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
881        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
882        assert!(
883            unreferenced.is_empty(),
884            "A's boot dir must survive — D is the last deployment and still uses it"
885        );
886
887        Ok(())
888    }
889
890    /// Verify that boot_artifact_info().1 (has_prefix) is the correct
891    /// signal for identifying entries that need migration, and that the
892    /// GC filter works correctly at each stage of the migration pipeline.
893    ///
894    /// This exercises the API that stage_bls_entry_changes() in PR #2128
895    /// uses to decide which entries to migrate.
896    #[test]
897    fn test_boot_artifact_info_drives_migration_decisions() -> anyhow::Result<()> {
898        use crate::bootc_composefs::status::get_sorted_type1_boot_entries;
899
900        let mut root = TestRoot::new_legacy()?;
901        let digest_a = root.current().verity.clone();
902
903        root.upgrade(1, ChangeType::Userspace)?;
904        root.upgrade(2, ChangeType::Kernel)?;
905
906        // -- Pre-migration: all entries lack the prefix --
907        let boot_dir = root.boot_dir()?;
908        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
909        assert_eq!(raw_entries.len(), 2);
910
911        let needs_migration: Vec<_> = raw_entries
912            .iter()
913            .filter(|e| !e.boot_artifact_info().unwrap().1)
914            .collect();
915        assert_eq!(
916            needs_migration.len(),
917            2,
918            "All legacy entries should need migration (has_prefix=false)"
919        );
920
921        // GC scanner can't see the boot dirs (no prefix on disk)
922        let mut on_disk = Vec::new();
923        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
924        assert!(on_disk.is_empty(), "Legacy dirs invisible before migration");
925
926        // -- Migrate --
927        root.migrate_to_prefixed()?;
928
929        // -- Post-migration: all entries have the prefix --
930        let boot_dir = root.boot_dir()?;
931        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
932        assert_eq!(raw_entries.len(), 2);
933
934        let needs_migration: Vec<_> = raw_entries
935            .iter()
936            .filter(|e| !e.boot_artifact_info().unwrap().1)
937            .collect();
938        assert!(
939            needs_migration.is_empty(),
940            "No entries should need migration after migrate_to_prefixed()"
941        );
942
943        // GC scanner can now see the boot dirs
944        let mut on_disk = Vec::new();
945        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
946        assert_eq!(on_disk.len(), 2, "Both dirs visible after migration");
947
948        // GC filter correctly identifies all dirs as referenced
949        let bls_entries = list_type1_entries(&boot_dir)?;
950        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
951        assert!(
952            unreferenced.is_empty(),
953            "All dirs referenced after migration"
954        );
955
956        // -- Upgrade with new bootc (prefixed from creation) --
957        root.upgrade(3, ChangeType::Kernel)?;
958
959        let boot_dir = root.boot_dir()?;
960        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
961        // All entries (both migrated and new) should have the prefix
962        for entry in &raw_entries {
963            let (_, has_prefix) = entry.boot_artifact_info()?;
964            assert!(
965                has_prefix,
966                "All entries should have prefix after migration + upgrade"
967            );
968        }
969
970        // GC should now see 3 boot dirs: A's, C's (from upgrade 2), and
971        // the new one from upgrade 3
972        let mut on_disk = Vec::new();
973        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
974        assert_eq!(on_disk.len(), 3, "Three boot dirs on disk");
975
976        // Only 2 BLS entries (primary + secondary), so one dir is unreferenced
977        let bls_entries = list_type1_entries(&boot_dir)?;
978        assert_eq!(bls_entries.len(), 2);
979        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
980        assert_eq!(
981            unreferenced.len(),
982            1,
983            "A's boot dir should be unreferenced (B evicted from BLS)"
984        );
985        assert_eq!(unreferenced[0].1, digest_a);
986
987        Ok(())
988    }
989}