1use 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#[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 collect_uki_binaries(&esp.fd, &mut boot_binaries)?;
65
66 collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?;
69
70 Ok(boot_binaries)
71}
72
73#[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#[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 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#[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 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 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 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
175fn 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#[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 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 let state_dirs = list_state_dirs(&sysroot)?;
307
308 let staged = &host.status.staged;
309
310 let orphaned_state_dirs: Vec<_> = state_dirs
312 .iter()
313 .filter(|s| !bootloader_entries.iter().any(|entry| &entry.fsverity == *s))
314 .collect();
315
316 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 let mut live_manifest_digests: Vec<composefs_oci::OciDigest> = Vec::new();
351 let mut additional_roots = Vec::new();
352 let mut live_container_images: std::collections::HashSet<String> = Default::default();
354
355 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 if all_orphans.contains(&verity) {
365 continue;
366 }
367
368 additional_roots.push(verity.clone());
373
374 if let Some(ini) = read_origin(sysroot, verity)? {
375 if let Some(container_ref) =
377 ini.get::<String>("origin", ostree_ext::container::deploy::ORIGIN_CONTAINER)
378 {
379 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 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 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 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 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 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 let gc_result = if gc_opts.dry_run {
492 booted_cfs.repo.gc_dry_run(&additional_roots)?
493 } else {
494 booted_cfs.repo.gc(&additional_roots)?
495 };
496
497 Ok(gc_result)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::bootc_composefs::status::list_type1_entries;
504 use crate::testutils::{ChangeType, TestRoot};
505
506 #[test]
524 fn test_gc_shared_boot_binaries_not_deleted() -> anyhow::Result<()> {
525 let mut root = TestRoot::new()?;
526 let digest_a = root.current().verity.clone();
527
528 root.upgrade(1, ChangeType::Userspace)?;
530
531 root.upgrade(2, ChangeType::Kernel)?;
533 let digest_c = root.current().verity.clone();
534
535 root.upgrade(3, ChangeType::Userspace)?;
537 let digest_d = root.current().verity.clone();
538
539 root.gc_deployment(&digest_a)?;
541
542 let boot_dir = root.boot_dir()?;
547
548 let mut on_disk = Vec::new();
550 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
551 assert_eq!(
552 on_disk.len(),
553 2,
554 "should have A's and C's boot dirs on disk"
555 );
556
557 let bls_entries = list_type1_entries(&boot_dir)?;
559 assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
560
561 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
567
568 assert_eq!(unreferenced.len(), 1);
570 assert_eq!(unreferenced[0].1, digest_a);
571
572 assert!(
574 !unreferenced.iter().any(|b| b.1 == digest_c),
575 "C's boot dir must not be unreferenced"
576 );
577
578 root.gc_deployment(&digest_c)?;
582
583 let mut on_disk_2 = Vec::new();
584 collect_type1_boot_binaries(&root.boot_dir()?, &mut on_disk_2)?;
585 assert_eq!(on_disk_2.len(), 2);
587
588 let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?;
589 assert_eq!(bls_entries_2.len(), 2);
591
592 let entry_d = bls_entries_2
593 .iter()
594 .find(|e| e.fsverity == digest_d)
595 .unwrap();
596 assert_eq!(
597 entry_d.boot_artifact_name, digest_c,
598 "D shares C's boot dir"
599 );
600
601 let unreferenced_2 = unreferenced_boot_binaries(&on_disk_2, &bls_entries_2);
602
603 assert!(
607 unreferenced_2.is_empty(),
608 "no boot dirs should be unreferenced when both are shared"
609 );
610
611 let buggy_unreferenced: Vec<_> = on_disk_2
617 .iter()
618 .filter(|bin| !bls_entries_2.iter().any(|e| e.fsverity == bin.1))
619 .collect();
620 assert_eq!(
621 buggy_unreferenced.len(),
622 2,
623 "old fsverity-based logic would incorrectly GC both boot dirs"
624 );
625
626 Ok(())
627 }
628
629 #[test]
633 fn test_list_type1_entries_handles_legacy_bls() -> anyhow::Result<()> {
634 let mut root = TestRoot::new_legacy()?;
635 let digest_a = root.current().verity.clone();
636
637 root.upgrade(1, ChangeType::Userspace)?;
638 let digest_b = root.current().verity.clone();
639
640 let boot_dir = root.boot_dir()?;
641 let bls_entries = list_type1_entries(&boot_dir)?;
642
643 assert_eq!(bls_entries.len(), 2, "Should find both BLS entries");
644
645 for entry in &bls_entries {
648 assert_eq!(
649 entry.boot_artifact_name, digest_a,
650 "Both entries should reference A's boot dir (shared kernel)"
651 );
652 }
653
654 let verity_set: std::collections::HashSet<&str> =
656 bls_entries.iter().map(|e| e.fsverity.as_str()).collect();
657 assert!(verity_set.contains(digest_a.as_str()));
658 assert!(verity_set.contains(digest_b.as_str()));
659
660 Ok(())
661 }
662
663 #[test]
670 fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> {
671 let root = TestRoot::new_legacy()?;
672
673 let boot_dir = root.boot_dir()?;
675 let mut on_disk = Vec::new();
676 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
677
678 assert!(
681 on_disk.is_empty(),
682 "Legacy (unprefixed) boot dirs should not be found by collect_type1_boot_binaries"
683 );
684
685 Ok(())
686 }
687
688 #[test]
692 fn test_gc_works_after_legacy_migration() -> anyhow::Result<()> {
693 let mut root = TestRoot::new_legacy()?;
694 let digest_a = root.current().verity.clone();
695
696 root.upgrade(1, ChangeType::Userspace)?;
698
699 root.upgrade(2, ChangeType::Kernel)?;
701
702 root.migrate_to_prefixed()?;
704
705 let boot_dir = root.boot_dir()?;
707 let mut on_disk = Vec::new();
708 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
709 assert_eq!(on_disk.len(), 2, "Should see A's and C's boot dirs");
710
711 let bls_entries = list_type1_entries(&boot_dir)?;
713 assert_eq!(bls_entries.len(), 2);
714
715 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
717 assert!(
718 unreferenced.is_empty(),
719 "All boot dirs should be referenced after migration"
720 );
721
722 root.gc_deployment(&digest_a)?;
724
725 let boot_dir = root.boot_dir()?;
726 let bls_entries = list_type1_entries(&boot_dir)?;
727 assert_eq!(bls_entries.len(), 2, "B (secondary) + C (primary)");
728
729 let mut on_disk = Vec::new();
730 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
731 assert_eq!(on_disk.len(), 2, "Both boot dirs still on disk");
732
733 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
734 assert!(
736 unreferenced.is_empty(),
737 "A's boot dir should still be referenced by B after migration"
738 );
739
740 Ok(())
741 }
742
743 #[test]
749 fn test_gc_post_migration_upgrade_cycle() -> anyhow::Result<()> {
750 let mut root = TestRoot::new_legacy()?;
751 let digest_a = root.current().verity.clone();
752
753 root.upgrade(1, ChangeType::Userspace)?;
755
756 root.migrate_to_prefixed()?;
758
759 root.upgrade(2, ChangeType::Kernel)?;
761 let digest_c = root.current().verity.clone();
762
763 root.upgrade(3, ChangeType::Userspace)?;
765 let digest_d = root.current().verity.clone();
766
767 root.gc_deployment(&digest_a)?;
769
770 let boot_dir = root.boot_dir()?;
771 let mut on_disk = Vec::new();
772 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
773
774 let bls_entries = list_type1_entries(&boot_dir)?;
775 assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
776
777 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
778 assert_eq!(
780 unreferenced.len(),
781 1,
782 "A's boot dir should be unreferenced after GC of A and B is evicted"
783 );
784 assert_eq!(unreferenced[0].1, digest_a);
785
786 assert!(
788 !unreferenced.iter().any(|b| b.1 == digest_c),
789 "C's boot dir must still be referenced by D"
790 );
791
792 let entry_d = bls_entries
794 .iter()
795 .find(|e| e.fsverity == digest_d)
796 .expect("D should have a BLS entry");
797 assert_eq!(
798 entry_d.boot_artifact_name, digest_c,
799 "D should share C's boot dir"
800 );
801
802 Ok(())
803 }
804
805 #[test]
815 fn test_gc_deep_transitive_sharing_chain() -> anyhow::Result<()> {
816 let mut root = TestRoot::new()?;
817 let digest_a = root.current().verity.clone();
818
819 root.upgrade(1, ChangeType::Userspace)?;
821 root.upgrade(2, ChangeType::Userspace)?;
822 root.upgrade(3, ChangeType::Userspace)?;
823 let digest_d = root.current().verity.clone();
824
825 let boot_dir = root.boot_dir()?;
827 let mut on_disk = Vec::new();
828 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
829 assert_eq!(on_disk.len(), 1, "All deployments share one boot dir");
830 assert_eq!(on_disk[0].1, digest_a, "The boot dir belongs to A");
831
832 let bls_entries = list_type1_entries(&boot_dir)?;
834 assert_eq!(bls_entries.len(), 2);
835 for entry in &bls_entries {
836 assert_eq!(
837 entry.boot_artifact_name, digest_a,
838 "All entries reference A's boot dir"
839 );
840 }
841
842 root.gc_deployment(&digest_a)?;
844
845 let boot_dir = root.boot_dir()?;
846 let bls_entries = list_type1_entries(&boot_dir)?;
847 assert_eq!(bls_entries.len(), 2);
849
850 let mut on_disk = Vec::new();
851 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
852
853 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
854 assert!(
855 unreferenced.is_empty(),
856 "A's boot dir must stay — C and D still reference it"
857 );
858
859 let digest_b = crate::testutils::fake_digest_version(1);
861 let digest_c = crate::testutils::fake_digest_version(2);
862 root.gc_deployment(&digest_b)?;
863 root.gc_deployment(&digest_c)?;
864
865 let boot_dir = root.boot_dir()?;
867 let bls_entries = list_type1_entries(&boot_dir)?;
868 assert_eq!(bls_entries.len(), 1, "Only D remains");
869 assert_eq!(bls_entries[0].fsverity, digest_d);
870 assert_eq!(
871 bls_entries[0].boot_artifact_name, digest_a,
872 "D still references A's boot dir"
873 );
874
875 let mut on_disk = Vec::new();
876 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
877 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
878 assert!(
879 unreferenced.is_empty(),
880 "A's boot dir must survive — D is the last deployment and still uses it"
881 );
882
883 Ok(())
884 }
885
886 #[test]
893 fn test_boot_artifact_info_drives_migration_decisions() -> anyhow::Result<()> {
894 use crate::bootc_composefs::status::get_sorted_type1_boot_entries;
895
896 let mut root = TestRoot::new_legacy()?;
897 let digest_a = root.current().verity.clone();
898
899 root.upgrade(1, ChangeType::Userspace)?;
900 root.upgrade(2, ChangeType::Kernel)?;
901
902 let boot_dir = root.boot_dir()?;
904 let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
905 assert_eq!(raw_entries.len(), 2);
906
907 let needs_migration: Vec<_> = raw_entries
908 .iter()
909 .filter(|e| !e.boot_artifact_info().unwrap().1)
910 .collect();
911 assert_eq!(
912 needs_migration.len(),
913 2,
914 "All legacy entries should need migration (has_prefix=false)"
915 );
916
917 let mut on_disk = Vec::new();
919 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
920 assert!(on_disk.is_empty(), "Legacy dirs invisible before migration");
921
922 root.migrate_to_prefixed()?;
924
925 let boot_dir = root.boot_dir()?;
927 let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
928 assert_eq!(raw_entries.len(), 2);
929
930 let needs_migration: Vec<_> = raw_entries
931 .iter()
932 .filter(|e| !e.boot_artifact_info().unwrap().1)
933 .collect();
934 assert!(
935 needs_migration.is_empty(),
936 "No entries should need migration after migrate_to_prefixed()"
937 );
938
939 let mut on_disk = Vec::new();
941 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
942 assert_eq!(on_disk.len(), 2, "Both dirs visible after migration");
943
944 let bls_entries = list_type1_entries(&boot_dir)?;
946 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
947 assert!(
948 unreferenced.is_empty(),
949 "All dirs referenced after migration"
950 );
951
952 root.upgrade(3, ChangeType::Kernel)?;
954
955 let boot_dir = root.boot_dir()?;
956 let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?;
957 for entry in &raw_entries {
959 let (_, has_prefix) = entry.boot_artifact_info()?;
960 assert!(
961 has_prefix,
962 "All entries should have prefix after migration + upgrade"
963 );
964 }
965
966 let mut on_disk = Vec::new();
969 collect_type1_boot_binaries(&boot_dir, &mut on_disk)?;
970 assert_eq!(on_disk.len(), 3, "Three boot dirs on disk");
971
972 let bls_entries = list_type1_entries(&boot_dir)?;
974 assert_eq!(bls_entries.len(), 2);
975 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
976 assert_eq!(
977 unreferenced.len(),
978 1,
979 "A's boot dir should be unreferenced (B evicted from BLS)"
980 );
981 assert_eq!(unreferenced[0].1, digest_a);
982
983 Ok(())
984 }
985}