1use std::path::{Path, PathBuf};
39
40use haz_domain::settings::cache_clean::max_age::MaxAge;
41use haz_domain::settings::cache_clean::max_size::MaxSize;
42use haz_vfs::{EntryKind, FsError, WritableFilesystem};
43use snafu::Snafu;
44
45use crate::layout;
46use crate::manifest::{HashFunctionLabel, Manifest};
47use crate::writer::CacheWriter;
48
49#[derive(Debug, Snafu)]
51pub enum CleanError {
52 #[snafu(display("filesystem error during cache invalidation: {source}"))]
55 Io {
56 source: FsError,
58 },
59}
60
61#[derive(Debug, Snafu)]
69pub enum CleanFailure {
70 #[snafu(display("failed to read cache shard at: {}: {source}", path.display()))]
73 Shard {
74 path: PathBuf,
76 source: FsError,
78 },
79 #[snafu(display("failed to read entry manifest at: {}: {source}", path.display()))]
82 Manifest {
83 path: PathBuf,
85 source: FsError,
87 },
88 #[snafu(display("failed to remove cache entry at: {}: {source}", path.display()))]
90 Entry {
91 path: PathBuf,
93 source: FsError,
95 },
96 #[snafu(display("failed to remove orphan tmp directory at: {}: {source}", path.display()))]
98 Tmp {
99 path: PathBuf,
101 source: FsError,
103 },
104 #[snafu(display(
106 "failed to remove orphan restore directory at: {}: {source}",
107 path.display()
108 ))]
109 Restore {
110 path: PathBuf,
112 source: FsError,
114 },
115}
116
117#[derive(Debug, Default)]
126pub struct CleanOutcome {
127 pub report: CleanReport,
129 pub failures: Vec<CleanFailure>,
131}
132
133#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
141pub struct CleanOptions {
142 pub soft: bool,
146 pub max_age: Option<MaxAge>,
153 pub max_size: Option<MaxSize>,
158 pub dry_run: bool,
163 pub now_unix: u64,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum EvictionMode {
177 Soft,
180 MaxAge,
182 MaxSize,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct EvictedEntry {
197 pub key_hex_prefix: String,
202 pub created_at_unix: u64,
205 pub footprint: u64,
209 pub matched_mode: EvictionMode,
211}
212
213#[derive(Debug, Default, Clone, PartialEq, Eq)]
220pub struct CleanReport {
221 pub inspected: u64,
225 pub evicted_by_soft: u64,
227 pub evicted_by_max_age: u64,
230 pub evicted_by_max_size: u64,
233 pub removed_tmp_dirs: u64,
236 pub removed_restore_dirs: u64,
239 pub bytes_reclaimed: u64,
245 pub evicted_entries: Vec<EvictedEntry>,
250}
251
252impl<Fs: WritableFilesystem> CacheWriter<Fs> {
253 pub fn clear(&self) -> Result<(), CleanError> {
267 match self.fs().remove_dir_all(self.cache_root()) {
268 Ok(()) | Err(FsError::NotFound { .. }) => Ok(()),
269 Err(e) => Err(CleanError::Io { source: e }),
270 }
271 }
272
273 pub fn clean(&self, opts: &CleanOptions) -> Result<CleanOutcome, CleanError> {
297 let Some(enumerated) = self.enumerate_for_clean()? else {
298 return Ok(CleanOutcome::default());
299 };
300 let CleanEnumeration {
301 well_formed,
302 corrupt,
303 tmp_paths,
304 restore_paths,
305 mut failures,
306 } = enumerated;
307
308 let mut report = CleanReport {
309 inspected: (well_formed.len() + corrupt.len()) as u64,
310 ..CleanReport::default()
311 };
312
313 let mut plan: Vec<PlannedEviction> = Vec::new();
314 apply_soft_pass(opts, corrupt, &mut plan);
315 let survivors = apply_max_age_pass(opts, well_formed, &mut plan);
316 apply_max_size_pass(opts, survivors, &mut plan);
317
318 let mut removed: Vec<EvictedEntry> = Vec::new();
324 for planned in plan {
325 match self.evict_path(opts.dry_run, &planned.path) {
326 Ok(()) => {
327 bump_mode_count(&mut report, planned.detail.matched_mode);
328 report.bytes_reclaimed = report
329 .bytes_reclaimed
330 .saturating_add(planned.detail.footprint);
331 removed.push(planned.detail);
332 }
333 Err(source) => failures.push(CleanFailure::Entry {
334 path: planned.path,
335 source,
336 }),
337 }
338 }
339
340 if opts.soft {
341 for path in tmp_paths {
342 match self.evict_path(opts.dry_run, &path) {
343 Ok(()) => {
344 report.removed_tmp_dirs = report.removed_tmp_dirs.saturating_add(1);
345 }
346 Err(source) => failures.push(CleanFailure::Tmp { path, source }),
347 }
348 }
349 for path in restore_paths {
350 match self.evict_path(opts.dry_run, &path) {
351 Ok(()) => {
352 report.removed_restore_dirs = report.removed_restore_dirs.saturating_add(1);
353 }
354 Err(source) => failures.push(CleanFailure::Restore { path, source }),
355 }
356 }
357 }
358
359 report.evicted_entries = finalize_evicted_entries(removed);
360 Ok(CleanOutcome { report, failures })
361 }
362
363 fn evict_path(&self, dry_run: bool, path: &Path) -> Result<(), FsError> {
368 if dry_run {
369 Ok(())
370 } else {
371 self.fs().remove_dir_all(path)
372 }
373 }
374
375 fn enumerate_for_clean(&self) -> Result<Option<CleanEnumeration>, CleanError> {
376 let cache_entries = match self.fs().read_dir(self.cache_root()) {
377 Ok(es) => es,
378 Err(FsError::NotFound { .. }) => return Ok(None),
379 Err(e) => return Err(CleanError::Io { source: e }),
380 };
381 let mut e = CleanEnumeration::default();
382 for cache_entry in cache_entries {
383 let name = cache_entry
384 .path
385 .file_name()
386 .map(|n| n.to_string_lossy().into_owned())
387 .unwrap_or_default();
388
389 if name.starts_with(".restore-") {
390 e.restore_paths.push(cache_entry.path);
391 continue;
392 }
393 if cache_entry.metadata.kind != EntryKind::Dir {
394 continue;
395 }
396 self.clean_classify_shard(
397 &cache_entry.path,
398 &mut e.well_formed,
399 &mut e.corrupt,
400 &mut e.tmp_paths,
401 &mut e.failures,
402 );
403 }
404 Ok(Some(e))
405 }
406
407 fn clean_classify_shard(
408 &self,
409 shard_dir: &Path,
410 well_formed: &mut Vec<EntryRecord>,
411 corrupt: &mut Vec<EntryRecord>,
412 tmp_paths: &mut Vec<PathBuf>,
413 failures: &mut Vec<CleanFailure>,
414 ) {
415 let shard_entries = match self.fs().read_dir(shard_dir) {
418 Ok(entries) => entries,
419 Err(source) => {
420 failures.push(CleanFailure::Shard {
421 path: shard_dir.to_path_buf(),
422 source,
423 });
424 return;
425 }
426 };
427 for shard_entry in shard_entries {
428 let sname = shard_entry
429 .path
430 .file_name()
431 .map(|n| n.to_string_lossy().into_owned())
432 .unwrap_or_default();
433
434 if sname.starts_with(".tmp-") {
435 tmp_paths.push(shard_entry.path);
436 continue;
437 }
438
439 if shard_entry.metadata.kind != EntryKind::Dir {
440 continue;
441 }
442
443 self.clean_classify_entry(&shard_entry.path, &sname, well_formed, corrupt, failures);
444 }
445 }
446
447 fn clean_classify_entry(
448 &self,
449 entry_dir: &Path,
450 basename: &str,
451 well_formed: &mut Vec<EntryRecord>,
452 corrupt: &mut Vec<EntryRecord>,
453 failures: &mut Vec<CleanFailure>,
454 ) {
455 let key_hex_prefix: String = basename.chars().take(8).collect();
456 let manifest_path = entry_dir.join(layout::MANIFEST_FILE_NAME);
457 let bytes = match self.fs().read(&manifest_path) {
458 Ok(b) => b,
459 Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => {
460 corrupt.push(EntryRecord {
461 path: entry_dir.to_path_buf(),
462 key_hex_prefix,
463 created_at_unix: 0,
464 footprint: 0,
465 });
466 return;
467 }
468 Err(source) => {
472 failures.push(CleanFailure::Manifest {
473 path: manifest_path,
474 source,
475 });
476 return;
477 }
478 };
479 let Ok(manifest) = Manifest::from_json(&bytes) else {
480 corrupt.push(EntryRecord {
481 path: entry_dir.to_path_buf(),
482 key_hex_prefix,
483 created_at_unix: 0,
484 footprint: 0,
485 });
486 return;
487 };
488 let chapter_ok = manifest.current_chapter_revision_matches();
489 let hash_ok = HashFunctionLabel::from(self.hash_algo()) == manifest.hash_function;
490 let footprint = manifest_footprint(&manifest);
491 let record = EntryRecord {
492 path: entry_dir.to_path_buf(),
493 key_hex_prefix,
494 created_at_unix: manifest.created_at_unix,
495 footprint,
496 };
497 if chapter_ok && hash_ok {
498 well_formed.push(record);
499 } else {
500 corrupt.push(record);
501 }
502 }
503}
504
505struct EntryRecord {
506 path: PathBuf,
507 key_hex_prefix: String,
508 created_at_unix: u64,
509 footprint: u64,
510}
511
512struct PlannedEviction {
513 path: PathBuf,
514 detail: EvictedEntry,
515}
516
517#[derive(Default)]
518struct CleanEnumeration {
519 well_formed: Vec<EntryRecord>,
520 corrupt: Vec<EntryRecord>,
521 tmp_paths: Vec<PathBuf>,
522 restore_paths: Vec<PathBuf>,
523 failures: Vec<CleanFailure>,
524}
525
526fn apply_soft_pass(
527 opts: &CleanOptions,
528 corrupt: Vec<EntryRecord>,
529 plan: &mut Vec<PlannedEviction>,
530) {
531 if !opts.soft {
532 return;
533 }
534 for c in corrupt {
535 plan.push(PlannedEviction {
536 path: c.path,
537 detail: EvictedEntry {
538 key_hex_prefix: c.key_hex_prefix,
539 created_at_unix: c.created_at_unix,
540 footprint: c.footprint,
541 matched_mode: EvictionMode::Soft,
542 },
543 });
544 }
545}
546
547fn apply_max_age_pass(
548 opts: &CleanOptions,
549 well_formed: Vec<EntryRecord>,
550 plan: &mut Vec<PlannedEviction>,
551) -> Vec<EntryRecord> {
552 let Some(max_age) = opts.max_age else {
553 return well_formed;
554 };
555 let cutoff = opts
556 .now_unix
557 .saturating_sub(max_age.as_duration().as_secs());
558 let mut survivors: Vec<EntryRecord> = Vec::with_capacity(well_formed.len());
559 for wf in well_formed {
560 if wf.created_at_unix < cutoff {
561 plan.push(PlannedEviction {
562 path: wf.path.clone(),
563 detail: EvictedEntry {
564 key_hex_prefix: wf.key_hex_prefix.clone(),
565 created_at_unix: wf.created_at_unix,
566 footprint: wf.footprint,
567 matched_mode: EvictionMode::MaxAge,
568 },
569 });
570 } else {
571 survivors.push(wf);
572 }
573 }
574 survivors
575}
576
577fn apply_max_size_pass(
578 opts: &CleanOptions,
579 mut survivors: Vec<EntryRecord>,
580 plan: &mut Vec<PlannedEviction>,
581) {
582 let Some(max_size) = opts.max_size else {
583 return;
584 };
585 let limit = max_size.as_bytes();
586 let total: u64 = survivors.iter().map(|e| e.footprint).sum();
587 if total <= limit {
588 return;
589 }
590 survivors.sort_by(|a, b| {
591 a.created_at_unix
592 .cmp(&b.created_at_unix)
593 .then_with(|| a.key_hex_prefix.cmp(&b.key_hex_prefix))
594 });
595 let mut remaining = total;
596 for wf in &survivors {
597 if remaining <= limit {
598 break;
599 }
600 plan.push(PlannedEviction {
601 path: wf.path.clone(),
602 detail: EvictedEntry {
603 key_hex_prefix: wf.key_hex_prefix.clone(),
604 created_at_unix: wf.created_at_unix,
605 footprint: wf.footprint,
606 matched_mode: EvictionMode::MaxSize,
607 },
608 });
609 remaining = remaining.saturating_sub(wf.footprint);
610 }
611}
612
613fn bump_mode_count(report: &mut CleanReport, mode: EvictionMode) {
618 match mode {
619 EvictionMode::Soft => {
620 report.evicted_by_soft = report.evicted_by_soft.saturating_add(1);
621 }
622 EvictionMode::MaxAge => {
623 report.evicted_by_max_age = report.evicted_by_max_age.saturating_add(1);
624 }
625 EvictionMode::MaxSize => {
626 report.evicted_by_max_size = report.evicted_by_max_size.saturating_add(1);
627 }
628 }
629}
630
631fn finalize_evicted_entries(mut details: Vec<EvictedEntry>) -> Vec<EvictedEntry> {
632 details.sort_by(|a, b| {
633 mode_rank(a.matched_mode)
634 .cmp(&mode_rank(b.matched_mode))
635 .then(a.created_at_unix.cmp(&b.created_at_unix))
636 .then_with(|| a.key_hex_prefix.cmp(&b.key_hex_prefix))
637 });
638 details
639}
640
641fn manifest_footprint(m: &Manifest) -> u64 {
642 let mut total = m.stdout_len.saturating_add(m.stderr_len);
643 for o in &m.outputs {
644 total = total.saturating_add(o.size);
645 }
646 total
647}
648
649const fn mode_rank(m: EvictionMode) -> u8 {
650 match m {
651 EvictionMode::Soft => 0,
652 EvictionMode::MaxAge => 1,
653 EvictionMode::MaxSize => 2,
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use std::io::ErrorKind;
660 use std::path::Path;
661
662 use haz_domain::path::CanonicalPath;
663 use haz_domain::settings::cache::HashAlgo;
664 use haz_domain::settings::cache_clean::max_age::MaxAge;
665 use haz_domain::settings::cache_clean::max_size::MaxSize;
666 use haz_vfs::{Filesystem, WritableFilesystem};
667 use haz_vfs_testing::{MemFaultOp, MemFilesystem};
668
669 use crate::clean::{CleanError, CleanFailure, CleanOptions, CleanReport, EvictionMode};
670 use crate::key::CacheKey;
671 use crate::key::prefix::CHAPTER_REVISION;
672 use crate::layout;
673 use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
674 use crate::store::{StoreInputs, StoredOutput};
675 use crate::writer::CacheWriter;
676
677 fn cp(s: &str) -> CanonicalPath {
678 CanonicalPath::parse_workspace_absolute(s)
679 .expect("test helper expects a valid workspace-absolute path")
680 }
681
682 const WORKSPACE_ROOT: &str = "/ws";
683
684 fn make_cache(fs: MemFilesystem, algo: HashAlgo) -> CacheWriter<MemFilesystem> {
685 CacheWriter::new(fs, Path::new(WORKSPACE_ROOT), algo)
686 }
687
688 fn key_with_first_byte(first: u8) -> CacheKey {
689 let mut bytes = [0u8; 32];
690 bytes[0] = first;
691 CacheKey::from_bytes(bytes)
692 }
693
694 fn store_entry_at(
695 cache: &CacheWriter<MemFilesystem>,
696 key: &CacheKey,
697 rel: &str,
698 bytes: &[u8],
699 created_at_unix: u64,
700 ) {
701 let target = Path::new(WORKSPACE_ROOT).join(rel);
702 let anchored = format!("/{rel}");
703 cache.fs().create_dir_all(target.parent().unwrap()).unwrap();
704 cache.fs().write_file(&target, bytes).unwrap();
705 let outs = [StoredOutput {
706 workspace_absolute_path: &anchored,
707 on_disk_path: &target,
708 mode: 0o644,
709 }];
710 cache
711 .store(
712 key,
713 &StoreInputs {
714 outputs: &outs,
715 stdout: b"",
716 stderr: b"",
717 created_at_unix,
718 },
719 )
720 .unwrap();
721 }
722
723 fn store_a_valid_entry(
724 cache: &CacheWriter<MemFilesystem>,
725 key: &CacheKey,
726 rel: &str,
727 bytes: &[u8],
728 ) {
729 store_entry_at(cache, key, rel, bytes, 0);
730 }
731
732 fn write_manifest_to_entry(
733 cache: &CacheWriter<MemFilesystem>,
734 key: &CacheKey,
735 manifest: &Manifest,
736 ) {
737 cache
738 .fs()
739 .create_dir_all(&layout::entry_dir(cache.cache_root(), key))
740 .unwrap();
741 cache
742 .fs()
743 .write_file(
744 &layout::manifest_path(cache.cache_root(), key),
745 &manifest.to_json_bytes(),
746 )
747 .unwrap();
748 }
749
750 fn soft_only() -> CleanOptions {
751 CleanOptions {
752 soft: true,
753 ..Default::default()
754 }
755 }
756
757 #[test]
760 fn cache_021_clear_empties_a_populated_cache() {
761 let mut fs = MemFilesystem::new();
762 fs.add_dir("/ws").unwrap();
763 let cache = make_cache(fs, HashAlgo::Blake3);
764 let key = key_with_first_byte(0xAB);
765 store_a_valid_entry(&cache, &key, "proj/out", b"x");
766
767 assert!(
768 cache.reader().lookup(&key).is_some(),
769 "precondition: entry present"
770 );
771 cache.clear().unwrap();
772 assert!(
773 cache.reader().lookup(&key).is_none(),
774 "lookup must be a miss after clear"
775 );
776 }
777
778 #[test]
779 fn cache_021_clear_on_fresh_cache_is_a_noop_not_an_error() {
780 let mut fs = MemFilesystem::new();
781 fs.add_dir("/ws").unwrap();
782 let cache = make_cache(fs, HashAlgo::Blake3);
783 cache.clear().unwrap();
784 }
785
786 #[test]
787 fn cache_021_clear_does_not_touch_files_outside_cache_root() {
788 let mut fs = MemFilesystem::new();
789 fs.add_dir("/ws").unwrap();
790 fs.add_file("/ws/unrelated.txt", b"keep me".to_vec())
791 .unwrap();
792 let cache = make_cache(fs, HashAlgo::Blake3);
793 let key = key_with_first_byte(0xAB);
794 store_a_valid_entry(&cache, &key, "proj/out", b"x");
795
796 cache.clear().unwrap();
797 assert_eq!(
798 cache.fs().read(Path::new("/ws/unrelated.txt")).unwrap(),
799 b"keep me"
800 );
801 }
802
803 #[test]
806 fn cache_022_clean_soft_on_fresh_cache_is_a_noop_with_zero_counts() {
807 let mut fs = MemFilesystem::new();
808 fs.add_dir("/ws").unwrap();
809 let cache = make_cache(fs, HashAlgo::Blake3);
810 let report = cache.clean(&soft_only()).unwrap().report;
811 assert_eq!(report, CleanReport::default());
812 }
813
814 #[test]
815 fn aux_022_clean_with_no_modes_is_a_noop_on_a_populated_cache() {
816 let mut fs = MemFilesystem::new();
817 fs.add_dir("/ws").unwrap();
818 let cache = make_cache(fs, HashAlgo::Blake3);
819 let key = key_with_first_byte(0xAB);
820 store_a_valid_entry(&cache, &key, "proj/out", b"x");
821
822 let report = cache.clean(&CleanOptions::default()).unwrap().report;
823 assert_eq!(report.evicted_by_soft, 0);
824 assert_eq!(report.evicted_by_max_age, 0);
825 assert_eq!(report.evicted_by_max_size, 0);
826 assert_eq!(report.removed_tmp_dirs, 0);
827 assert_eq!(report.removed_restore_dirs, 0);
828 assert_eq!(report.inspected, 1);
829 assert!(cache.reader().lookup(&key).is_some());
830 }
831
832 #[test]
833 fn cache_022_clean_soft_keeps_a_valid_entry_intact() {
834 let mut fs = MemFilesystem::new();
835 fs.add_dir("/ws").unwrap();
836 let cache = make_cache(fs, HashAlgo::Blake3);
837 let key = key_with_first_byte(0xAB);
838 store_a_valid_entry(&cache, &key, "proj/out", b"x");
839
840 let report = cache.clean(&soft_only()).unwrap().report;
841 assert_eq!(report.evicted_by_soft, 0);
842 assert!(cache.reader().lookup(&key).is_some());
843 }
844
845 #[test]
848 fn cache_022_clean_soft_removes_entry_with_chapter_revision_mismatch() {
849 let mut fs = MemFilesystem::new();
850 fs.add_dir("/ws").unwrap();
851 let cache = make_cache(fs, HashAlgo::Blake3);
852 let key = key_with_first_byte(0xAB);
853 let manifest = Manifest {
854 chapter_revision: CHAPTER_REVISION.saturating_add(1),
855 hash_function: HashFunctionLabel::Blake3,
856 key,
857 outputs: vec![],
858 stdout_len: 0,
859 stderr_len: 0,
860 stdout_hash: [0u8; 32],
861 stderr_hash: [0u8; 32],
862 exit_status: 0,
863 created_at_unix: 0,
864 };
865 write_manifest_to_entry(&cache, &key, &manifest);
866 assert!(
867 cache
868 .fs()
869 .metadata(&layout::entry_dir(cache.cache_root(), &key))
870 .is_ok()
871 );
872
873 let report = cache.clean(&soft_only()).unwrap().report;
874 assert_eq!(report.evicted_by_soft, 1);
875 assert!(
876 cache
877 .fs()
878 .metadata(&layout::entry_dir(cache.cache_root(), &key))
879 .is_err()
880 );
881 }
882
883 #[test]
884 fn cache_022_clean_soft_removes_entry_with_hash_function_mismatch() {
885 let mut fs = MemFilesystem::new();
886 fs.add_dir("/ws").unwrap();
887 let cache = make_cache(fs, HashAlgo::Blake3);
888 let key = key_with_first_byte(0xAB);
889 let manifest = Manifest {
890 chapter_revision: CHAPTER_REVISION,
891 hash_function: HashFunctionLabel::Sha256,
892 key,
893 outputs: vec![],
894 stdout_len: 0,
895 stderr_len: 0,
896 stdout_hash: [0u8; 32],
897 stderr_hash: [0u8; 32],
898 exit_status: 0,
899 created_at_unix: 0,
900 };
901 write_manifest_to_entry(&cache, &key, &manifest);
902
903 let report = cache.clean(&soft_only()).unwrap().report;
904 assert_eq!(report.evicted_by_soft, 1);
905 }
906
907 #[test]
910 fn cache_022_clean_soft_removes_entry_without_a_manifest() {
911 let mut fs = MemFilesystem::new();
912 fs.add_dir("/ws").unwrap();
913 let cache = make_cache(fs, HashAlgo::Blake3);
914 let key = key_with_first_byte(0xAB);
915 cache
916 .fs()
917 .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
918 .unwrap();
919
920 let report = cache.clean(&soft_only()).unwrap().report;
921 assert_eq!(report.evicted_by_soft, 1);
922 }
923
924 #[test]
925 fn cache_022_clean_soft_removes_entry_with_unparseable_manifest() {
926 let mut fs = MemFilesystem::new();
927 fs.add_dir("/ws").unwrap();
928 let cache = make_cache(fs, HashAlgo::Blake3);
929 let key = key_with_first_byte(0xAB);
930 cache
931 .fs()
932 .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
933 .unwrap();
934 cache
935 .fs()
936 .write_file(
937 &layout::manifest_path(cache.cache_root(), &key),
938 b"this is not json",
939 )
940 .unwrap();
941
942 let report = cache.clean(&soft_only()).unwrap().report;
943 assert_eq!(report.evicted_by_soft, 1);
944 }
945
946 #[test]
949 fn cache_022_clean_soft_removes_store_tmp_directory() {
950 let mut fs = MemFilesystem::new();
951 fs.add_dir("/ws").unwrap();
952 let cache = make_cache(fs, HashAlgo::Blake3);
953 let key = key_with_first_byte(0xAB);
954 let tmp = layout::tmp_entry_dir(cache.cache_root(), &key, "abcdef");
955 cache.fs().create_dir_all(&tmp).unwrap();
956 cache
957 .fs()
958 .write_file(&tmp.join("manifest.json"), b"partial")
959 .unwrap();
960
961 let report = cache.clean(&soft_only()).unwrap().report;
962 assert_eq!(report.removed_tmp_dirs, 1);
963 assert!(cache.fs().metadata(&tmp).is_err());
964 }
965
966 #[test]
967 fn cache_022_clean_soft_removes_restore_staging_directory() {
968 let mut fs = MemFilesystem::new();
969 fs.add_dir("/ws").unwrap();
970 let cache = make_cache(fs, HashAlgo::Blake3);
971 let key = key_with_first_byte(0xAB);
972 let staging = layout::restore_staging_dir(cache.cache_root(), &key, "feedface");
973 cache.fs().create_dir_all(&staging).unwrap();
974 cache
975 .fs()
976 .write_file(&staging.join("00000000"), b"leftover")
977 .unwrap();
978
979 let report = cache.clean(&soft_only()).unwrap().report;
980 assert_eq!(report.removed_restore_dirs, 1);
981 assert!(cache.fs().metadata(&staging).is_err());
982 }
983
984 #[test]
987 fn cache_022_clean_soft_is_selective_when_mixed_state_is_present() {
988 let mut fs = MemFilesystem::new();
989 fs.add_dir("/ws").unwrap();
990 let cache = make_cache(fs, HashAlgo::Blake3);
991
992 let key_good = key_with_first_byte(0xAB);
993 store_a_valid_entry(&cache, &key_good, "proj/out", b"x");
994
995 let key_stale = key_with_first_byte(0xCD);
996 let stale_manifest = Manifest {
997 chapter_revision: CHAPTER_REVISION,
998 hash_function: HashFunctionLabel::Sha256,
999 key: key_stale,
1000 outputs: vec![],
1001 stdout_len: 0,
1002 stderr_len: 0,
1003 stdout_hash: [0u8; 32],
1004 stderr_hash: [0u8; 32],
1005 exit_status: 0,
1006 created_at_unix: 0,
1007 };
1008 write_manifest_to_entry(&cache, &key_stale, &stale_manifest);
1009
1010 let key_tmp = key_with_first_byte(0xEF);
1011 let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_tmp, "rnd1");
1012 cache.fs().create_dir_all(&tmp).unwrap();
1013
1014 let key_restore = key_with_first_byte(0x12);
1015 let staging = layout::restore_staging_dir(cache.cache_root(), &key_restore, "rnd2");
1016 cache.fs().create_dir_all(&staging).unwrap();
1017
1018 let report = cache.clean(&soft_only()).unwrap().report;
1019 assert_eq!(report.evicted_by_soft, 1);
1020 assert_eq!(report.removed_tmp_dirs, 1);
1021 assert_eq!(report.removed_restore_dirs, 1);
1022 assert!(cache.reader().lookup(&key_good).is_some());
1023 }
1024
1025 #[test]
1026 fn cache_022_clean_soft_does_not_touch_files_outside_cache_root() {
1027 let mut fs = MemFilesystem::new();
1028 fs.add_dir("/ws").unwrap();
1029 fs.add_file("/ws/sibling.txt", b"don't touch".to_vec())
1030 .unwrap();
1031 let cache = make_cache(fs, HashAlgo::Blake3);
1032 let key = key_with_first_byte(0xAB);
1033 cache
1034 .fs()
1035 .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
1036 .unwrap();
1037 cache.clean(&soft_only()).unwrap();
1038 assert_eq!(
1039 cache.fs().read(Path::new("/ws/sibling.txt")).unwrap(),
1040 b"don't touch"
1041 );
1042 }
1043
1044 #[test]
1045 fn cache_022_clean_soft_does_not_inspect_blob_contents() {
1046 let mut fs = MemFilesystem::new();
1051 fs.add_dir("/ws").unwrap();
1052 let cache = make_cache(fs, HashAlgo::Blake3);
1053 let key = key_with_first_byte(0xAB);
1054
1055 let manifest = Manifest {
1056 chapter_revision: CHAPTER_REVISION,
1057 hash_function: HashFunctionLabel::Blake3,
1058 key,
1059 outputs: vec![OutputBlob {
1060 workspace_absolute_path: cp("/proj/out"),
1061 content_hash: [0xAAu8; 32],
1062 size: 42,
1063 mode: 0o644,
1064 }],
1065 stdout_len: 0,
1066 stderr_len: 0,
1067 stdout_hash: [0u8; 32],
1068 stderr_hash: [0u8; 32],
1069 exit_status: 0,
1070 created_at_unix: 0,
1071 };
1072 write_manifest_to_entry(&cache, &key, &manifest);
1073
1074 let report = cache.clean(&soft_only()).unwrap().report;
1075 assert_eq!(report.evicted_by_soft, 0);
1076 assert!(
1077 cache
1078 .fs()
1079 .metadata(&layout::entry_dir(cache.cache_root(), &key))
1080 .is_ok()
1081 );
1082 }
1083
1084 #[test]
1087 fn aux_023_clean_max_age_evicts_entries_strictly_older_than_cutoff() {
1088 let mut fs = MemFilesystem::new();
1089 fs.add_dir("/ws").unwrap();
1090 let cache = make_cache(fs, HashAlgo::Blake3);
1091
1092 let key_old = key_with_first_byte(0xAA);
1093 store_entry_at(&cache, &key_old, "proj/old", b"x", 100);
1094 let key_new = key_with_first_byte(0xBB);
1095 store_entry_at(&cache, &key_new, "proj/new", b"y", 260);
1096
1097 let opts = CleanOptions {
1098 max_age: Some(MaxAge::parse("50s").unwrap()),
1099 now_unix: 300,
1100 ..Default::default()
1101 };
1102 let report = cache.clean(&opts).unwrap().report;
1103 assert_eq!(report.evicted_by_max_age, 1);
1104 assert_eq!(report.evicted_by_soft, 0);
1105 assert_eq!(report.evicted_by_max_size, 0);
1106 assert!(cache.reader().lookup(&key_old).is_none());
1107 assert!(cache.reader().lookup(&key_new).is_some());
1108 }
1109
1110 #[test]
1111 fn aux_023_clean_max_age_keeps_entry_at_exactly_cutoff() {
1112 let mut fs = MemFilesystem::new();
1113 fs.add_dir("/ws").unwrap();
1114 let cache = make_cache(fs, HashAlgo::Blake3);
1115
1116 let key = key_with_first_byte(0xAA);
1117 store_entry_at(&cache, &key, "proj/x", b"x", 100);
1118
1119 let opts = CleanOptions {
1120 max_age: Some(MaxAge::parse("100s").unwrap()),
1121 now_unix: 200,
1122 ..Default::default()
1123 };
1124 let report = cache.clean(&opts).unwrap().report;
1125 assert_eq!(report.evicted_by_max_age, 0);
1126 assert!(cache.reader().lookup(&key).is_some());
1127 }
1128
1129 #[test]
1130 fn aux_023_clean_max_age_ignores_corrupt_entries() {
1131 let mut fs = MemFilesystem::new();
1132 fs.add_dir("/ws").unwrap();
1133 let cache = make_cache(fs, HashAlgo::Blake3);
1134
1135 let key_corrupt = key_with_first_byte(0xCC);
1136 let m = Manifest {
1137 chapter_revision: CHAPTER_REVISION,
1138 hash_function: HashFunctionLabel::Sha256,
1139 key: key_corrupt,
1140 outputs: vec![],
1141 stdout_len: 0,
1142 stderr_len: 0,
1143 stdout_hash: [0u8; 32],
1144 stderr_hash: [0u8; 32],
1145 exit_status: 0,
1146 created_at_unix: 0,
1147 };
1148 write_manifest_to_entry(&cache, &key_corrupt, &m);
1149
1150 let key_stale = key_with_first_byte(0xAA);
1151 store_entry_at(&cache, &key_stale, "proj/x", b"x", 100);
1152
1153 let opts = CleanOptions {
1154 max_age: Some(MaxAge::parse("50s").unwrap()),
1155 now_unix: 300,
1156 ..Default::default()
1157 };
1158 let report = cache.clean(&opts).unwrap().report;
1159 assert_eq!(report.evicted_by_max_age, 1);
1160 assert_eq!(report.evicted_by_soft, 0);
1161 assert!(
1162 cache
1163 .fs()
1164 .metadata(&layout::entry_dir(cache.cache_root(), &key_corrupt))
1165 .is_ok()
1166 );
1167 assert!(cache.reader().lookup(&key_stale).is_none());
1168 }
1169
1170 #[test]
1173 fn aux_023_clean_max_size_is_noop_when_under_limit() {
1174 let mut fs = MemFilesystem::new();
1175 fs.add_dir("/ws").unwrap();
1176 let cache = make_cache(fs, HashAlgo::Blake3);
1177
1178 let key = key_with_first_byte(0xAA);
1179 store_entry_at(&cache, &key, "proj/x", b"hello", 100);
1180
1181 let opts = CleanOptions {
1182 max_size: Some(MaxSize::parse("1KB").unwrap()),
1183 ..Default::default()
1184 };
1185 let report = cache.clean(&opts).unwrap().report;
1186 assert_eq!(report.evicted_by_max_size, 0);
1187 assert!(cache.reader().lookup(&key).is_some());
1188 }
1189
1190 #[test]
1191 fn aux_023_clean_max_size_evicts_oldest_first_until_at_or_below_limit() {
1192 let mut fs = MemFilesystem::new();
1193 fs.add_dir("/ws").unwrap();
1194 let cache = make_cache(fs, HashAlgo::Blake3);
1195
1196 let bytes = b"0123456789"; let key_old = key_with_first_byte(0x11);
1198 let key_mid = key_with_first_byte(0x22);
1199 let key_new = key_with_first_byte(0x33);
1200 store_entry_at(&cache, &key_old, "proj/a", bytes, 100);
1201 store_entry_at(&cache, &key_mid, "proj/b", bytes, 200);
1202 store_entry_at(&cache, &key_new, "proj/c", bytes, 300);
1203
1204 let opts = CleanOptions {
1206 max_size: Some(MaxSize::parse("15").unwrap()),
1207 ..Default::default()
1208 };
1209 let report = cache.clean(&opts).unwrap().report;
1210 assert_eq!(report.evicted_by_max_size, 2);
1211 assert!(cache.reader().lookup(&key_old).is_none());
1212 assert!(cache.reader().lookup(&key_mid).is_none());
1213 assert!(cache.reader().lookup(&key_new).is_some());
1214 assert_eq!(report.bytes_reclaimed, 20);
1215 }
1216
1217 #[test]
1218 fn aux_023_clean_max_size_zero_evicts_every_well_formed_entry() {
1219 let mut fs = MemFilesystem::new();
1220 fs.add_dir("/ws").unwrap();
1221 let cache = make_cache(fs, HashAlgo::Blake3);
1222
1223 let key = key_with_first_byte(0xAA);
1224 store_entry_at(&cache, &key, "proj/x", b"x", 100);
1225
1226 let opts = CleanOptions {
1227 max_size: Some(MaxSize::parse("0").unwrap()),
1228 ..Default::default()
1229 };
1230 let report = cache.clean(&opts).unwrap().report;
1231 assert_eq!(report.evicted_by_max_size, 1);
1232 assert!(cache.reader().lookup(&key).is_none());
1233 }
1234
1235 #[test]
1238 fn aux_023_clean_soft_and_max_age_count_separately_per_priority() {
1239 let mut fs = MemFilesystem::new();
1240 fs.add_dir("/ws").unwrap();
1241 let cache = make_cache(fs, HashAlgo::Blake3);
1242
1243 let key_corrupt = key_with_first_byte(0xCC);
1244 let m = Manifest {
1245 chapter_revision: CHAPTER_REVISION.saturating_add(1),
1246 hash_function: HashFunctionLabel::Blake3,
1247 key: key_corrupt,
1248 outputs: vec![],
1249 stdout_len: 0,
1250 stderr_len: 0,
1251 stdout_hash: [0u8; 32],
1252 stderr_hash: [0u8; 32],
1253 exit_status: 0,
1254 created_at_unix: 0,
1255 };
1256 write_manifest_to_entry(&cache, &key_corrupt, &m);
1257
1258 let key_stale = key_with_first_byte(0xAA);
1259 store_entry_at(&cache, &key_stale, "proj/x", b"x", 100);
1260
1261 let opts = CleanOptions {
1262 soft: true,
1263 max_age: Some(MaxAge::parse("50s").unwrap()),
1264 now_unix: 300,
1265 ..Default::default()
1266 };
1267 let report = cache.clean(&opts).unwrap().report;
1268 assert_eq!(report.evicted_by_soft, 1);
1269 assert_eq!(report.evicted_by_max_age, 1);
1270 assert_eq!(report.inspected, 2);
1271 }
1272
1273 #[test]
1274 fn aux_023_clean_evicted_entries_sorted_by_mode_then_created_at() {
1275 let mut fs = MemFilesystem::new();
1276 fs.add_dir("/ws").unwrap();
1277 let cache = make_cache(fs, HashAlgo::Blake3);
1278
1279 let key_a = key_with_first_byte(0x11);
1281 let key_b = key_with_first_byte(0x22);
1282 store_entry_at(&cache, &key_a, "proj/a", b"x", 100);
1283 store_entry_at(&cache, &key_b, "proj/b", b"y", 200);
1284
1285 let key_corrupt = key_with_first_byte(0xCC);
1287 let stale = Manifest {
1288 chapter_revision: CHAPTER_REVISION.saturating_add(1),
1289 hash_function: HashFunctionLabel::Blake3,
1290 key: key_corrupt,
1291 outputs: vec![],
1292 stdout_len: 0,
1293 stderr_len: 0,
1294 stdout_hash: [0u8; 32],
1295 stderr_hash: [0u8; 32],
1296 exit_status: 0,
1297 created_at_unix: 0,
1298 };
1299 write_manifest_to_entry(&cache, &key_corrupt, &stale);
1300
1301 let opts = CleanOptions {
1302 soft: true,
1303 max_age: Some(MaxAge::parse("50s").unwrap()),
1304 now_unix: 300,
1305 ..Default::default()
1306 };
1307 let report = cache.clean(&opts).unwrap().report;
1308 assert_eq!(report.evicted_entries.len(), 3);
1309 assert_eq!(report.evicted_entries[0].matched_mode, EvictionMode::Soft);
1310 assert_eq!(report.evicted_entries[1].matched_mode, EvictionMode::MaxAge);
1311 assert_eq!(
1312 report.evicted_entries[1].created_at_unix, 100,
1313 "older max-age entry sorts before newer one"
1314 );
1315 assert_eq!(report.evicted_entries[2].matched_mode, EvictionMode::MaxAge);
1316 assert_eq!(report.evicted_entries[2].created_at_unix, 200);
1317 }
1318
1319 #[test]
1322 fn aux_023_clean_dry_run_does_not_modify_disk() {
1323 let mut fs = MemFilesystem::new();
1324 fs.add_dir("/ws").unwrap();
1325 let cache = make_cache(fs, HashAlgo::Blake3);
1326
1327 let key = key_with_first_byte(0xAA);
1328 store_entry_at(&cache, &key, "proj/x", b"x", 100);
1329
1330 let opts = CleanOptions {
1331 max_age: Some(MaxAge::parse("50s").unwrap()),
1332 now_unix: 300,
1333 dry_run: true,
1334 ..Default::default()
1335 };
1336 let report = cache.clean(&opts).unwrap().report;
1337 assert_eq!(report.evicted_by_max_age, 1);
1338 assert_eq!(report.evicted_entries.len(), 1);
1339 assert_eq!(report.evicted_entries[0].matched_mode, EvictionMode::MaxAge);
1340 assert!(
1341 cache.reader().lookup(&key).is_some(),
1342 "dry-run must leave the entry on disk"
1343 );
1344 }
1345
1346 #[test]
1347 fn aux_023_clean_dry_run_under_soft_keeps_tmp_and_restore_dirs() {
1348 let mut fs = MemFilesystem::new();
1349 fs.add_dir("/ws").unwrap();
1350 let cache = make_cache(fs, HashAlgo::Blake3);
1351
1352 let key_tmp = key_with_first_byte(0xEF);
1353 let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_tmp, "rnd1");
1354 cache.fs().create_dir_all(&tmp).unwrap();
1355
1356 let key_restore = key_with_first_byte(0x12);
1357 let staging = layout::restore_staging_dir(cache.cache_root(), &key_restore, "rnd2");
1358 cache.fs().create_dir_all(&staging).unwrap();
1359
1360 let opts = CleanOptions {
1361 soft: true,
1362 dry_run: true,
1363 ..Default::default()
1364 };
1365 let report = cache.clean(&opts).unwrap().report;
1366 assert_eq!(report.removed_tmp_dirs, 1);
1367 assert_eq!(report.removed_restore_dirs, 1);
1368 assert!(cache.fs().metadata(&tmp).is_ok());
1369 assert!(cache.fs().metadata(&staging).is_ok());
1370 }
1371
1372 #[test]
1373 fn aux_023_clean_bytes_reclaimed_sums_evicted_footprints() {
1374 let mut fs = MemFilesystem::new();
1375 fs.add_dir("/ws").unwrap();
1376 let cache = make_cache(fs, HashAlgo::Blake3);
1377
1378 let key_a = key_with_first_byte(0x11);
1379 store_entry_at(&cache, &key_a, "proj/a", b"hello", 100); let key_b = key_with_first_byte(0x22);
1381 store_entry_at(&cache, &key_b, "proj/b", b"world!", 200); let opts = CleanOptions {
1384 max_age: Some(MaxAge::parse("50s").unwrap()),
1385 now_unix: 300,
1386 ..Default::default()
1387 };
1388 let report = cache.clean(&opts).unwrap().report;
1389 assert_eq!(report.evicted_by_max_age, 2);
1390 assert_eq!(report.bytes_reclaimed, 11);
1391 }
1392
1393 fn corrupt_manifest(key: CacheKey) -> Manifest {
1399 Manifest {
1400 chapter_revision: CHAPTER_REVISION,
1401 hash_function: HashFunctionLabel::Sha256,
1402 key,
1403 outputs: vec![],
1404 stdout_len: 0,
1405 stderr_len: 0,
1406 stdout_hash: [0u8; 32],
1407 stderr_hash: [0u8; 32],
1408 exit_status: 0,
1409 created_at_unix: 0,
1410 }
1411 }
1412
1413 #[test]
1414 fn aux_028_clean_soft_is_best_effort_when_one_removal_fails() {
1415 let mut fs = MemFilesystem::new();
1416 fs.add_dir("/ws").unwrap();
1417 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1418
1419 let key_stuck = key_with_first_byte(0xAA);
1420 let key_ok = key_with_first_byte(0xBB);
1421 let stuck_dir = layout::entry_dir(&cache_root, &key_stuck);
1422 let ok_dir = layout::entry_dir(&cache_root, &key_ok);
1423 fs.fail_on(
1424 MemFaultOp::RemoveDirAll,
1425 &stuck_dir,
1426 ErrorKind::PermissionDenied,
1427 );
1428
1429 let cache = make_cache(fs, HashAlgo::Blake3);
1430 write_manifest_to_entry(&cache, &key_stuck, &corrupt_manifest(key_stuck));
1431 write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
1432
1433 let outcome = cache.clean(&soft_only()).unwrap();
1434
1435 assert!(
1437 cache.fs().metadata(&stuck_dir).is_ok(),
1438 "stuck entry remains"
1439 );
1440 assert!(
1441 cache.fs().metadata(&ok_dir).is_err(),
1442 "removable entry evicted despite the other failing"
1443 );
1444 assert_eq!(outcome.report.evicted_by_soft, 1);
1446 assert_eq!(outcome.report.evicted_entries.len(), 1);
1447 assert_eq!(outcome.failures.len(), 1);
1448 assert!(matches!(
1449 &outcome.failures[0],
1450 CleanFailure::Entry { path, .. } if path == &stuck_dir
1451 ));
1452 }
1453
1454 #[test]
1455 fn aux_028_clean_collects_every_removal_failure_not_just_the_first() {
1456 let mut fs = MemFilesystem::new();
1457 fs.add_dir("/ws").unwrap();
1458 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1459 let key_a = key_with_first_byte(0xA1);
1460 let key_b = key_with_first_byte(0xB2);
1461 fs.fail_on(
1462 MemFaultOp::RemoveDirAll,
1463 layout::entry_dir(&cache_root, &key_a),
1464 ErrorKind::PermissionDenied,
1465 );
1466 fs.fail_on(
1467 MemFaultOp::RemoveDirAll,
1468 layout::entry_dir(&cache_root, &key_b),
1469 ErrorKind::PermissionDenied,
1470 );
1471
1472 let cache = make_cache(fs, HashAlgo::Blake3);
1473 write_manifest_to_entry(&cache, &key_a, &corrupt_manifest(key_a));
1474 write_manifest_to_entry(&cache, &key_b, &corrupt_manifest(key_b));
1475
1476 let outcome = cache.clean(&soft_only()).unwrap();
1477 assert_eq!(
1478 outcome.report.evicted_by_soft, 0,
1479 "neither removal succeeded"
1480 );
1481 assert_eq!(
1482 outcome.failures.len(),
1483 2,
1484 "both failures surfaced, not just the first"
1485 );
1486 }
1487
1488 #[test]
1489 fn aux_028_clean_soft_best_effort_across_tmp_and_restore() {
1490 let mut fs = MemFilesystem::new();
1491 fs.add_dir("/ws").unwrap();
1492 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1493 let tmp = layout::tmp_entry_dir(&cache_root, &key_with_first_byte(0xEF), "rnd1");
1494 let staging = layout::restore_staging_dir(&cache_root, &key_with_first_byte(0x12), "rnd2");
1495 fs.fail_on(MemFaultOp::RemoveDirAll, &tmp, ErrorKind::PermissionDenied);
1496
1497 let cache = make_cache(fs, HashAlgo::Blake3);
1498 cache.fs().create_dir_all(&tmp).unwrap();
1499 cache.fs().create_dir_all(&staging).unwrap();
1500
1501 let outcome = cache.clean(&soft_only()).unwrap();
1502 assert!(
1504 cache.fs().metadata(&staging).is_err(),
1505 "restore dir removed"
1506 );
1507 assert!(cache.fs().metadata(&tmp).is_ok(), "stuck tmp dir remains");
1508 assert_eq!(outcome.report.removed_restore_dirs, 1);
1509 assert_eq!(
1510 outcome.report.removed_tmp_dirs, 0,
1511 "tmp removal failed, not counted"
1512 );
1513 assert_eq!(outcome.failures.len(), 1);
1514 assert!(matches!(
1515 &outcome.failures[0],
1516 CleanFailure::Tmp { path, .. } if path == &tmp
1517 ));
1518 }
1519
1520 #[test]
1521 fn aux_028_clean_skips_unreadable_shard_and_cleans_the_rest() {
1522 let mut fs = MemFilesystem::new();
1523 fs.add_dir("/ws").unwrap();
1524 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1525 let key_blocked = key_with_first_byte(0xAA);
1526 let key_ok = key_with_first_byte(0xBB);
1527 let blocked_shard = layout::shard_dir(&cache_root, &key_blocked);
1528 fs.fail_on(
1529 MemFaultOp::ReadDir,
1530 &blocked_shard,
1531 ErrorKind::PermissionDenied,
1532 );
1533
1534 let cache = make_cache(fs, HashAlgo::Blake3);
1535 write_manifest_to_entry(&cache, &key_blocked, &corrupt_manifest(key_blocked));
1536 write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
1537
1538 let outcome = cache.clean(&soft_only()).unwrap();
1539 assert!(
1540 cache
1541 .fs()
1542 .metadata(&layout::entry_dir(&cache_root, &key_ok))
1543 .is_err(),
1544 "entry in the readable shard is evicted"
1545 );
1546 assert!(
1547 cache
1548 .fs()
1549 .metadata(&layout::entry_dir(&cache_root, &key_blocked))
1550 .is_ok(),
1551 "entry in the unreadable shard is left untouched"
1552 );
1553 assert_eq!(outcome.report.evicted_by_soft, 1);
1554 assert_eq!(outcome.failures.len(), 1);
1555 assert!(matches!(
1556 &outcome.failures[0],
1557 CleanFailure::Shard { path, .. } if path == &blocked_shard
1558 ));
1559 }
1560
1561 #[test]
1562 fn aux_028_clean_skips_unreadable_manifest_and_cleans_the_rest() {
1563 let mut fs = MemFilesystem::new();
1564 fs.add_dir("/ws").unwrap();
1565 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1566 let key_blocked = key_with_first_byte(0xAA);
1567 let key_ok = key_with_first_byte(0xCC);
1568 let blocked_manifest = layout::manifest_path(&cache_root, &key_blocked);
1569 fs.fail_on(
1570 MemFaultOp::Read,
1571 &blocked_manifest,
1572 ErrorKind::PermissionDenied,
1573 );
1574
1575 let cache = make_cache(fs, HashAlgo::Blake3);
1576 write_manifest_to_entry(&cache, &key_blocked, &corrupt_manifest(key_blocked));
1577 write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
1578
1579 let outcome = cache.clean(&soft_only()).unwrap();
1580 assert!(
1581 cache
1582 .fs()
1583 .metadata(&layout::entry_dir(&cache_root, &key_ok))
1584 .is_err(),
1585 "entry with a readable manifest is evicted"
1586 );
1587 assert!(
1588 cache
1589 .fs()
1590 .metadata(&layout::entry_dir(&cache_root, &key_blocked))
1591 .is_ok(),
1592 "entry with an unreadable manifest is left untouched"
1593 );
1594 assert_eq!(outcome.report.evicted_by_soft, 1);
1595 assert_eq!(outcome.failures.len(), 1);
1596 assert!(matches!(
1597 &outcome.failures[0],
1598 CleanFailure::Manifest { path, .. } if path == &blocked_manifest
1599 ));
1600 }
1601
1602 #[test]
1603 fn aux_028_clean_unreadable_cache_root_is_fatal_with_no_report() {
1604 let mut fs = MemFilesystem::new();
1605 fs.add_dir("/ws").unwrap();
1606 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1607 fs.fail_on(
1610 MemFaultOp::ReadDir,
1611 &cache_root,
1612 ErrorKind::PermissionDenied,
1613 );
1614 let cache = make_cache(fs, HashAlgo::Blake3);
1615
1616 let result = cache.clean(&soft_only());
1617 assert!(
1618 matches!(result, Err(CleanError::Io { .. })),
1619 "an unreadable cache root is fatal"
1620 );
1621 }
1622
1623 #[test]
1624 fn aux_028_dry_run_records_no_failures_even_with_a_remove_fault() {
1625 let mut fs = MemFilesystem::new();
1626 fs.add_dir("/ws").unwrap();
1627 let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1628 let key = key_with_first_byte(0xAA);
1629 let entry_dir = layout::entry_dir(&cache_root, &key);
1630 fs.fail_on(
1631 MemFaultOp::RemoveDirAll,
1632 &entry_dir,
1633 ErrorKind::PermissionDenied,
1634 );
1635
1636 let cache = make_cache(fs, HashAlgo::Blake3);
1637 write_manifest_to_entry(&cache, &key, &corrupt_manifest(key));
1638
1639 let opts = CleanOptions {
1640 soft: true,
1641 dry_run: true,
1642 ..Default::default()
1643 };
1644 let outcome = cache.clean(&opts).unwrap();
1645 assert!(
1646 outcome.failures.is_empty(),
1647 "dry-run never touches disk, so no removal failure is recorded"
1648 );
1649 assert_eq!(
1650 outcome.report.evicted_by_soft, 1,
1651 "dry-run still projects the plan"
1652 );
1653 assert!(
1654 cache.fs().metadata(&entry_dir).is_ok(),
1655 "dry-run leaves the entry on disk"
1656 );
1657 }
1658}