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 std::collections::HashSet;
8
9use anyhow::{Context, Result};
10use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
11use composefs::fsverity::FsVerityHashValue;
12use composefs::repository::GcResult;
13use composefs_boot::bootloader::EFI_EXT;
14use composefs_ctl::composefs;
15use composefs_ctl::composefs_boot;
16use composefs_ctl::composefs_oci;
17use ostree_ext::composefs::fsverity::Sha512HashValue;
18use ostree_ext::composefs_oci::linked_erofs_images;
19use rustix::fs::AtFlags;
20use rustix::fs::FlockOperation;
21use rustix::fs::flock;
22use rustix::fs::{statat, unlinkat};
23
24use crate::bootc_composefs::boot::GLOBAL_UKI_ADDONS_DIR;
25use crate::bootc_composefs::boot::get_global_uki_addon_name;
26use crate::bootc_composefs::uki_addon::UkiAddonType;
27use crate::bootc_composefs::uki_addon::list_installed_uki_addons;
28use crate::bootc_composefs::uki_addon::list_referenced_uki_addons;
29use crate::{
30    bootc_composefs::{
31        boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name},
32        delete::{delete_staged, delete_state_dir},
33        repo::bootc_tag_for_manifest,
34        state::read_origin,
35        status::{BootloaderEntry, get_composefs_status, list_bootloader_entries},
36    },
37    composefs_consts::{
38        BOOTC_TAG_PREFIX, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, STATE_DIR_RELATIVE,
39        TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX,
40    },
41    store::{BootedComposefs, Storage},
42};
43
44#[fn_error_context::context("Listing state directories")]
45fn list_state_dirs(sysroot: &Dir) -> Result<Vec<String>> {
46    let state = sysroot
47        .open_dir(STATE_DIR_RELATIVE)
48        .context("Opening state dir")?;
49    list_deployment_state_dirs(&state)
50}
51
52/// List the per-deployment directories in an opened composefs state directory.
53pub(crate) fn list_deployment_state_dirs(state: &Dir) -> Result<Vec<String>> {
54    let mut dirs = vec![];
55
56    for dir in state.entries_utf8()? {
57        let dir = dir?;
58
59        if dir.file_type()?.is_file() {
60            continue;
61        }
62
63        dirs.push(dir.file_name()?);
64    }
65
66    Ok(dirs)
67}
68
69type BootBinary = (BootType, String);
70
71fn image_refs_match(
72    image_ref_v1: Option<&composefs::fsverity::Sha512HashValue>,
73    image_ref_v2: Option<&composefs::fsverity::Sha512HashValue>,
74    verity: &str,
75) -> bool {
76    [image_ref_v1, image_ref_v2]
77        .into_iter()
78        .flatten()
79        .any(|image_ref| image_ref.to_hex() == verity)
80}
81
82/// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem
83///
84/// Returns a vector of binary type (UKI/Type1) + name of all boot binaries
85#[fn_error_context::context("Collecting boot binaries")]
86fn collect_boot_binaries(storage: &Storage) -> Result<Vec<BootBinary>> {
87    let mut boot_binaries = Vec::new();
88    let boot_dir = storage.bls_boot_binaries_dir()?;
89    let boot_mount = storage.require_boot_mount()?;
90
91    // Scan for UKI binaries in EFI/Linux/bootc
92    collect_uki_binaries(&boot_mount.fd, &mut boot_binaries)?;
93
94    // Scan for Type1 boot binaries (kernels + initrds) in `boot_dir`
95    // depending upon whether systemd-boot is being used, or grub
96    collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?;
97
98    Ok(boot_binaries)
99}
100
101/// Scan for UKI binaries in EFI/Linux/bootc
102#[fn_error_context::context("Collecting UKI binaries")]
103fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec<BootBinary>) -> Result<()> {
104    let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(BOOTC_UKI_DIR) else {
105        return Ok(());
106    };
107
108    for entry in efi_dir.entries_utf8()? {
109        let entry = entry?;
110        let name = entry.file_name()?;
111
112        let Some(efi_name_no_prefix) = name.strip_prefix(UKI_NAME_PREFIX) else {
113            continue;
114        };
115
116        if let Some(verity) = efi_name_no_prefix.strip_suffix(EFI_EXT) {
117            boot_binaries.push((BootType::Uki, verity.into()));
118        }
119    }
120
121    Ok(())
122}
123
124/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with
125/// that start with bootc_composefs-
126///
127/// Strips the prefix and returns the rest of the string
128#[fn_error_context::context("Collecting Type1 boot binaries")]
129fn collect_type1_boot_binaries(boot_dir: &Dir, boot_binaries: &mut Vec<BootBinary>) -> Result<()> {
130    for entry in boot_dir.entries_utf8()? {
131        let entry = entry?;
132        let dir_name = entry.file_name()?;
133
134        if !entry.file_type()?.is_dir() {
135            continue;
136        }
137
138        let Some(verity) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else {
139            continue;
140        };
141
142        // The directory name starts with our custom prefix
143        boot_binaries.push((BootType::Bls, verity.to_string()));
144    }
145
146    Ok(())
147}
148
149#[fn_error_context::context("Deleting kernel and initrd")]
150fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) -> Result<()> {
151    tracing::debug!("Deleting Type1 entry {dir_to_delete}");
152
153    if dry_run {
154        return Ok(());
155    }
156
157    let boot_dir = storage.bls_boot_binaries_dir()?;
158
159    boot_dir
160        .remove_dir_all(dir_to_delete)
161        .with_context(|| anyhow::anyhow!("Deleting {dir_to_delete}"))
162}
163
164/// Deletes the UKI `uki_id` and any addons specific to it
165#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")]
166fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> {
167    let boot_mount = storage.require_boot_mount()?;
168
169    let uki_dir = boot_mount.fd.open_dir(BOOTC_UKI_DIR)?;
170
171    for entry in uki_dir.entries_utf8()? {
172        let entry = entry?;
173        let entry_name = entry.file_name()?;
174
175        // The actual UKI PE binary
176        if entry_name == get_uki_name(uki_id) {
177            tracing::debug!("Deleting UKI: {}", entry_name);
178
179            if dry_run {
180                continue;
181            }
182
183            entry.remove_file().context("Deleting UKI")?;
184        } else if entry_name == get_uki_addon_dir_name(uki_id) {
185            // Addons dir
186            tracing::debug!("Deleting UKI addons directory: {}", entry_name);
187
188            if dry_run {
189                continue;
190            }
191
192            uki_dir
193                .remove_dir_all(entry_name)
194                .context("Deleting UKI addons dir")?;
195        }
196    }
197
198    Ok(())
199}
200
201/// Find boot binaries on disk that are not referenced by any bootloader entry.
202///
203/// We compare against `boot_artifact_name` (the directory/file name on disk)
204/// rather than `fsverity` (the composefs= cmdline digest), because a shared
205/// entry's directory name may belong to a different deployment than the one
206/// whose composefs digest is in the BLS options line.
207fn unreferenced_boot_binaries<'a>(
208    boot_binaries: &'a [BootBinary],
209    bootloader_entries: &[BootloaderEntry],
210) -> Vec<&'a BootBinary> {
211    boot_binaries
212        .iter()
213        .filter(|bin| {
214            !bootloader_entries
215                .iter()
216                .any(|entry| entry.boot_artifact_name == bin.1)
217        })
218        .collect()
219}
220
221#[fn_error_context::context("GC Global UKI Addons")]
222fn gc_global_uki_addons(
223    storage: &Storage,
224    booted_cfs: &BootedComposefs,
225    gc_opts: &GCOpts,
226    bootloader_entries: &Vec<BootloaderEntry>,
227) -> Result<()> {
228    // GC always runs *after* finalizing deployments so there would be no staged deployment
229    // by definition but still only consider live addons for GC
230    let currently_installed_addons = list_installed_uki_addons(storage, &None)?
231        .into_iter()
232        .filter(|addon| !addon.staged)
233        .collect::<Vec<_>>();
234
235    if !currently_installed_addons
236        .iter()
237        .any(|addon| addon.addon_type == UkiAddonType::Global)
238    {
239        tracing::debug!("No bootc managed Global UKI Addon installed");
240        return Ok(());
241    }
242
243    // Remove unreferenced global UKI Addons
244    // GC always runs *after* finalizing deployments so there would be no staged deployment
245    // by definition
246    let currently_referenced_addons =
247        list_referenced_uki_addons(booted_cfs, &bootloader_entries, &None)?;
248    let mut unreferenced_global_addons = vec![];
249
250    tracing::debug!("currently_referenced_addons: {currently_referenced_addons:#?}");
251    tracing::debug!("currently_installed_addons: {currently_installed_addons:#?}");
252
253    for installed_addon in &currently_installed_addons {
254        // We handle scoped UKI Addons along with the UKI itself
255        if installed_addon.addon_type != UkiAddonType::Global {
256            continue;
257        }
258
259        let is_referenced = currently_referenced_addons.iter().any(|(_, refs)| {
260            refs.iter().any(|r| {
261                r.addon_type == installed_addon.addon_type && r.name == installed_addon.name
262            })
263        });
264
265        if !is_referenced {
266            unreferenced_global_addons.push(installed_addon.name.clone());
267        }
268    }
269
270    tracing::debug!("Unreferenced Global Addons: {unreferenced_global_addons:?}");
271
272    if !unreferenced_global_addons.is_empty() {
273        let global_uki_dir = storage
274            .require_boot_mount()?
275            .fd
276            .open_dir(GLOBAL_UKI_ADDONS_DIR)
277            .context("Opening global UKI Addons dir")?;
278
279        for addon in &unreferenced_global_addons {
280            let global_addon_name = get_global_uki_addon_name(&addon);
281
282            tracing::debug!("Deleting Global UKI Addon: {}", addon);
283
284            if gc_opts.dry_run {
285                continue;
286            }
287
288            global_uki_dir
289                .remove_file(&global_addon_name)
290                .with_context(|| format!("Removing global addon {global_addon_name}"))?;
291        }
292    }
293
294    Ok(())
295}
296
297pub(crate) struct GCOpts {
298    pub(crate) dry_run: bool,
299    pub(crate) prune_repo: bool,
300}
301
302fn orphaned_state_dirs<'a>(
303    state_dirs: &'a [String],
304    live_deployments: &[&str],
305    bootloader_entries: &[BootloaderEntry],
306    is_aboot: bool,
307) -> Vec<&'a String> {
308    state_dirs
309        .iter()
310        .filter(|state| {
311            if is_aboot {
312                !live_deployments.contains(&state.as_str())
313            } else {
314                !bootloader_entries
315                    .iter()
316                    .any(|entry| &entry.fsverity == *state)
317            }
318        })
319        .collect()
320}
321
322fn get_boot_dump(booted_cfs: &BootedComposefs, verity: &str) -> Result<Option<Vec<u8>>> {
323    let verity_to_sha = Sha512HashValue::from_hex(verity);
324
325    let Ok(verity_to_sha) = verity_to_sha else {
326        // We do not want to block on GC if we find corrupted state
327        tracing::warn!("Bad FSVerity '{verity}' when checkign additional roots, skipping");
328        return Ok(None);
329    };
330
331    let linked_images = match linked_erofs_images(&booted_cfs.repo, &verity_to_sha) {
332        Ok(images) => images,
333        Err(err) => {
334            tracing::warn!(
335                "Unable to inspect linked EROFS images for orphan '{verity}', skipping boot-object cleanup: {err:#}"
336            );
337            return Ok(None);
338        }
339    };
340
341    // Get any image that is non-bootable
342    let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else {
343        tracing::debug!("No non-bootable image found for {verity}");
344        return Ok(None);
345    };
346
347    let boot_dump = composefs_ctl::dump_files(
348        &booted_cfs.repo,
349        &non_bootable_img.id.to_hex(),
350        &vec![std::path::Path::new("/").join(crate::install::BOOT)],
351        false,
352    )
353    .context("Getting dump for /boot")?;
354
355    if boot_dump.is_empty() {
356        tracing::debug!("Nothing found in /boot in non-bootable image");
357        return Ok(None);
358    }
359
360    Ok(Some(boot_dump))
361}
362
363#[fn_error_context::context("GC UKI Artifacts")]
364fn gc_uki_artifacts(
365    booted_cfs: &BootedComposefs,
366    gc_opts: &GCOpts,
367    additional_roots: &Vec<&str>,
368    all_orphans: &Vec<&String>,
369) -> Result<GcResult> {
370    let mut live_objects: HashSet<String> = HashSet::new();
371    let mut result = GcResult {
372        objects_removed: 0,
373        objects_bytes: 0,
374        ..Default::default()
375    };
376
377    for verity in additional_roots {
378        let Some(boot_dump) = get_boot_dump(booted_cfs, verity)? else {
379            continue;
380        };
381
382        let boot_dump = std::str::from_utf8(&boot_dump).context("dumpfile is not UTF-8")?;
383
384        use composefs::dumpfile_parse::{Entry, Item};
385
386        for line in boot_dump.lines() {
387            let entry =
388                Entry::parse(line).with_context(|| format!("Parsing dumpfile line '{line}'"))?;
389
390            let Item::Regular { path, .. } = entry.item else {
391                continue;
392            };
393
394            live_objects.insert(
395                path.to_str()
396                    .ok_or_else(|| anyhow::anyhow!("Object path is not valid UTF-8"))?
397                    .to_string(),
398            );
399        }
400    }
401
402    flock(&booted_cfs.repo.repo_fd(), FlockOperation::LockExclusive)
403        .context("Acquiring exclusive lock for GC")?;
404
405    // Now GC the UKI/UKI Addons from `.boot` EROFS if we have them.
406    // Aboot artifacts may share repository objects and are left to repository GC.
407    // These won't be GC'd by the above `repo.gc` as the EROFS have `/boot` masked.
408    for verity in all_orphans {
409        let Some(boot_dump) = get_boot_dump(booted_cfs, verity)? else {
410            continue;
411        };
412
413        let boot_dump = std::str::from_utf8(&boot_dump).context("dumpfile is not UTF-8")?;
414
415        let objects_dir = booted_cfs
416            .repo
417            .objects_dir()
418            .context("Opening objects dir")?;
419
420        use composefs::dumpfile_parse::{Entry, Item};
421
422        for line in boot_dump.lines() {
423            let entry =
424                Entry::parse(line).with_context(|| format!("Parsing dumpfile line '{line}'"))?;
425
426            let Item::Regular { path, .. } = entry.item else {
427                continue;
428            };
429
430            let path_string = path
431                .to_str()
432                .ok_or_else(|| anyhow::anyhow!("Object path is not valid UTF-8"))?
433                .to_string();
434
435            if live_objects.contains(&path_string) {
436                tracing::debug!("{path:?} is still referenced. Skipping...");
437                continue;
438            }
439
440            tracing::debug!(
441                "{}: objects/{path:?}",
442                if gc_opts.dry_run {
443                    "would remove"
444                } else {
445                    "removing"
446                },
447            );
448
449            if gc_opts.dry_run {
450                continue;
451            }
452
453            // Get file size before removing
454            match statat(&objects_dir, path.as_ref(), AtFlags::empty()) {
455                Ok(stat) => {
456                    result.objects_bytes += stat.st_size as u64;
457                }
458                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
459                    tracing::debug!("Object {} not found... skipping", path.display());
460                    continue;
461                }
462                Err(e) => Err(e).with_context(|| format!("statat {} failed", path.display()))?,
463            }
464
465            result.objects_removed += 1;
466
467            unlinkat(&objects_dir, path.as_ref(), AtFlags::empty())
468                .with_context(|| format!("Unlinking (from bootc) object {path:?}"))?;
469        }
470    }
471
472    flock(booted_cfs.repo.repo_fd(), FlockOperation::Unlock).context("repository unlock failed")?;
473
474    Ok(result)
475}
476
477/// 1. List all bootloader entries
478/// 2. List all EROFS images
479/// 3. List all state directories
480/// 4. List staged depl if any
481///
482/// If bootloader entry B1 doesn't exist, but EROFS image B1 does exist, then delete the image and
483/// perform GC
484///
485/// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and
486/// perform GC
487//
488// Cases
489// - BLS Entries
490//      - On upgrade/switch, if only two are left, the staged and the current, then no GC
491//          - If there are three - rollback, booted and staged, GC the rollback, so the current
492//          becomes rollback
493#[fn_error_context::context("Running composefs garbage collection")]
494pub(crate) async fn composefs_gc(
495    storage: &Storage,
496    booted_cfs: &BootedComposefs,
497    gc_opts: GCOpts,
498) -> Result<GcResult> {
499    const COMPOSEFS_GC_JOURNAL_ID: &str = "3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7";
500
501    tracing::info!(
502        message_id = COMPOSEFS_GC_JOURNAL_ID,
503        bootc.operation = "gc",
504        bootc.current_deployment = booted_cfs.cmdline.digest,
505        "Starting composefs garbage collection"
506    );
507
508    // Upgrade any old-format OCI images (pre-EROFS-at-pull-time) before GC.
509    //
510    // Old bootc (pre composefs-rs 93634590c) used a seal-based flow that stored
511    // the composefs EROFS hash in an OCI config label but did NOT commit the EROFS
512    // image into the repository's images/ directory.  The GC's additional_roots
513    // mechanism protects deployments by looking up each deployment's EROFS verity
514    // in images/ and walking its object refs — but if no such image exists (old
515    // format), all the layer blob objects for that deployment appear unreferenced
516    // and are incorrectly collected.
517    //
518    // upgrade_repo() walks every tagged OCI image and generates EROFS for any
519    // that lack it, rewriting their config splitstreams.  After this step the
520    // additional_roots lookup succeeds and the rollback deployment's objects are
521    // protected.  This is a no-op for already-upgraded images (idempotent).
522    //
523    // Safety net: upgrade_repo() is also called in pull_composefs_repo() so
524    // that it runs at `bootc upgrade`/`bootc switch` time before any new
525    // deployment is staged.  Running it here too covers the case where GC is
526    // invoked directly (e.g. `bootc internals composefs-gc`) on a system that
527    // skipped the pull path.  upgrade_repo() is idempotent (fast-paths images
528    // that already have EROFS refs) and always runs even in dry-run mode since
529    // it is a format migration, not a deletion.
530    let upgrade_result = composefs_oci::upgrade_repo(&booted_cfs.repo)
531        .context("Upgrading old-format OCI images before GC")?;
532    if upgrade_result.upgraded > 0 {
533        tracing::info!(
534            "Upgraded {} old-format OCI image(s) to current format before GC",
535            upgrade_result.upgraded
536        );
537    }
538
539    let host = get_composefs_status(storage, booted_cfs).await?;
540    let booted_cfs_status = host.require_composefs_booted()?;
541
542    let sysroot = &storage.physical_root;
543
544    let is_aboot = booted_cfs_status.boot_type == BootType::Aboot;
545    let (bootloader_entries, boot_binaries) = if is_aboot {
546        (Vec::new(), Vec::new())
547    } else {
548        (
549            list_bootloader_entries(storage)?,
550            collect_boot_binaries(storage)?,
551        )
552    };
553
554    tracing::debug!("bootloader_entries: {bootloader_entries:?}");
555    tracing::debug!("boot_binaries: {boot_binaries:?}");
556
557    let unreferenced_boot_binaries =
558        unreferenced_boot_binaries(&boot_binaries, &bootloader_entries);
559
560    tracing::debug!("unreferenced_boot_binaries: {unreferenced_boot_binaries:?}");
561
562    if unreferenced_boot_binaries
563        .iter()
564        .find(|be| be.1 == booted_cfs_status.verity)
565        .is_some()
566    {
567        anyhow::bail!(
568            "Inconsistent state. Booted binaries '{}' found for cleanup",
569            booted_cfs_status.verity
570        )
571    }
572
573    for (ty, verity) in &unreferenced_boot_binaries {
574        match ty {
575            BootType::Bls => {
576                delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)?
577            }
578            BootType::Uki => delete_uki(storage, verity, gc_opts.dry_run)?,
579            BootType::Aboot => {
580                anyhow::bail!("Aboot artifact unexpectedly reached boot binary garbage collection")
581            }
582        }
583    }
584
585    if booted_cfs_status.boot_type == BootType::Uki {
586        gc_global_uki_addons(storage, booted_cfs, &gc_opts, &bootloader_entries)?;
587    }
588
589    if !gc_opts.prune_repo {
590        return Ok(GcResult::default());
591    }
592
593    // Identify orphaned deployments: state dirs or bootloader entries
594    // that don't correspond to a live deployment. EROFS images in
595    // composefs/images/ are NOT managed here — repo.gc() handles those
596    // via the tag→manifest→config→image ref chain.
597    let state_dirs = list_state_dirs(&sysroot)?;
598
599    let staged = &host.status.staged;
600
601    let live_deployments = host
602        .list_deployments()
603        .into_iter()
604        .map(|deployment| {
605            deployment
606                .require_composefs()
607                .map(|cfs| cfs.verity.as_str())
608        })
609        .collect::<Result<Vec<_>>>()?;
610
611    // Aboot uses slot/pending references from status (because there are no BLS files).
612    // BLS/UKI use raw boot entries, which can retain deployments omitted from status
613    // after interrupted operations.
614    let orphaned_state_dirs = orphaned_state_dirs(
615        &state_dirs,
616        &live_deployments,
617        &bootloader_entries,
618        is_aboot,
619    );
620
621    // Bootloader entries without a state dir are from interrupted cleanups.
622    let orphaned_boot_entries: Vec<_> = if is_aboot {
623        Vec::new()
624    } else {
625        bootloader_entries
626            .iter()
627            .map(|entry| &entry.fsverity)
628            .filter(|verity| !state_dirs.contains(verity))
629            .collect()
630    };
631
632    let all_orphans: Vec<_> = orphaned_state_dirs
633        .iter()
634        .chain(orphaned_boot_entries.iter())
635        .copied()
636        .collect();
637
638    if all_orphans.contains(&&booted_cfs_status.verity) {
639        anyhow::bail!(
640            "Inconsistent state. Booted entry '{}' found for cleanup",
641            booted_cfs_status.verity
642        )
643    }
644
645    for verity in &orphaned_state_dirs {
646        tracing::debug!("Cleaning up orphaned state dir: {verity}");
647        delete_staged(staged, &all_orphans, gc_opts.dry_run)?;
648        delete_state_dir(&sysroot, verity, gc_opts.dry_run)?;
649    }
650
651    for verity in &orphaned_boot_entries {
652        tracing::debug!("Cleaning up orphaned bootloader entry: {verity}");
653        delete_staged(staged, &all_orphans, gc_opts.dry_run)?;
654    }
655
656    // Collect the set of manifest digests referenced by live deployments,
657    // and track EROFS image verities as fallback additional_roots for
658    // deployments that predate the manifest -> image link.
659    let mut live_manifest_digests: Vec<composefs_oci::OciDigest> = Vec::new();
660    let mut additional_roots = Vec::new();
661    // Container image names for containers-storage pruning.
662    let mut live_container_images: std::collections::HashSet<String> = Default::default();
663
664    // Read existing tags before the deployment loop so we can search
665    // them for deployments that lack manifest_digest in their origin.
666    let existing_tags = composefs_oci::list_refs(&*booted_cfs.repo)
667        .context("Listing OCI tags in composefs repo")?;
668
669    for deployment in host.list_deployments() {
670        let verity = &deployment.require_composefs()?.verity;
671
672        // Skip deployments that are already being GC'd.
673        if all_orphans.contains(&verity) {
674            continue;
675        }
676
677        // Keep the EROFS image as an additional root until all deployments
678        // have manifest -> image refs. Once a deployment is pulled with the
679        // new code, its EROFS image is reachable from the manifest and
680        // this entry becomes redundant (but harmless).
681        additional_roots.push(verity.clone());
682
683        if let Some(ini) = read_origin(sysroot, verity)? {
684            // Collect the container image name for containers-storage GC.
685            if let Some(container_ref) =
686                ini.get::<String>("origin", ostree_ext::container::deploy::ORIGIN_CONTAINER)
687            {
688                // Parse the ostree image reference to extract the bare image name
689                // (e.g. "quay.io/foo:tag" from "ostree-unverified-image:docker://quay.io/foo:tag")
690                let image_name = container_ref
691                    .parse::<ostree_ext::container::OstreeImageReference>()
692                    .map(|r| r.imgref.name)
693                    .unwrap_or_else(|_| container_ref.clone());
694                live_container_images.insert(image_name);
695            }
696
697            if let Some(manifest_digest_str) =
698                ini.get::<String>(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST)
699            {
700                let digest: composefs_oci::OciDigest = manifest_digest_str
701                    .parse()
702                    .with_context(|| format!("Parsing manifest digest {manifest_digest_str}"))?;
703                live_manifest_digests.push(digest);
704            } else {
705                // Pre-OCI-metadata deployment: search tagged manifests
706                // for one whose config links to this EROFS image.
707                let mut found_manifest = false;
708                for (_, ref_digest) in &existing_tags {
709                    if let Ok(img) = composefs_oci::oci_image::OciImage::open(
710                        &*booted_cfs.repo,
711                        ref_digest,
712                        None,
713                    ) {
714                        // Check both V1 and V2 slots: the deployment verity
715                        // may have been produced under either format.
716                        if image_refs_match(img.image_ref_v1(), img.image_ref_v2(), verity) {
717                            tracing::info!(
718                                "Deployment {verity} has no manifest_digest in origin; \
719                                 found matching manifest {ref_digest} via image_ref"
720                            );
721                            live_manifest_digests.push(ref_digest.clone());
722                            found_manifest = true;
723                            break;
724                        }
725                    }
726                }
727                if !found_manifest {
728                    tracing::warn!(
729                        "Deployment {verity} has no manifest_digest in origin \
730                         and no tagged manifest references it; \
731                         EROFS image is protected but OCI metadata may be collected"
732                    );
733                }
734            }
735        }
736    }
737
738    // Migration: ensure every live deployment has a bootc-owned tag.
739    // Deployments from before the tag-based GC won't have tags yet;
740    // create them now so their OCI metadata survives this GC cycle.
741
742    for manifest_digest in &live_manifest_digests {
743        let expected_tag = bootc_tag_for_manifest(&manifest_digest.to_string());
744        let has_tag = existing_tags
745            .iter()
746            .any(|(tag_name, _)| tag_name == &expected_tag);
747        if !has_tag {
748            tracing::info!("Creating missing bootc tag for live deployment: {expected_tag}");
749            if !gc_opts.dry_run {
750                composefs_oci::tag_image(&*booted_cfs.repo, manifest_digest, &expected_tag)
751                    .with_context(|| format!("Creating migration tag {expected_tag}"))?;
752            }
753        }
754    }
755
756    // Re-read tags after potential migration.
757    let all_tags = composefs_oci::list_refs(&*booted_cfs.repo)
758        .context("Listing OCI tags in composefs repo")?;
759
760    for (tag_name, manifest_digest) in &all_tags {
761        if !tag_name.starts_with(BOOTC_TAG_PREFIX) {
762            // Not a bootc-owned tag; leave it alone (could be an app image).
763            continue;
764        }
765
766        if !live_manifest_digests.iter().any(|d| d == manifest_digest) {
767            tracing::debug!("Removing unreferenced bootc tag: {tag_name}");
768            if !gc_opts.dry_run {
769                composefs_oci::untag_image(&*booted_cfs.repo, tag_name)
770                    .with_context(|| format!("Removing tag {tag_name}"))?;
771            }
772        }
773    }
774
775    let additional_roots = additional_roots
776        .iter()
777        .map(|x| x.as_str())
778        .collect::<Vec<_>>();
779
780    // Prune containers-storage: remove images not backing any live deployment.
781    if !gc_opts.dry_run && !live_container_images.is_empty() {
782        let subpath = crate::podstorage::CStorage::subpath();
783        if sysroot.try_exists(&subpath).unwrap_or(false) {
784            let run = Dir::open_ambient_dir("/run", cap_std_ext::cap_std::ambient_authority())?;
785            let imgstore = crate::podstorage::CStorage::create(&sysroot, &run, None)?;
786            let roots: std::collections::HashSet<&str> =
787                live_container_images.iter().map(|s| s.as_str()).collect();
788            let pruned = imgstore.prune_except_roots(&roots).await?;
789            if !pruned.is_empty() {
790                tracing::info!("Pruned {} images from containers-storage", pruned.len());
791            }
792        }
793    }
794
795    let uki_gc_result = if booted_cfs_status.boot_type == BootType::Uki {
796        let res = gc_uki_artifacts(booted_cfs, &gc_opts, &additional_roots, &all_orphans)?;
797        Some(res)
798    } else {
799        None
800    };
801
802    // Run garbage collection. Tags root the OCI metadata chain
803    // (manifest → config → layers). The additional_roots protect EROFS
804    // images for deployments that predate the manifest→image link;
805    // once all deployments have been pulled with the new code, these
806    // become redundant.
807    // `Repository::gc()` takes an exclusive flock on the repository file as its
808    // first action. Callers must ensure no other `Repository` handle on that
809    // same underlying file is still alive when this runs, or it will deadlock
810    // (see update.rs's do_upgrade() for an example of getting this wrong).
811    let mut gc_result = if gc_opts.dry_run {
812        booted_cfs.repo.gc_dry_run(&additional_roots)?
813    } else {
814        booted_cfs.repo.gc(&additional_roots)?
815    };
816
817    if let Some(uki_gc_result) = uki_gc_result {
818        gc_result.objects_bytes += uki_gc_result.objects_bytes;
819        gc_result.objects_removed += uki_gc_result.objects_removed;
820    }
821
822    Ok(gc_result)
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::bootc_composefs::status::list_type1_entries;
829    use crate::bootc_composefs::{aboot, status::ComposefsCmdline};
830    use crate::composefs_consts::STATE_DIR_RELATIVE;
831    use crate::store::ComposefsRepository;
832    use crate::testutils::{ChangeType, TestRoot};
833    use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir};
834    use composefs_ctl::composefs::repository::RepositoryConfig;
835
836    fn write_aboot_origin(root: &Dir, digest: &str) -> Result<()> {
837        let path = format!("{STATE_DIR_RELATIVE}/{digest}");
838        root.create_dir_all(&path)?;
839        root.atomic_write(
840            format!("{path}/{digest}.origin"),
841            "[boot]\nboot_type=aboot\ndigest=boot-digest\n",
842        )?;
843        Ok(())
844    }
845
846    #[test]
847    fn aboot_orphans_only_unreferenced_state() {
848        let state = ["booted".into(), "rollback".into(), "invalid".into()];
849        let live = ["booted", "rollback"];
850        let orphans = orphaned_state_dirs(&state, &live, &[], true);
851        assert_eq!(
852            orphans.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
853            ["invalid"]
854        );
855
856        let live = ["booted"];
857        let orphans = orphaned_state_dirs(&state, &live, &[], true);
858        assert_eq!(
859            orphans.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
860            ["rollback", "invalid"]
861        );
862    }
863
864    #[tokio::test]
865    async fn aboot_gc_uses_status_roots() -> Result<()> {
866        let root = tempdir(ambient_authority())?;
867        root.create_dir_all("composefs")?;
868        root.create_dir("run")?;
869        let repo_dir = root.open_dir("composefs")?;
870        let config = RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512)
871            .set_insecure();
872        let (repo, _) = ComposefsRepository::init_path(&repo_dir, ".", config)?;
873        let repo = std::sync::Arc::new(repo);
874        let storage = Storage::new_composefs_for_test(&root, &root.open_dir("run")?)?;
875        let booted = "aa".repeat(64);
876        let rollback = "bb".repeat(64);
877        let orphan = "cc".repeat(64);
878        for digest in [&booted, &rollback, &orphan] {
879            write_aboot_origin(&root, digest)?;
880        }
881        let mut aboot_state = aboot::AbootState::open(&root)?;
882        aboot_state.record_booted(
883            &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_a"),
884            &booted,
885        )?;
886        aboot_state.record_booted(
887            &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_b"),
888            &rollback,
889        )?;
890        let cmdline = Box::leak(Box::new(ComposefsCmdline::new(&booted)));
891        let booted_cfs = BootedComposefs { repo, cmdline };
892
893        composefs_gc(
894            &storage,
895            &booted_cfs,
896            GCOpts {
897                dry_run: false,
898                prune_repo: true,
899            },
900        )
901        .await?;
902
903        assert!(root.try_exists(format!("{STATE_DIR_RELATIVE}/{booted}"))?);
904        assert!(root.try_exists(format!("{STATE_DIR_RELATIVE}/{rollback}"))?);
905        assert!(!root.try_exists(format!("{STATE_DIR_RELATIVE}/{orphan}"))?);
906        Ok(())
907    }
908
909    #[test]
910    fn test_image_refs_match_v2_when_v1_is_present() {
911        let v1 = composefs::fsverity::Sha512HashValue::from_hex(&"11".repeat(64)).unwrap();
912        let v2 = composefs::fsverity::Sha512HashValue::from_hex(&"22".repeat(64)).unwrap();
913
914        assert!(image_refs_match(Some(&v1), Some(&v2), &v2.to_hex()));
915    }
916
917    /// Reproduce the shared-entry GC bug from issue #2102.
918    ///
919    /// Scenario with both shared and non-shared kernels:
920    ///
921    /// 1. Install deployment A (kernel K1, boot dir "A")
922    /// 2. Upgrade to B, same kernel → shares A's boot dir
923    /// 3. Upgrade to C, new kernel K2 → gets its own boot dir "C"
924    /// 4. Upgrade to D, same kernel as C → shares C's boot dir
925    ///
926    /// After GC of A (the creator of boot dir used by B):
927    /// - A's boot dir must still exist (B references it)
928    /// - C's boot dir must still exist (D references it)
929    ///
930    /// The old code compared `fsverity` instead of `boot_artifact_name`,
931    /// which would incorrectly mark A's boot dir as unreferenced once A's
932    /// BLS entry is gone — even though B still points its linux/initrd
933    /// paths at A's directory.
934    #[test]
935    fn test_gc_shared_boot_binaries_not_deleted() -> anyhow::Result<()> {
936        let mut root = TestRoot::new()?;
937        let digest_a = root.current().verity.clone();
938
939        // B shares A's kernel (userspace-only change)
940        root.upgrade(1, ChangeType::Userspace)?;
941
942        // C gets a new kernel
943        root.upgrade(2, ChangeType::Kernel)?;
944        let digest_c = root.current().verity.clone();
945
946        // D shares C's kernel (userspace-only change)
947        root.upgrade(3, ChangeType::Userspace)?;
948        let digest_d = root.current().verity.clone();
949
950        // Now GC deployment A — the one that *created* the shared boot dir
951        root.gc_deployment(&digest_a)?;
952
953        // At this point only C (secondary) and D (primary) have BLS entries.
954        // But A's boot binary directory is still on disk because B used to
955        // share it and we haven't cleaned up boot binaries yet — that's
956        // what the GC filter decides.
957        let boot_dir = root.boot_dir()?;
958
959        // Collect what's on disk: two boot dirs (A's and C's)
960        let mut on_disk = Vec::new();
961        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
962        assert_eq!(
963            on_disk.len(),
964            2,
965            "should have A's and C's boot dirs on disk"
966        );
967
968        // Collect what the BLS entries reference
969        let bls_entries = list_type1_entries(&boot_dir)?;
970        assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
971
972        // The fix: unreferenced_boot_binaries uses boot_artifact_name.
973        // D's boot_artifact_name points to C's dir, C's points to itself.
974        // A's boot dir is NOT referenced by any current BLS entry's
975        // boot_artifact_name (B was the one referencing it, and B is no
976        // longer in the BLS entries either).
977        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
978
979        // A's boot dir IS unreferenced (only B used it, and B isn't in BLS anymore)
980        assert_eq!(unreferenced.len(), 1);
981        assert_eq!(unreferenced[0].1, digest_a);
982
983        // C's boot dir is still referenced (by both C and D via boot_artifact_name)
984        assert!(
985            !unreferenced.iter().any(|b| b.1 == digest_c),
986            "C's boot dir must not be unreferenced"
987        );
988
989        // Now the more dangerous scenario: GC C, the creator of the boot
990        // dir that D shares. After this, remaining deployments are [B, D].
991        // B still shares A's boot dir, D still shares C's boot dir.
992        root.gc_deployment(&digest_c)?;
993
994        let mut on_disk_2 = Vec::new();
995        collect_type1_boot_binaries(&root.boot_dir()?, &mut on_disk_2)?;
996        // A's dir + C's dir still on disk (boot binary cleanup hasn't run)
997        assert_eq!(on_disk_2.len(), 2);
998
999        let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?;
1000        // D (primary) + B (secondary)
1001        assert_eq!(bls_entries_2.len(), 2);
1002
1003        let entry_d = bls_entries_2
1004            .iter()
1005            .find(|e| e.fsverity == digest_d)
1006            .unwrap();
1007        assert_eq!(
1008            entry_d.boot_artifact_name, digest_c,
1009            "D shares C's boot dir"
1010        );
1011
1012        let unreferenced_2 = unreferenced_boot_binaries(&on_disk_2, &bls_entries_2);
1013
1014        // Both boot dirs are still referenced:
1015        // - A's dir via B's boot_artifact_name
1016        // - C's dir via D's boot_artifact_name
1017        assert!(
1018            unreferenced_2.is_empty(),
1019            "no boot dirs should be unreferenced when both are shared"
1020        );
1021
1022        // Prove the old buggy logic would fail: if we compared fsverity
1023        // instead of boot_artifact_name, BOTH dirs would be wrongly
1024        // unreferenced. Neither A nor C has a BLS entry with matching
1025        // fsverity — only B (verity=B) and D (verity=D) exist, but their
1026        // boot dirs are named after A and C respectively.
1027        let buggy_unreferenced: Vec<_> = on_disk_2
1028            .iter()
1029            .filter(|bin| !bls_entries_2.iter().any(|e| e.fsverity == bin.1))
1030            .collect();
1031        assert_eq!(
1032            buggy_unreferenced.len(),
1033            2,
1034            "old fsverity-based logic would incorrectly GC both boot dirs"
1035        );
1036
1037        Ok(())
1038    }
1039
1040    /// Verify that list_type1_entries correctly parses legacy (unprefixed) BLS
1041    /// entries. This is the code path that composefs_gc actually uses to find
1042    /// bootloader entries, so it's critical that it handles both layouts.
1043    #[test]
1044    fn test_list_type1_entries_handles_legacy_bls() -> anyhow::Result<()> {
1045        let mut root = TestRoot::new_legacy()?;
1046        let digest_a = root.current().verity.clone();
1047
1048        root.upgrade(1, ChangeType::Userspace)?;
1049        let digest_b = root.current().verity.clone();
1050
1051        let boot_dir = root.boot_dir()?;
1052        let bls_entries = list_type1_entries(&boot_dir)?;
1053
1054        assert_eq!(bls_entries.len(), 2, "Should find both BLS entries");
1055
1056        // boot_artifact_name should return the raw digest (no prefix)
1057        // because the legacy entries don't have the prefix
1058        for entry in &bls_entries {
1059            assert_eq!(
1060                entry.boot_artifact_name, digest_a,
1061                "Both entries should reference A's boot dir (shared kernel)"
1062            );
1063        }
1064
1065        // fsverity should differ between the two entries
1066        let verity_set: std::collections::HashSet<&str> =
1067            bls_entries.iter().map(|e| e.fsverity.as_str()).collect();
1068        assert!(verity_set.contains(digest_a.as_str()));
1069        assert!(verity_set.contains(digest_b.as_str()));
1070
1071        Ok(())
1072    }
1073
1074    /// Legacy (unprefixed) boot dirs are invisible to collect_type1_boot_binaries,
1075    /// which only looks for the `bootc_composefs-` prefix. This test verifies
1076    /// that the GC scanner does not see unprefixed directories.
1077    ///
1078    /// This is the problem that PR #2128 solves by migrating legacy entries
1079    /// to the prefixed format before any GC or status operations run.
1080    #[test]
1081    fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> {
1082        let root = TestRoot::new_legacy()?;
1083
1084        // The legacy layout creates a boot dir without the prefix
1085        let boot_dir = root.boot_dir()?;
1086        let mut on_disk = Vec::new();
1087        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1088
1089        // collect_type1_boot_binaries requires the prefix — legacy dirs
1090        // are invisible to it
1091        assert!(
1092            on_disk.is_empty(),
1093            "Legacy (unprefixed) boot dirs should not be found by collect_type1_boot_binaries"
1094        );
1095
1096        Ok(())
1097    }
1098
1099    /// After migration from legacy to prefixed layout, GC should work
1100    /// correctly — the boot binary directories become visible and
1101    /// the BLS entries reference them properly.
1102    #[test]
1103    fn test_gc_works_after_legacy_migration() -> anyhow::Result<()> {
1104        let mut root = TestRoot::new_legacy()?;
1105        let digest_a = root.current().verity.clone();
1106
1107        // B shares A's kernel (userspace-only change)
1108        root.upgrade(1, ChangeType::Userspace)?;
1109
1110        // C gets a new kernel
1111        root.upgrade(2, ChangeType::Kernel)?;
1112
1113        // Simulate the migration that PR #2128 performs
1114        root.migrate_to_prefixed()?;
1115
1116        // Now GC should see both boot dirs
1117        let boot_dir = root.boot_dir()?;
1118        let mut on_disk = Vec::new();
1119        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1120        assert_eq!(on_disk.len(), 2, "Should see A's and C's boot dirs");
1121
1122        // BLS entries should correctly reference boot artifact names
1123        let bls_entries = list_type1_entries(&boot_dir)?;
1124        assert_eq!(bls_entries.len(), 2);
1125
1126        // No boot dirs should be unreferenced (all are in use)
1127        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1128        assert!(
1129            unreferenced.is_empty(),
1130            "All boot dirs should be referenced after migration"
1131        );
1132
1133        // GC deployment A (the one that created the shared boot dir)
1134        root.gc_deployment(&digest_a)?;
1135
1136        let boot_dir = root.boot_dir()?;
1137        let bls_entries = list_type1_entries(&boot_dir)?;
1138        assert_eq!(bls_entries.len(), 2, "B (secondary) + C (primary)");
1139
1140        let mut on_disk = Vec::new();
1141        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1142        assert_eq!(on_disk.len(), 2, "Both boot dirs still on disk");
1143
1144        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1145        // A's boot dir is still referenced by B
1146        assert!(
1147            unreferenced.is_empty(),
1148            "A's boot dir should still be referenced by B after migration"
1149        );
1150
1151        Ok(())
1152    }
1153
1154    /// Test the full upgrade cycle with shared kernels after migration:
1155    /// install (legacy) → migrate → upgrade → GC.
1156    ///
1157    /// This verifies that GC correctly handles a system that was originally
1158    /// installed with old bootc, migrated, and then upgraded with new bootc.
1159    #[test]
1160    fn test_gc_post_migration_upgrade_cycle() -> anyhow::Result<()> {
1161        let mut root = TestRoot::new_legacy()?;
1162        let digest_a = root.current().verity.clone();
1163
1164        // B shares A's kernel (still legacy)
1165        root.upgrade(1, ChangeType::Userspace)?;
1166
1167        // Simulate migration
1168        root.migrate_to_prefixed()?;
1169
1170        // Now upgrade with new bootc (creates prefixed entries)
1171        root.upgrade(2, ChangeType::Kernel)?;
1172        let digest_c = root.current().verity.clone();
1173
1174        // D shares C's kernel
1175        root.upgrade(3, ChangeType::Userspace)?;
1176        let digest_d = root.current().verity.clone();
1177
1178        // GC all old deployments, keeping only C and D
1179        root.gc_deployment(&digest_a)?;
1180
1181        let boot_dir = root.boot_dir()?;
1182        let mut on_disk = Vec::new();
1183        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1184
1185        let bls_entries = list_type1_entries(&boot_dir)?;
1186        assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
1187
1188        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1189        // A's boot dir is unreferenced (B is gone, only C and D remain)
1190        assert_eq!(
1191            unreferenced.len(),
1192            1,
1193            "A's boot dir should be unreferenced after GC of A and B is evicted"
1194        );
1195        assert_eq!(unreferenced[0].1, digest_a);
1196
1197        // C's boot dir must still be referenced by D
1198        assert!(
1199            !unreferenced.iter().any(|b| b.1 == digest_c),
1200            "C's boot dir must still be referenced by D"
1201        );
1202
1203        // Verify D shares C's boot dir
1204        let entry_d = bls_entries
1205            .iter()
1206            .find(|e| e.fsverity == digest_d)
1207            .expect("D should have a BLS entry");
1208        assert_eq!(
1209            entry_d.boot_artifact_name, digest_c,
1210            "D should share C's boot dir"
1211        );
1212
1213        Ok(())
1214    }
1215
1216    /// Test deep transitive sharing: A → B → C → D all share A's boot dir
1217    /// via successive userspace-only upgrades. When we GC A (the creator
1218    /// of the boot dir), the dir must be kept because the remaining
1219    /// deployments still reference it.
1220    ///
1221    /// This tests that boot_dir_verity propagates correctly through
1222    /// a chain of userspace-only upgrades and that the GC filter handles
1223    /// the case where no remaining deployment's fsverity matches the
1224    /// boot directory name.
1225    #[test]
1226    fn test_gc_deep_transitive_sharing_chain() -> anyhow::Result<()> {
1227        let mut root = TestRoot::new()?;
1228        let digest_a = root.current().verity.clone();
1229
1230        // B, C, D all share A's kernel via userspace-only upgrades
1231        root.upgrade(1, ChangeType::Userspace)?;
1232        root.upgrade(2, ChangeType::Userspace)?;
1233        root.upgrade(3, ChangeType::Userspace)?;
1234        let digest_d = root.current().verity.clone();
1235
1236        // Only one boot dir on disk (all share A's)
1237        let boot_dir = root.boot_dir()?;
1238        let mut on_disk = Vec::new();
1239        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1240        assert_eq!(on_disk.len(), 1, "All deployments share one boot dir");
1241        assert_eq!(on_disk[0].1, digest_a, "The boot dir belongs to A");
1242
1243        // BLS entries: D (primary) + C (secondary), both referencing A's dir
1244        let bls_entries = list_type1_entries(&boot_dir)?;
1245        assert_eq!(bls_entries.len(), 2);
1246        for entry in &bls_entries {
1247            assert_eq!(
1248                entry.boot_artifact_name, digest_a,
1249                "All entries reference A's boot dir"
1250            );
1251        }
1252
1253        // GC deployment A (the creator of the shared boot dir)
1254        root.gc_deployment(&digest_a)?;
1255
1256        let boot_dir = root.boot_dir()?;
1257        let bls_entries = list_type1_entries(&boot_dir)?;
1258        // D (primary) + C (secondary) — A was already evicted from BLS
1259        assert_eq!(bls_entries.len(), 2);
1260
1261        let mut on_disk = Vec::new();
1262        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1263
1264        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1265        assert!(
1266            unreferenced.is_empty(),
1267            "A's boot dir must stay — C and D still reference it"
1268        );
1269
1270        // Now also GC B and C, leaving only D
1271        let digest_b = crate::testutils::fake_digest_version(1);
1272        let digest_c = crate::testutils::fake_digest_version(2);
1273        root.gc_deployment(&digest_b)?;
1274        root.gc_deployment(&digest_c)?;
1275
1276        // D is the only deployment left
1277        let boot_dir = root.boot_dir()?;
1278        let bls_entries = list_type1_entries(&boot_dir)?;
1279        assert_eq!(bls_entries.len(), 1, "Only D remains");
1280        assert_eq!(bls_entries[0].fsverity, digest_d);
1281        assert_eq!(
1282            bls_entries[0].boot_artifact_name, digest_a,
1283            "D still references A's boot dir"
1284        );
1285
1286        let mut on_disk = Vec::new();
1287        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1288        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1289        assert!(
1290            unreferenced.is_empty(),
1291            "A's boot dir must survive — D is the last deployment and still uses it"
1292        );
1293
1294        Ok(())
1295    }
1296
1297    /// Verify that boot_artifact_info().1 (has_prefix) is the correct
1298    /// signal for identifying entries that need migration, and that the
1299    /// GC filter works correctly at each stage of the migration pipeline.
1300    ///
1301    /// This exercises the API that stage_bls_entry_changes() in PR #2128
1302    /// uses to decide which entries to migrate.
1303    #[test]
1304    fn test_boot_artifact_info_drives_migration_decisions() -> anyhow::Result<()> {
1305        use crate::bootc_composefs::status::get_sorted_type1_boot_entries;
1306
1307        let mut root = TestRoot::new_legacy()?;
1308        let digest_a = root.current().verity.clone();
1309
1310        root.upgrade(1, ChangeType::Userspace)?;
1311        root.upgrade(2, ChangeType::Kernel)?;
1312
1313        // -- Pre-migration: all entries lack the prefix --
1314        let boot_dir = root.boot_dir()?;
1315        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
1316        assert_eq!(raw_entries.len(), 2);
1317
1318        let needs_migration: Vec<_> = raw_entries
1319            .iter()
1320            .filter(|e| !e.boot_artifact_info().unwrap().1)
1321            .collect();
1322        assert_eq!(
1323            needs_migration.len(),
1324            2,
1325            "All legacy entries should need migration (has_prefix=false)"
1326        );
1327
1328        // GC scanner can't see the boot dirs (no prefix on disk)
1329        let mut on_disk = Vec::new();
1330        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1331        assert!(on_disk.is_empty(), "Legacy dirs invisible before migration");
1332
1333        // -- Migrate --
1334        root.migrate_to_prefixed()?;
1335
1336        // -- Post-migration: all entries have the prefix --
1337        let boot_dir = root.boot_dir()?;
1338        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
1339        assert_eq!(raw_entries.len(), 2);
1340
1341        let needs_migration: Vec<_> = raw_entries
1342            .iter()
1343            .filter(|e| !e.boot_artifact_info().unwrap().1)
1344            .collect();
1345        assert!(
1346            needs_migration.is_empty(),
1347            "No entries should need migration after migrate_to_prefixed()"
1348        );
1349
1350        // GC scanner can now see the boot dirs
1351        let mut on_disk = Vec::new();
1352        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1353        assert_eq!(on_disk.len(), 2, "Both dirs visible after migration");
1354
1355        // GC filter correctly identifies all dirs as referenced
1356        let bls_entries = list_type1_entries(&boot_dir)?;
1357        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1358        assert!(
1359            unreferenced.is_empty(),
1360            "All dirs referenced after migration"
1361        );
1362
1363        // -- Upgrade with new bootc (prefixed from creation) --
1364        root.upgrade(3, ChangeType::Kernel)?;
1365
1366        let boot_dir = root.boot_dir()?;
1367        let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
1368        // All entries (both migrated and new) should have the prefix
1369        for entry in &raw_entries {
1370            let (_, has_prefix) = entry.boot_artifact_info()?;
1371            assert!(
1372                has_prefix,
1373                "All entries should have prefix after migration + upgrade"
1374            );
1375        }
1376
1377        // GC should now see 3 boot dirs: A's, C's (from upgrade 2), and
1378        // the new one from upgrade 3
1379        let mut on_disk = Vec::new();
1380        collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
1381        assert_eq!(on_disk.len(), 3, "Three boot dirs on disk");
1382
1383        // Only 2 BLS entries (primary + secondary), so one dir is unreferenced
1384        let bls_entries = list_type1_entries(&boot_dir)?;
1385        assert_eq!(bls_entries.len(), 2);
1386        let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
1387        assert_eq!(
1388            unreferenced.len(),
1389            1,
1390            "A's boot dir should be unreferenced (B evicted from BLS)"
1391        );
1392        assert_eq!(unreferenced[0].1, digest_a);
1393
1394        Ok(())
1395    }
1396}