1use 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
52pub(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#[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 collect_uki_binaries(&boot_mount.fd, &mut boot_binaries)?;
93
94 collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?;
97
98 Ok(boot_binaries)
99}
100
101#[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#[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 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#[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 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 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
201fn 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 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 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 ¤tly_installed_addons {
254 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 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 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 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 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#[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 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 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 let orphaned_state_dirs = orphaned_state_dirs(
615 &state_dirs,
616 &live_deployments,
617 &bootloader_entries,
618 is_aboot,
619 );
620
621 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 let mut live_manifest_digests: Vec<composefs_oci::OciDigest> = Vec::new();
660 let mut additional_roots = Vec::new();
661 let mut live_container_images: std::collections::HashSet<String> = Default::default();
663
664 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 if all_orphans.contains(&verity) {
674 continue;
675 }
676
677 additional_roots.push(verity.clone());
682
683 if let Some(ini) = read_origin(sysroot, verity)? {
684 if let Some(container_ref) =
686 ini.get::<String>("origin", ostree_ext::container::deploy::ORIGIN_CONTAINER)
687 {
688 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 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 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 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 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 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 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 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 #[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 root.upgrade(1, ChangeType::Userspace)?;
941
942 root.upgrade(2, ChangeType::Kernel)?;
944 let digest_c = root.current().verity.clone();
945
946 root.upgrade(3, ChangeType::Userspace)?;
948 let digest_d = root.current().verity.clone();
949
950 root.gc_deployment(&digest_a)?;
952
953 let boot_dir = root.boot_dir()?;
958
959 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 let bls_entries = list_type1_entries(&boot_dir)?;
970 assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)");
971
972 let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries);
978
979 assert_eq!(unreferenced.len(), 1);
981 assert_eq!(unreferenced[0].1, digest_a);
982
983 assert!(
985 !unreferenced.iter().any(|b| b.1 == digest_c),
986 "C's boot dir must not be unreferenced"
987 );
988
989 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 assert_eq!(on_disk_2.len(), 2);
998
999 let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?;
1000 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 assert!(
1018 unreferenced_2.is_empty(),
1019 "no boot dirs should be unreferenced when both are shared"
1020 );
1021
1022 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 #[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 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 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 #[test]
1081 fn test_legacy_boot_dirs_invisible_to_gc_scanner() -> anyhow::Result<()> {
1082 let root = TestRoot::new_legacy()?;
1083
1084 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 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 #[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 root.upgrade(1, ChangeType::Userspace)?;
1109
1110 root.upgrade(2, ChangeType::Kernel)?;
1112
1113 root.migrate_to_prefixed()?;
1115
1116 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 let bls_entries = list_type1_entries(&boot_dir)?;
1124 assert_eq!(bls_entries.len(), 2);
1125
1126 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 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 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]
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 root.upgrade(1, ChangeType::Userspace)?;
1166
1167 root.migrate_to_prefixed()?;
1169
1170 root.upgrade(2, ChangeType::Kernel)?;
1172 let digest_c = root.current().verity.clone();
1173
1174 root.upgrade(3, ChangeType::Userspace)?;
1176 let digest_d = root.current().verity.clone();
1177
1178 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 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 assert!(
1199 !unreferenced.iter().any(|b| b.1 == digest_c),
1200 "C's boot dir must still be referenced by D"
1201 );
1202
1203 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]
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 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 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 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 root.gc_deployment(&digest_a)?;
1255
1256 let boot_dir = root.boot_dir()?;
1257 let bls_entries = list_type1_entries(&boot_dir)?;
1258 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 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 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 #[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 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 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 root.migrate_to_prefixed()?;
1335
1336 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 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 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 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 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 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 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}