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 {
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 #[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 root.upgrade(1, ChangeType::Userspace)?;
534
535 root.upgrade(2, ChangeType::Kernel)?;
537 let digest_c = root.current().verity.clone();
538
539 root.upgrade(3, ChangeType::Userspace)?;
541 let digest_d = root.current().verity.clone();
542
543 root.gc_deployment(&digest_a)?;
545
546 let boot_dir = root.boot_dir()?;
551
552 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 let bls_entries = list_type1_entries(&boot_dir)?;
563 assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
564
565 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
571
572 assert_eq!(unreferenced.len(), 1);
574 assert_eq!(unreferenced[0].1, digest_a);
575
576 assert!(
578 !unreferenced.iter().any(|b| b.1 == digest_c),
579 "C's boot dir must not be unreferenced"
580 );
581
582 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 assert_eq!(on_disk_2.len(), 2);
591
592 let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?;
593 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 assert!(
611 unreferenced_2.is_empty(),
612 "no boot dirs should be unreferenced when both are shared"
613 );
614
615 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 #[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 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 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 #[test]
674 fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> {
675 let root = TestRoot::new_legacy()?;
676
677 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 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 #[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 root.upgrade(1, ChangeType::Userspace)?;
702
703 root.upgrade(2, ChangeType::Kernel)?;
705
706 root.migrate_to_prefixed()?;
708
709 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 let bls_entries = list_type1_entries(&boot_dir)?;
717 assert_eq!(bls_entries.len(), 2);
718
719 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 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 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]
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 root.upgrade(1, ChangeType::Userspace)?;
759
760 root.migrate_to_prefixed()?;
762
763 root.upgrade(2, ChangeType::Kernel)?;
765 let digest_c = root.current().verity.clone();
766
767 root.upgrade(3, ChangeType::Userspace)?;
769 let digest_d = root.current().verity.clone();
770
771 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 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 assert!(
792 !unreferenced.iter().any(|b| b.1 == digest_c),
793 "C's boot dir must still be referenced by D"
794 );
795
796 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]
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 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 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 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 root.gc_deployment(&digest_a)?;
848
849 let boot_dir = root.boot_dir()?;
850 let bls_entries = list_type1_entries(&boot_dir)?;
851 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 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 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 #[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 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 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 root.migrate_to_prefixed()?;
928
929 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 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 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 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 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 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 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}