1use std::ffi::OsStr;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26use crate::engine_contract::{Error, Result, ScanScope};
27use crate::snapshot::{self, Identity};
28use crate::stored_state::{ContentTierIdentity, SnapshotIdentity};
29
30const SNAPSHOT_NAME_HEX_DIGITS: usize = 16;
32
33const SNAPSHOT_NAME_SUFFIX: &str = ".fdu";
35
36const CONTENT_NAME_SUFFIX: &str = ".content";
38
39const TEMP_NAME_INFIX: &str = ".tmp.";
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct CacheStatus {
46 pub path: PathBuf,
48 pub bytes: u64,
50 pub content: Option<ContentStatus>,
53 pub state: CacheState,
55}
56
57impl CacheStatus {
58 pub fn snapshot(&self) -> Option<&SnapshotInfo> {
60 match &self.state {
61 CacheState::Current(info) => Some(info),
62 CacheState::Stale(_)
63 | CacheState::Leftover(_)
64 | CacheState::Unrecognized
65 | CacheState::Absent => None,
66 }
67 }
68
69 pub fn is_fdu_snapshot(&self) -> bool {
72 matches!(self.state, CacheState::Current(_) | CacheState::Stale(_))
73 }
74
75 pub fn content_bytes(&self) -> Option<u64> {
77 self.content.as_ref().map(|content| content.bytes)
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct ContentStatus {
90 pub bytes: u64,
92 pub state: ContentState,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum ContentState {
99 Current(ContentInfo),
101 Stale(StaleReason),
104}
105
106impl ContentState {
107 pub const LABELS: [&'static str; 2] = ["current", "stale"];
113
114 pub fn label(&self) -> &'static str {
116 match self {
117 Self::Current(_) => "current",
118 Self::Stale(_) => "stale",
119 }
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct ContentInfo {
126 pub identity: ContentTierIdentity,
128 pub records: u64,
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum CacheState {
135 Current(SnapshotInfo),
137 Stale(StaleReason),
139 Leftover(LeftoverKind),
144 Unrecognized,
150 Absent,
153}
154
155impl CacheState {
156 pub const LABELS: [&'static str; 5] =
158 ["current", "stale", "leftover", "unrecognized", "absent"];
159
160 pub fn label(&self) -> &'static str {
162 match self {
163 Self::Current(_) => "current",
164 Self::Stale(_) => "stale",
165 Self::Leftover(_) => "leftover",
166 Self::Unrecognized => "unrecognized",
167 Self::Absent => "absent",
168 }
169 }
170}
171
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum LeftoverKind {
179 StagingTemporary,
184 OrphanedContent,
191}
192
193impl LeftoverKind {
194 pub const LABELS: [&'static str; 2] = ["staging_temporary", "orphaned_content"];
196
197 pub fn label(self) -> &'static str {
199 match self {
200 Self::StagingTemporary => "staging_temporary",
201 Self::OrphanedContent => "orphaned_content",
202 }
203 }
204}
205
206#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212pub struct ClearSummary {
213 pub snapshots: usize,
215 pub leftovers: usize,
217}
218
219impl ClearSummary {
220 pub fn is_empty(self) -> bool {
222 self.snapshots == 0 && self.leftovers == 0
223 }
224}
225
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228pub enum StaleReason {
229 OlderFormat {
231 version: u32,
233 },
234 NewerFormat {
236 version: u32,
238 },
239 OtherEngine,
242 Unreadable,
245}
246
247impl StaleReason {
248 pub const LABELS: [&'static str; 4] =
250 ["older_format", "newer_format", "other_engine", "unreadable"];
251
252 pub fn label(self) -> &'static str {
254 match self {
255 Self::OlderFormat { .. } => "older_format",
256 Self::NewerFormat { .. } => "newer_format",
257 Self::OtherEngine => "other_engine",
258 Self::Unreadable => "unreadable",
259 }
260 }
261
262 pub fn format_version(self) -> Option<u32> {
264 match self {
265 Self::OlderFormat { version } | Self::NewerFormat { version } => Some(version),
266 Self::OtherEngine | Self::Unreadable => None,
267 }
268 }
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
273pub enum CacheScope {
274 Root,
276 All,
278}
279
280impl CacheScope {
281 pub const LABELS: [&'static str; 2] = ["root", "all"];
283
284 pub fn label(self) -> &'static str {
286 match self {
287 Self::Root => "root",
288 Self::All => "all",
289 }
290 }
291
292 pub fn parse(value: &str) -> Option<Self> {
294 match value.trim().to_ascii_lowercase().as_str() {
295 "root" => Some(Self::Root),
296 "all" => Some(Self::All),
297 _ => None,
298 }
299 }
300}
301
302#[derive(Clone, Debug, PartialEq, Eq)]
304pub struct SnapshotInfo {
305 pub root: PathBuf,
307 pub identity: SnapshotIdentity,
309 pub entries: u64,
311}
312
313impl SnapshotInfo {
314 pub fn scope(&self) -> ScanScope {
316 self.identity.scan_scope()
317 }
318}
319
320pub(crate) fn snapshot_file_name(root_hash: u64) -> String {
322 format!("{root_hash:0SNAPSHOT_NAME_HEX_DIGITS$x}{SNAPSHOT_NAME_SUFFIX}")
323}
324
325fn is_snapshot_name_bytes(bytes: &[u8]) -> bool {
331 bytes.len() == SNAPSHOT_NAME_HEX_DIGITS + SNAPSHOT_NAME_SUFFIX.len()
332 && bytes.ends_with(SNAPSHOT_NAME_SUFFIX.as_bytes())
333 && bytes[..SNAPSHOT_NAME_HEX_DIGITS]
334 .iter()
335 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
336}
337
338fn is_sidecar_name_bytes(bytes: &[u8]) -> bool {
340 bytes.strip_suffix(CONTENT_NAME_SUFFIX.as_bytes()).is_some_and(is_snapshot_name_bytes)
341}
342
343fn name_shape(name: &OsStr) -> NameShape {
349 let bytes = name.as_encoded_bytes();
350 if is_snapshot_name_bytes(bytes) {
351 return NameShape::Snapshot;
352 }
353 if is_sidecar_name_bytes(bytes) {
354 return NameShape::Sidecar;
355 }
356 let Some(rest) = bytes.strip_prefix(b".") else { return NameShape::Other };
359 let Some(at) =
360 rest.windows(TEMP_NAME_INFIX.len()).position(|w| w == TEMP_NAME_INFIX.as_bytes())
361 else {
362 return NameShape::Other;
363 };
364 let (target, suffix) = rest.split_at(at);
365 if suffix.len() <= TEMP_NAME_INFIX.len() {
366 return NameShape::Other;
368 }
369 if is_snapshot_name_bytes(target) {
370 NameShape::SnapshotTemporary
371 } else if is_sidecar_name_bytes(target) {
372 NameShape::SidecarTemporary
373 } else {
374 NameShape::Other
375 }
376}
377
378#[derive(Clone, Copy, Debug, PartialEq, Eq)]
380enum NameShape {
381 Snapshot,
383 Sidecar,
385 SnapshotTemporary,
387 SidecarTemporary,
389 Other,
391}
392
393pub fn cache_status(path: &Path) -> Result<CacheStatus> {
404 Ok(status_at(path)?.unwrap_or_else(|| CacheStatus {
405 path: path.to_path_buf(),
406 bytes: 0,
407 content: None,
408 state: CacheState::Absent,
409 }))
410}
411
412fn status_at(path: &Path) -> Result<Option<CacheStatus>> {
418 let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
419 return Ok(None);
420 };
421 if !metadata.file_type().is_file() {
422 return Ok(Some(unrecognized(path, reportable_bytes(&metadata))));
423 }
424 let leftover = match path.file_name().map_or(NameShape::Other, name_shape) {
434 NameShape::SnapshotTemporary => Some(leftover_or_unrecognized(
435 path,
436 metadata.len(),
437 LeftoverKind::StagingTemporary,
438 is_snapshot_image(path)?,
439 )),
440 NameShape::SidecarTemporary => Some(leftover_or_unrecognized(
441 path,
442 metadata.len(),
443 LeftoverKind::StagingTemporary,
444 is_sidecar_image(path)?,
445 )),
446 NameShape::Sidecar => Some(leftover_or_unrecognized(
447 path,
448 metadata.len(),
449 LeftoverKind::OrphanedContent,
450 is_sidecar_image(path)?,
451 )),
452 NameShape::Snapshot | NameShape::Other => None,
453 };
454 if let Some(status) = leftover {
455 return Ok(Some(status));
456 }
457 let state = match snapshot::identify(path)? {
458 None => return Ok(None),
459 Some(Identity::Foreign) => return Ok(Some(unrecognized(path, metadata.len()))),
460 Some(Identity::Current(info)) => CacheState::Current(info),
461 Some(Identity::Stale(reason)) => CacheState::Stale(reason),
462 };
463 let content = crate::content::identify_sidecar(&crate::content::content_cache_path(path))?;
464 Ok(Some(CacheStatus { path: path.to_path_buf(), bytes: metadata.len(), content, state }))
465}
466
467fn is_snapshot_image(path: &Path) -> Result<bool> {
473 let Some(metadata) = present(fs::symlink_metadata(path), path)? else { return Ok(false) };
474 if !metadata.file_type().is_file() {
475 return Ok(false);
476 }
477 Ok(matches!(snapshot::identify(path)?, Some(Identity::Current(_) | Identity::Stale(_))))
478}
479
480fn is_sidecar_image(path: &Path) -> Result<bool> {
482 Ok(crate::content::content_sidecar_bytes(path)?.is_some())
483}
484
485fn sidecar_snapshot_path(path: &Path) -> PathBuf {
490 path.with_extension("")
491}
492
493fn unrecognized(path: &Path, bytes: u64) -> CacheStatus {
494 CacheStatus { path: path.to_path_buf(), bytes, content: None, state: CacheState::Unrecognized }
495}
496
497fn leftover_or_unrecognized(
500 path: &Path,
501 bytes: u64,
502 kind: LeftoverKind,
503 has_magic: bool,
504) -> CacheStatus {
505 if has_magic {
506 CacheStatus {
507 path: path.to_path_buf(),
508 bytes,
509 content: None,
510 state: CacheState::Leftover(kind),
511 }
512 } else {
513 unrecognized(path, bytes)
514 }
515}
516
517fn reportable_bytes(metadata: &fs::Metadata) -> u64 {
525 if metadata.file_type().is_file() { metadata.len() } else { 0 }
526}
527
528fn present<T>(result: io::Result<T>, path: &Path) -> Result<Option<T>> {
533 match result {
534 Ok(value) => Ok(Some(value)),
535 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
536 Err(error) => Err(Error::io(path, error)),
537 }
538}
539
540pub fn list_caches(cache_dir: &Path) -> Result<Vec<CacheStatus>> {
551 let Some(entries) = present(fs::read_dir(cache_dir), cache_dir)? else {
552 return Ok(Vec::new());
554 };
555
556 let mut found = Vec::new();
557 for entry in entries {
558 let entry = entry.map_err(|error| Error::io(cache_dir, error))?;
559 let path = entry.path();
560 let Some(file_type) = present(entry.file_type(), &path)? else { continue };
562 let status = if file_type.is_file() && name_shape(&entry.file_name()) != NameShape::Other {
563 status_at(&path)?
564 } else {
565 present(fs::symlink_metadata(&path), &path)?
566 .map(|metadata| unrecognized(&path, reportable_bytes(&metadata)))
567 };
568 found.extend(status);
569 }
570 let paired_sidecars = found
571 .iter()
572 .filter(|status| status.is_fdu_snapshot() && status.content.is_some())
573 .map(|status| crate::content::content_cache_path(&status.path))
574 .collect::<std::collections::BTreeSet<_>>();
575 found.retain(|status| !paired_sidecars.contains(&status.path));
576 found.sort_by(|left, right| left.path.cmp(&right.path));
578 Ok(found)
579}
580
581pub fn clear_cache(path: &Path) -> Result<bool> {
587 let Some(status) = status_at(path)? else { return Ok(false) };
588 if !status.is_fdu_snapshot() {
589 return Ok(false);
592 }
593 if !remove_present(path)? {
596 return Ok(false);
597 }
598 let content_path = crate::content::content_cache_path(path);
599 if crate::content::content_sidecar_bytes(&content_path)?.is_some() {
600 remove_present(&content_path)?;
601 }
602 Ok(true)
603}
604
605fn remove_present(path: &Path) -> Result<bool> {
607 Ok(present(fs::remove_file(path), path)?.is_some())
608}
609
610pub fn clear_all_caches(cache_dir: &Path) -> Result<ClearSummary> {
619 let listed = list_caches(cache_dir)?;
620 let mut summary = ClearSummary::default();
621 for status in &listed {
622 if status.is_fdu_snapshot() && clear_cache(&status.path)? {
623 summary.snapshots += 1;
624 }
625 }
626 for status in &listed {
627 if let CacheState::Leftover(kind) = status.state {
628 if clear_leftover(&status.path, kind)? {
629 summary.leftovers += 1;
630 }
631 }
632 }
633 Ok(summary)
634}
635
636fn clear_leftover(path: &Path, kind: LeftoverKind) -> Result<bool> {
643 let Some(status) = status_at(path)? else { return Ok(false) };
644 if status.state != CacheState::Leftover(kind) {
645 return Ok(false);
646 }
647 let removable = match kind {
648 LeftoverKind::StagingTemporary => {
652 let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
653 return Ok(false);
654 };
655 let Ok(modified) = metadata.modified() else { return Ok(false) };
656 std::time::SystemTime::now()
657 .duration_since(modified)
658 .is_ok_and(|age| age >= snapshot::STALE_TEMP_AGE)
659 }
660 LeftoverKind::OrphanedContent => !is_snapshot_image(&sidecar_snapshot_path(path))?,
666 };
667 if !removable {
668 return Ok(false);
669 }
670 remove_present(path)
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::{CachePolicy, OpenFixture, open_fixture as open};
677
678 const VERSION_OFFSET: usize = 8;
680
681 const FINGERPRINT_OFFSET: usize = VERSION_OFFSET + 4;
683
684 fn layout_name(seed: u64) -> String {
686 snapshot_file_name(seed)
687 }
688
689 fn seed(tree: &Path, snapshot_path: &Path) {
690 std::fs::write(tree.join("a.txt"), b"hello").expect("write");
691 let config = OpenFixture {
692 cache_path: Some(snapshot_path.to_path_buf()),
693 policy: CachePolicy::Auto,
694 ..OpenFixture::default()
695 };
696 open(tree, &config).expect("seed");
697 }
698
699 fn with_format_version(from: &Path, to: &Path, version: impl FnOnce(u32) -> u32) {
701 let mut bytes = std::fs::read(from).expect("read snapshot");
702 let range = VERSION_OFFSET..FINGERPRINT_OFFSET;
703 let current = u32::from_le_bytes(bytes[range.clone()].try_into().expect("four bytes"));
704 bytes[range].copy_from_slice(&version(current).to_le_bytes());
705 std::fs::write(to, bytes).expect("write stale snapshot");
706 }
707
708 #[test]
709 fn a_snapshot_reports_the_root_it_describes() {
710 let tree = tempfile::tempdir().expect("tempdir");
713 let cache = tempfile::tempdir().expect("cache");
714 let path = cache.path().join("snap.fdu");
715 seed(tree.path(), &path);
716
717 let status = cache_status(&path).expect("status");
718 assert!(status.bytes > 0);
719 let info = status.snapshot().expect("header");
720 assert_eq!(info.root, tree.path().canonicalize().expect("canonical"));
721 assert_eq!(info.entries, 2, "the root plus one file");
722 }
723
724 #[test]
725 fn content_sidecar_is_reported_and_cleared_with_its_snapshot() {
726 let tree = tempfile::tempdir().expect("tempdir");
727 let cache = tempfile::tempdir().expect("cache");
728 let path = cache.path().join(layout_name(1));
729 std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
730 let config = OpenFixture {
731 cache_path: Some(path.clone()),
732 policy: CachePolicy::Auto,
733 analysis: crate::content::AnalysisRequest {
734 profile: crate::content::AnalysisSet::NONE.with_lines(),
735 ..crate::content::AnalysisRequest::default()
736 },
737 ..OpenFixture::default()
738 };
739 let (index, _) = open(tree.path(), &config).expect("seed analyzed cache");
740
741 let listed = list_caches(cache.path()).expect("list");
742 assert_eq!(listed.len(), 1, "a sidecar is grouped with its snapshot");
743 let Some(CacheState::Current(snapshot)) = listed.first().map(|status| &status.state) else {
744 panic!("a current snapshot: {listed:?}");
745 };
746 assert_eq!(snapshot.identity, index.snapshot_identity());
747 let content = listed[0].content.as_ref().expect("its sidecar");
748 assert_eq!(
749 content.state,
750 ContentState::Current(ContentInfo {
751 identity: index.content_identity(config.analysis.profile),
752 records: 1,
753 }),
754 "status reports the identity the sidecar serves"
755 );
756 assert!(clear_cache(&path).expect("clear"));
757 assert!(!path.exists());
758 assert!(!crate::content::content_cache_path(&path).exists());
759 }
760
761 #[test]
762 fn stale_snapshots_are_reported_beside_a_current_one_and_all_are_cleared() {
763 let tree = tempfile::tempdir().expect("tempdir");
767 let cache = tempfile::tempdir().expect("cache");
768 let current = cache.path().join(layout_name(1));
769 seed(tree.path(), ¤t);
770
771 let older = cache.path().join(layout_name(2));
772 with_format_version(¤t, &older, |version| version - 1);
773 let newer = cache.path().join(layout_name(3));
774 with_format_version(¤t, &newer, |version| version + 1);
775 let other_engine = cache.path().join(layout_name(4));
776 let mut bytes = std::fs::read(¤t).expect("read");
777 for byte in &mut bytes[FINGERPRINT_OFFSET..FINGERPRINT_OFFSET + 8] {
778 *byte = !*byte;
779 }
780 std::fs::write(&other_engine, &bytes).expect("write");
781 let truncated = cache.path().join(layout_name(5));
782 let full = std::fs::read(¤t).expect("read");
783 std::fs::write(&truncated, &full[..full.len() - 1]).expect("truncate");
784 let hand_built = cache.path().join(layout_name(6));
787 let mut prologue = b"FDUSNAP\x00".to_vec();
788 prologue.extend_from_slice(&1_u32.to_le_bytes());
789 std::fs::write(&hand_built, &prologue).expect("write");
790
791 let current_version = u32::from_le_bytes(
792 full[VERSION_OFFSET..FINGERPRINT_OFFSET].try_into().expect("four bytes"),
793 );
794 let states = list_caches(cache.path())
795 .expect("list")
796 .into_iter()
797 .map(|status| (status.path, status.state))
798 .collect::<Vec<_>>();
799 assert_eq!(states.len(), 6);
800 assert!(matches!(states[0].1, CacheState::Current(_)));
801 assert_eq!(
802 states[1..],
803 [
804 (
805 older,
806 CacheState::Stale(StaleReason::OlderFormat { version: current_version - 1 })
807 ),
808 (
809 newer,
810 CacheState::Stale(StaleReason::NewerFormat { version: current_version + 1 })
811 ),
812 (other_engine, CacheState::Stale(StaleReason::OtherEngine)),
813 (truncated.clone(), CacheState::Stale(StaleReason::Unreadable)),
814 (hand_built, CacheState::Stale(StaleReason::OlderFormat { version: 1 })),
815 ]
816 );
817
818 assert!(clear_cache(&truncated).expect("clear one"), "a truncated snapshot is still fdu's");
819 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 5);
820 assert!(list_caches(cache.path()).expect("list").is_empty());
821 }
822
823 #[test]
824 fn a_stale_snapshot_takes_its_content_sidecar_with_it() {
825 let tree = tempfile::tempdir().expect("tempdir");
826 let cache = tempfile::tempdir().expect("cache");
827 let path = cache.path().join(layout_name(1));
828 std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
829 let config = OpenFixture {
830 cache_path: Some(path.clone()),
831 policy: CachePolicy::Auto,
832 analysis: crate::content::AnalysisRequest {
833 profile: crate::content::AnalysisSet::NONE.with_lines(),
834 ..crate::content::AnalysisRequest::default()
835 },
836 ..OpenFixture::default()
837 };
838 open(tree.path(), &config).expect("seed analyzed cache");
839 with_format_version(&path, &path, |version| version - 1);
840
841 let listed = list_caches(cache.path()).expect("list");
842 assert_eq!(listed.len(), 1, "a stale snapshot's sidecar is grouped with it");
843 assert!(matches!(listed[0].state, CacheState::Stale(StaleReason::OlderFormat { .. })));
844 assert!(listed[0].content.is_some());
845 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
846 assert!(!crate::content::content_cache_path(&path).exists());
847 }
848
849 #[test]
853 fn a_stale_sidecar_beside_a_current_snapshot_is_labelled_and_cleared() {
854 let tree = tempfile::tempdir().expect("tempdir");
855 let cache = tempfile::tempdir().expect("cache");
856 let path = cache.path().join(layout_name(1));
857 std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
858 let config = OpenFixture {
859 cache_path: Some(path.clone()),
860 policy: CachePolicy::Auto,
861 analysis: crate::content::AnalysisRequest {
862 profile: crate::content::AnalysisSet::NONE.with_lines(),
863 ..crate::content::AnalysisRequest::default()
864 },
865 ..OpenFixture::default()
866 };
867 open(tree.path(), &config).expect("seed analyzed cache");
868 let sidecar = crate::content::content_cache_path(&path);
869 let written = std::fs::read(&sidecar).expect("a sidecar");
870
871 let mut older = written.clone();
872 older[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
873 let mut newer = written.clone();
874 newer[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&99_u32.to_le_bytes());
875 let mut other_engine = written.clone();
876 other_engine[FINGERPRINT_OFFSET] ^= 0xff;
877 let truncated = written[..written.len() - 1].to_vec();
878 let mut unreadable_header = written.clone();
879 let path_encoding_at = FINGERPRINT_OFFSET + 8;
880 unreadable_header[path_encoding_at] ^= 0xff;
881 for (image, reason) in [
882 (older, StaleReason::OlderFormat { version: 4 }),
883 (newer, StaleReason::NewerFormat { version: 99 }),
884 (other_engine, StaleReason::OtherEngine),
885 (truncated, StaleReason::Unreadable),
886 (unreadable_header, StaleReason::Unreadable),
887 ] {
888 std::fs::write(&sidecar, &image).expect("rewrite the sidecar");
889 let listed = list_caches(cache.path()).expect("list");
890 assert_eq!(listed.len(), 1, "a stale sidecar is still grouped with its snapshot");
891 assert!(matches!(listed[0].state, CacheState::Current(_)), "{listed:?}");
892 let content = listed[0].content.as_ref().expect("its sidecar");
893 assert_eq!(content.bytes, u64::try_from(image.len()).expect("small"));
894 assert_eq!(content.state, ContentState::Stale(reason), "{reason:?}");
895 assert_eq!(cache_status(&path).expect("status").content, listed[0].content);
896 }
897
898 assert!(clear_cache(&path).expect("clear"));
899 assert!(!path.exists());
900 assert!(!sidecar.exists(), "the stale sidecar goes with its snapshot");
901 }
902
903 #[test]
904 fn unrecognized_files_are_listed_but_never_removed() {
905 let tree = tempfile::tempdir().expect("tempdir");
906 let cache = tempfile::tempdir().expect("cache");
907 let path = cache.path().join(layout_name(1));
908 seed(tree.path(), &path);
909 let foreign = cache.path().join("notes.txt");
910 std::fs::write(&foreign, b"not a snapshot").expect("write");
911 let impostor = cache.path().join(layout_name(2));
913 std::fs::write(&impostor, b"FDUSNAQ and more bytes").expect("write");
914 let renamed = cache.path().join("backup.fdu");
917 std::fs::copy(&path, &renamed).expect("copy");
918
919 let listed = list_caches(cache.path()).expect("list");
920 assert_eq!(
921 listed
922 .iter()
923 .map(|status| (status.path.clone(), status.state.label()))
924 .collect::<Vec<_>>(),
925 [
926 (path.clone(), "current"),
927 (impostor.clone(), "unrecognized"),
928 (renamed.clone(), "unrecognized"),
929 (foreign.clone(), "unrecognized"),
930 ]
931 );
932 assert_eq!(listed[3].bytes, 14, "an unrecognized file still reports its size");
933
934 assert!(!clear_cache(&impostor).expect("clear"));
935 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
936 for survivor in [&foreign, &impostor, &renamed] {
937 assert!(survivor.exists(), "{} must survive", survivor.display());
938 }
939 assert_eq!(list_caches(cache.path()).expect("list").len(), 3);
940 }
941
942 #[cfg(unix)]
943 #[test]
944 fn a_symbolic_link_in_the_cache_is_never_followed_or_removed() {
945 let tree = tempfile::tempdir().expect("tempdir");
948 let cache = tempfile::tempdir().expect("cache");
949 let outside = tempfile::tempdir().expect("outside");
950 let target = outside.path().join(layout_name(9));
951 seed(tree.path(), &target);
952 let link = cache.path().join(layout_name(1));
953 std::os::unix::fs::symlink(&target, &link).expect("symlink");
954
955 let listed = list_caches(cache.path()).expect("list");
956 assert_eq!(listed.len(), 1);
957 assert_eq!(listed[0].state, CacheState::Unrecognized);
958 assert_eq!(listed[0].bytes, 0, "a link has no size to report");
961 assert_eq!(cache_status(&link).expect("status").state, CacheState::Unrecognized);
962
963 assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
964 assert!(!clear_cache(&link).expect("clear"));
965 assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
966 assert!(
967 cache_status(&target).expect("status").snapshot().is_some(),
968 "and so does its target"
969 );
970 }
971
972 #[test]
973 fn a_directory_in_the_cache_is_listed_and_never_removed() {
974 let tree = tempfile::tempdir().expect("tempdir");
978 let cache = tempfile::tempdir().expect("cache");
979 let snapshot = cache.path().join(layout_name(1));
980 seed(tree.path(), &snapshot);
981 let nested = cache.path().join(layout_name(2));
983 std::fs::create_dir(&nested).expect("create dir");
984
985 let listed = list_caches(cache.path()).expect("list");
986 assert_eq!(
987 listed
988 .iter()
989 .map(|status| (status.path.clone(), status.state.label()))
990 .collect::<Vec<_>>(),
991 [(snapshot, "current"), (nested.clone(), "unrecognized")]
992 );
993 assert_eq!(listed[1].bytes, 0, "a directory has no size to report");
997 let single = cache_status(&nested).expect("status");
998 assert_eq!(single.state, CacheState::Unrecognized);
999 assert_eq!(single.bytes, 0, "and the single-path route agrees");
1000
1001 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
1002 assert!(!clear_cache(&nested).expect("clear"));
1003 assert!(nested.is_dir(), "a directory is never removed");
1004 }
1005
1006 fn set_modified(path: &Path, at: std::time::SystemTime) {
1009 std::fs::OpenOptions::new()
1010 .write(true)
1011 .open(path)
1012 .expect("open")
1013 .set_modified(at)
1014 .expect("set modified");
1015 }
1016
1017 fn beyond_the_reaper() -> std::time::SystemTime {
1019 std::time::SystemTime::now() - snapshot::STALE_TEMP_AGE - std::time::Duration::from_secs(1)
1020 }
1021
1022 fn staging_name(target: &str) -> String {
1024 format!(".{target}.tmp.7.0123456789abcdef.0")
1025 }
1026
1027 fn describe(status: &CacheStatus) -> String {
1029 match &status.state {
1030 CacheState::Leftover(kind) => format!("leftover/{}", kind.label()),
1031 other => other.label().to_string(),
1032 }
1033 }
1034
1035 #[test]
1036 fn fdus_own_leftovers_are_named_as_fdus_and_reclaimed_only_under_their_rules() {
1037 let tree = tempfile::tempdir().expect("tempdir");
1041 let cache = tempfile::tempdir().expect("cache");
1042 let current = cache.path().join(layout_name(1));
1043 seed(tree.path(), ¤t);
1044 let image = std::fs::read(¤t).expect("read snapshot");
1045
1046 let abandoned = cache.path().join(staging_name(&layout_name(2)));
1049 std::fs::write(&abandoned, &image).expect("write");
1050 set_modified(&abandoned, beyond_the_reaper());
1051 let in_flight = cache.path().join(staging_name(&layout_name(3)));
1053 std::fs::write(&in_flight, &image).expect("write");
1054 let impostor = cache.path().join(staging_name(&layout_name(4)));
1056 std::fs::write(&impostor, b"not a snapshot").expect("write");
1057 set_modified(&impostor, beyond_the_reaper());
1058 let staged_sidecar =
1060 cache.path().join(staging_name(&format!("{}.content", layout_name(5))));
1061 std::fs::write(&staged_sidecar, b"FDUCTNT\0payload").expect("write");
1062 set_modified(&staged_sidecar, beyond_the_reaper());
1063 let orphan = cache.path().join(format!("{}.content", layout_name(6)));
1064 std::fs::write(&orphan, b"FDUCTNT\0payload").expect("write");
1065 let foreign_sidecar = cache.path().join(format!("{}.content", layout_name(7)));
1067 std::fs::write(&foreign_sidecar, b"not a sidecar").expect("write");
1068 let snapshot_under_sidecar_name = cache.path().join(format!("{}.content", layout_name(8)));
1073 std::fs::write(&snapshot_under_sidecar_name, &image).expect("write");
1074 let snapshot_under_staged_sidecar_name =
1075 cache.path().join(staging_name(&format!("{}.content", layout_name(9))));
1076 std::fs::write(&snapshot_under_staged_sidecar_name, &image).expect("write");
1077 set_modified(&snapshot_under_staged_sidecar_name, beyond_the_reaper());
1078
1079 let listed = list_caches(cache.path()).expect("list");
1080 assert_eq!(
1081 listed.iter().map(|status| (status.path.clone(), describe(status))).collect::<Vec<_>>(),
1082 [
1083 (abandoned.clone(), "leftover/staging_temporary".to_string()),
1084 (in_flight.clone(), "leftover/staging_temporary".to_string()),
1085 (impostor.clone(), "unrecognized".to_string()),
1086 (staged_sidecar.clone(), "leftover/staging_temporary".to_string()),
1087 (snapshot_under_staged_sidecar_name.clone(), "unrecognized".to_string()),
1088 (current.clone(), "current".to_string()),
1089 (orphan.clone(), "leftover/orphaned_content".to_string()),
1090 (foreign_sidecar.clone(), "unrecognized".to_string()),
1091 (snapshot_under_sidecar_name.clone(), "unrecognized".to_string()),
1092 ]
1093 );
1094 for named in [&snapshot_under_sidecar_name, &snapshot_under_staged_sidecar_name] {
1097 assert_eq!(cache_status(named).expect("status").state, CacheState::Unrecognized);
1098 }
1099
1100 let summary = clear_all_caches(cache.path()).expect("clear");
1101 assert_eq!(summary, ClearSummary { snapshots: 1, leftovers: 3 });
1102 for gone in [&abandoned, &staged_sidecar, &orphan, ¤t] {
1103 assert!(!gone.exists(), "{} should be reclaimed", gone.display());
1104 }
1105 for kept in [
1106 &in_flight,
1107 &impostor,
1108 &foreign_sidecar,
1109 &snapshot_under_sidecar_name,
1110 &snapshot_under_staged_sidecar_name,
1111 ] {
1112 assert!(kept.exists(), "{} must survive", kept.display());
1113 }
1114 assert_eq!(clear_all_caches(cache.path()).expect("clear"), ClearSummary::default());
1116 }
1117
1118 #[test]
1119 fn a_sidecar_is_kept_while_a_snapshot_still_claims_it() {
1120 let tree = tempfile::tempdir().expect("tempdir");
1123 let cache = tempfile::tempdir().expect("cache");
1124 let current = cache.path().join(layout_name(1));
1125 seed(tree.path(), ¤t);
1126 let sidecar = crate::content::content_cache_path(¤t);
1127 std::fs::write(&sidecar, b"FDUCTNT\0payload").expect("write");
1128
1129 let listed = list_caches(cache.path()).expect("list");
1131 assert_eq!(listed.len(), 1);
1132 assert_eq!(listed[0].content_bytes(), Some(15));
1133 assert!(!clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
1134 assert!(sidecar.exists(), "its snapshot is still there");
1135
1136 assert!(remove_present(¤t).expect("remove"));
1138 assert!(clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
1139 assert!(!sidecar.exists());
1140 }
1141
1142 #[cfg(unix)]
1143 #[test]
1144 fn a_symbolic_link_named_like_a_leftover_is_never_followed_or_removed() {
1145 let tree = tempfile::tempdir().expect("tempdir");
1146 let cache = tempfile::tempdir().expect("cache");
1147 let outside = tempfile::tempdir().expect("outside");
1148 let target = outside.path().join(layout_name(9));
1149 seed(tree.path(), &target);
1150 let link = cache.path().join(staging_name(&layout_name(1)));
1153 std::os::unix::fs::symlink(&target, &link).expect("symlink");
1154
1155 let listed = list_caches(cache.path()).expect("list");
1156 assert_eq!(listed.len(), 1);
1157 assert_eq!(listed[0].state, CacheState::Unrecognized);
1158 assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
1159 assert!(!clear_leftover(&link, LeftoverKind::StagingTemporary).expect("clear"));
1160 assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
1161 assert!(target.exists(), "and so does its target");
1162 }
1163
1164 #[test]
1165 fn a_file_that_vanishes_before_removal_is_not_an_error() {
1166 let cache = tempfile::tempdir().expect("cache");
1169 let gone = cache.path().join(layout_name(1));
1170 assert!(!remove_present(&gone).expect("remove"));
1171 assert_eq!(status_at(&gone).expect("status"), None);
1172 }
1173
1174 #[test]
1175 fn clearing_is_idempotent_and_an_absent_directory_is_empty() {
1176 let cache = tempfile::tempdir().expect("cache");
1177 let missing = cache.path().join("does-not-exist");
1178 assert!(list_caches(&missing).expect("list").is_empty());
1179 assert!(clear_all_caches(&missing).expect("clear").is_empty());
1180 let absent = missing.join(layout_name(1));
1181 assert!(!clear_cache(&absent).expect("clear"));
1182 assert_eq!(cache_status(&absent).expect("status").state, CacheState::Absent);
1183 }
1184
1185 #[test]
1186 fn the_layout_names_what_default_cache_path_produces() {
1187 let tree = tempfile::tempdir().expect("tempdir");
1192 let path = crate::default_cache_path(tree.path())
1193 .expect("a user cache directory: set XDG_CACHE_HOME if this platform has none");
1194 assert_eq!(
1195 name_shape(path.file_name().expect("name")),
1196 NameShape::Snapshot,
1197 "{}",
1198 path.display()
1199 );
1200 }
1201
1202 #[test]
1203 fn a_name_is_shaped_like_one_of_fdus_files_or_like_nothing() {
1204 for (name, shape) in [
1207 ("0123456789abcdef.fdu", NameShape::Snapshot),
1208 ("0123456789abcdef.fdu.content", NameShape::Sidecar),
1209 (".0123456789abcdef.fdu.tmp.1.0011223344556677.9", NameShape::SnapshotTemporary),
1210 (".0123456789abcdef.fdu.content.tmp.1.0011223344556677.9", NameShape::SidecarTemporary),
1211 ("0123456789ABCDEF.fdu", NameShape::Other),
1214 (".0123456789abcdef.fdu.tmp.", NameShape::Other),
1215 (".notes.txt.tmp.1.0011223344556677.9", NameShape::Other),
1216 ("notes.txt.content", NameShape::Other),
1217 ("0123456789abcdef.fdu.content.content", NameShape::Other),
1218 (".fdu.tmp.1", NameShape::Other),
1219 ] {
1220 assert_eq!(name_shape(OsStr::new(name)), shape, "{name}");
1221 }
1222 }
1223
1224 #[test]
1225 fn labels_list_every_variant_in_order() {
1226 let states = [
1227 CacheState::Current(SnapshotInfo {
1228 root: PathBuf::new(),
1229 identity: crate::ScanConfig::default().snapshot_identity(),
1230 entries: 1,
1231 }),
1232 CacheState::Stale(StaleReason::Unreadable),
1233 CacheState::Leftover(LeftoverKind::StagingTemporary),
1234 CacheState::Unrecognized,
1235 CacheState::Absent,
1236 ];
1237 assert_eq!(states.iter().map(CacheState::label).collect::<Vec<_>>(), CacheState::LABELS);
1238 assert_eq!(
1239 [LeftoverKind::StagingTemporary, LeftoverKind::OrphanedContent]
1240 .map(LeftoverKind::label),
1241 LeftoverKind::LABELS
1242 );
1243 let reasons = [
1244 StaleReason::OlderFormat { version: 1 },
1245 StaleReason::NewerFormat { version: 9 },
1246 StaleReason::OtherEngine,
1247 StaleReason::Unreadable,
1248 ];
1249 assert_eq!(reasons.map(StaleReason::label), StaleReason::LABELS);
1250 assert_eq!([CacheScope::Root, CacheScope::All].map(CacheScope::label), CacheScope::LABELS);
1251 assert_eq!(
1252 CacheScope::LABELS.map(CacheScope::parse),
1253 [Some(CacheScope::Root), Some(CacheScope::All)]
1254 );
1255 }
1256}