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 = ".metadata.bin";
35
36const CONTENT_NAME_SUFFIX: &str = ".analysis.bin";
38
39#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct CachePaths {
42 pub metadata: PathBuf,
44 pub analysis: PathBuf,
46}
47
48impl CachePaths {
49 pub fn for_root_hash(directory: &Path, root_hash: u64) -> Self {
51 let key = format!("{root_hash:016x}");
52 Self {
53 metadata: directory.join(snapshot_file_name(root_hash)),
54 analysis: directory.join(format!("{key}{CONTENT_NAME_SUFFIX}")),
55 }
56 }
57
58 pub fn from_metadata(metadata: &Path) -> Self {
60 let analysis = metadata
61 .file_name()
62 .and_then(OsStr::to_str)
63 .and_then(|name| name.strip_suffix(SNAPSHOT_NAME_SUFFIX))
64 .map_or_else(
65 || {
66 let mut name = metadata.as_os_str().to_os_string();
69 name.push(".derived.bin");
73 PathBuf::from(name)
74 },
75 |stem| metadata.with_file_name(format!("{stem}{CONTENT_NAME_SUFFIX}")),
76 );
77 Self { metadata: metadata.to_path_buf(), analysis }
78 }
79
80 fn from_analysis(analysis: &Path) -> Self {
82 Self {
83 metadata: analysis.with_extension("").with_extension("metadata.bin"),
84 analysis: analysis.to_path_buf(),
85 }
86 }
87}
88
89const TEMP_NAME_INFIX: &str = ".tmp.";
92
93#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct CacheStatus {
96 pub path: PathBuf,
98 pub bytes: u64,
100 pub content: Option<ContentStatus>,
103 pub state: CacheState,
105}
106
107impl CacheStatus {
108 pub fn snapshot(&self) -> Option<&SnapshotInfo> {
110 match &self.state {
111 CacheState::Current(info) => Some(info),
112 CacheState::Stale(_)
113 | CacheState::Leftover(_)
114 | CacheState::Unrecognized
115 | CacheState::Absent => None,
116 }
117 }
118
119 pub fn is_fdu_snapshot(&self) -> bool {
122 matches!(self.state, CacheState::Current(_) | CacheState::Stale(_))
123 }
124
125 pub fn content_bytes(&self) -> Option<u64> {
127 self.content.as_ref().map(|content| content.bytes)
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct ContentStatus {
140 pub bytes: u64,
142 pub state: ContentState,
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
148pub enum ContentState {
149 Current(ContentInfo),
151 Stale(StaleReason),
154}
155
156impl ContentState {
157 pub const LABELS: [&'static str; 2] = ["current", "stale"];
163
164 pub fn label(&self) -> &'static str {
166 match self {
167 Self::Current(_) => "current",
168 Self::Stale(_) => "stale",
169 }
170 }
171}
172
173#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct ContentInfo {
176 pub identity: ContentTierIdentity,
178 pub records: u64,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq)]
184pub enum CacheState {
185 Current(SnapshotInfo),
187 Stale(StaleReason),
189 Leftover(LeftoverKind),
194 Unrecognized,
200 Absent,
203}
204
205impl CacheState {
206 pub const LABELS: [&'static str; 5] =
208 ["current", "stale", "leftover", "unrecognized", "absent"];
209
210 pub fn label(&self) -> &'static str {
212 match self {
213 Self::Current(_) => "current",
214 Self::Stale(_) => "stale",
215 Self::Leftover(_) => "leftover",
216 Self::Unrecognized => "unrecognized",
217 Self::Absent => "absent",
218 }
219 }
220}
221
222#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228pub enum LeftoverKind {
229 StagingTemporary,
234 OrphanedContent,
241}
242
243impl LeftoverKind {
244 pub const LABELS: [&'static str; 2] = ["staging_temporary", "orphaned_content"];
246
247 pub fn label(self) -> &'static str {
249 match self {
250 Self::StagingTemporary => "staging_temporary",
251 Self::OrphanedContent => "orphaned_content",
252 }
253 }
254}
255
256#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
262pub struct ClearSummary {
263 pub snapshots: usize,
265 pub leftovers: usize,
267}
268
269impl ClearSummary {
270 pub fn is_empty(self) -> bool {
272 self.snapshots == 0 && self.leftovers == 0
273 }
274}
275
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
278pub enum StaleReason {
279 OlderFormat {
281 version: u32,
283 },
284 NewerFormat {
286 version: u32,
288 },
289 OtherEngine,
292 Unreadable,
295}
296
297impl StaleReason {
298 pub const LABELS: [&'static str; 4] =
300 ["older_format", "newer_format", "other_engine", "unreadable"];
301
302 pub fn label(self) -> &'static str {
304 match self {
305 Self::OlderFormat { .. } => "older_format",
306 Self::NewerFormat { .. } => "newer_format",
307 Self::OtherEngine => "other_engine",
308 Self::Unreadable => "unreadable",
309 }
310 }
311
312 pub fn format_version(self) -> Option<u32> {
314 match self {
315 Self::OlderFormat { version } | Self::NewerFormat { version } => Some(version),
316 Self::OtherEngine | Self::Unreadable => None,
317 }
318 }
319}
320
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
323pub enum CacheScope {
324 Root,
326 All,
328}
329
330impl CacheScope {
331 pub const LABELS: [&'static str; 2] = ["root", "all"];
333
334 pub fn label(self) -> &'static str {
336 match self {
337 Self::Root => "root",
338 Self::All => "all",
339 }
340 }
341
342 pub fn parse(value: &str) -> Option<Self> {
344 match value.trim().to_ascii_lowercase().as_str() {
345 "root" => Some(Self::Root),
346 "all" => Some(Self::All),
347 _ => None,
348 }
349 }
350}
351
352#[derive(Clone, Debug, PartialEq, Eq)]
354pub struct SnapshotInfo {
355 pub root: PathBuf,
357 pub identity: SnapshotIdentity,
359 pub entries: u64,
361}
362
363impl SnapshotInfo {
364 pub fn scope(&self) -> ScanScope {
366 self.identity.scan_scope()
367 }
368}
369
370pub(crate) fn snapshot_file_name(root_hash: u64) -> String {
372 format!("{root_hash:0SNAPSHOT_NAME_HEX_DIGITS$x}{SNAPSHOT_NAME_SUFFIX}")
373}
374
375fn is_snapshot_name_bytes(bytes: &[u8]) -> bool {
381 bytes.len() == SNAPSHOT_NAME_HEX_DIGITS + SNAPSHOT_NAME_SUFFIX.len()
382 && bytes.ends_with(SNAPSHOT_NAME_SUFFIX.as_bytes())
383 && bytes[..SNAPSHOT_NAME_HEX_DIGITS]
384 .iter()
385 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
386}
387
388fn is_sidecar_name_bytes(bytes: &[u8]) -> bool {
390 bytes.len() == SNAPSHOT_NAME_HEX_DIGITS + CONTENT_NAME_SUFFIX.len()
391 && bytes.ends_with(CONTENT_NAME_SUFFIX.as_bytes())
392 && bytes[..SNAPSHOT_NAME_HEX_DIGITS]
393 .iter()
394 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
395}
396
397fn name_shape(name: &OsStr) -> NameShape {
403 let bytes = name.as_encoded_bytes();
404 if is_snapshot_name_bytes(bytes) {
405 return NameShape::Snapshot;
406 }
407 if is_sidecar_name_bytes(bytes) {
408 return NameShape::Sidecar;
409 }
410 let Some(rest) = bytes.strip_prefix(b".") else { return NameShape::Other };
413 let Some(at) =
414 rest.windows(TEMP_NAME_INFIX.len()).position(|w| w == TEMP_NAME_INFIX.as_bytes())
415 else {
416 return NameShape::Other;
417 };
418 let (target, suffix) = rest.split_at(at);
419 if suffix.len() <= TEMP_NAME_INFIX.len() {
420 return NameShape::Other;
422 }
423 if is_snapshot_name_bytes(target) {
424 NameShape::SnapshotTemporary
425 } else if is_sidecar_name_bytes(target) {
426 NameShape::SidecarTemporary
427 } else {
428 NameShape::Other
429 }
430}
431
432#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434enum NameShape {
435 Snapshot,
437 Sidecar,
439 SnapshotTemporary,
441 SidecarTemporary,
443 Other,
445}
446
447pub fn cache_status(path: &Path) -> Result<CacheStatus> {
458 Ok(status_at(path)?.unwrap_or_else(|| CacheStatus {
459 path: path.to_path_buf(),
460 bytes: 0,
461 content: None,
462 state: CacheState::Absent,
463 }))
464}
465
466fn status_at(path: &Path) -> Result<Option<CacheStatus>> {
472 let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
473 return Ok(None);
474 };
475 if !metadata.file_type().is_file() {
476 return Ok(Some(unrecognized(path, reportable_bytes(&metadata))));
477 }
478 let leftover = match path.file_name().map_or(NameShape::Other, name_shape) {
488 NameShape::SnapshotTemporary => Some(leftover_or_unrecognized(
489 path,
490 metadata.len(),
491 LeftoverKind::StagingTemporary,
492 is_snapshot_image(path)?,
493 )),
494 NameShape::SidecarTemporary => Some(leftover_or_unrecognized(
495 path,
496 metadata.len(),
497 LeftoverKind::StagingTemporary,
498 is_sidecar_image(path)?,
499 )),
500 NameShape::Sidecar => Some(leftover_or_unrecognized(
501 path,
502 metadata.len(),
503 LeftoverKind::OrphanedContent,
504 is_sidecar_image(path)?,
505 )),
506 NameShape::Snapshot | NameShape::Other => None,
507 };
508 if let Some(status) = leftover {
509 return Ok(Some(status));
510 }
511 let state = match snapshot::identify(path)? {
512 None => return Ok(None),
513 Some(Identity::Foreign) => return Ok(Some(unrecognized(path, metadata.len()))),
514 Some(Identity::Current(info)) => CacheState::Current(info),
515 Some(Identity::Stale(reason)) => CacheState::Stale(reason),
516 };
517 let content = crate::content::identify_sidecar(&crate::content::content_cache_path(path))?;
518 Ok(Some(CacheStatus { path: path.to_path_buf(), bytes: metadata.len(), content, state }))
519}
520
521fn is_snapshot_image(path: &Path) -> Result<bool> {
527 let Some(metadata) = present(fs::symlink_metadata(path), path)? else { return Ok(false) };
528 if !metadata.file_type().is_file() {
529 return Ok(false);
530 }
531 Ok(matches!(snapshot::identify(path)?, Some(Identity::Current(_) | Identity::Stale(_))))
532}
533
534fn is_sidecar_image(path: &Path) -> Result<bool> {
536 Ok(crate::content::content_sidecar_bytes(path)?.is_some())
537}
538
539fn sidecar_snapshot_path(path: &Path) -> PathBuf {
541 CachePaths::from_analysis(path).metadata
542}
543
544fn unrecognized(path: &Path, bytes: u64) -> CacheStatus {
545 CacheStatus { path: path.to_path_buf(), bytes, content: None, state: CacheState::Unrecognized }
546}
547
548fn leftover_or_unrecognized(
551 path: &Path,
552 bytes: u64,
553 kind: LeftoverKind,
554 has_magic: bool,
555) -> CacheStatus {
556 if has_magic {
557 CacheStatus {
558 path: path.to_path_buf(),
559 bytes,
560 content: None,
561 state: CacheState::Leftover(kind),
562 }
563 } else {
564 unrecognized(path, bytes)
565 }
566}
567
568fn reportable_bytes(metadata: &fs::Metadata) -> u64 {
576 if metadata.file_type().is_file() { metadata.len() } else { 0 }
577}
578
579fn present<T>(result: io::Result<T>, path: &Path) -> Result<Option<T>> {
584 match result {
585 Ok(value) => Ok(Some(value)),
586 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
587 Err(error) => Err(Error::io(path, error)),
588 }
589}
590
591pub fn list_caches(cache_dir: &Path) -> Result<Vec<CacheStatus>> {
602 let Some(entries) = present(fs::read_dir(cache_dir), cache_dir)? else {
603 return Ok(Vec::new());
605 };
606
607 let mut found = Vec::new();
608 for entry in entries {
609 let entry = entry.map_err(|error| Error::io(cache_dir, error))?;
610 let path = entry.path();
611 let Some(file_type) = present(entry.file_type(), &path)? else { continue };
613 let status = if file_type.is_file() && name_shape(&entry.file_name()) != NameShape::Other {
614 status_at(&path)?
615 } else {
616 present(fs::symlink_metadata(&path), &path)?
617 .map(|metadata| unrecognized(&path, reportable_bytes(&metadata)))
618 };
619 found.extend(status);
620 }
621 let paired_sidecars = found
622 .iter()
623 .filter(|status| status.is_fdu_snapshot() && status.content.is_some())
624 .map(|status| crate::content::content_cache_path(&status.path))
625 .collect::<std::collections::BTreeSet<_>>();
626 found.retain(|status| !paired_sidecars.contains(&status.path));
627 found.sort_by(|left, right| left.path.cmp(&right.path));
629 Ok(found)
630}
631
632pub fn clear_cache(path: &Path) -> Result<bool> {
638 let Some(status) = status_at(path)? else { return Ok(false) };
639 if !status.is_fdu_snapshot() {
640 return Ok(false);
643 }
644 if !remove_present(path)? {
647 return Ok(false);
648 }
649 let content_path = crate::content::content_cache_path(path);
650 if crate::content::content_sidecar_bytes(&content_path)?.is_some() {
651 remove_present(&content_path)?;
652 }
653 Ok(true)
654}
655
656fn remove_present(path: &Path) -> Result<bool> {
658 Ok(present(fs::remove_file(path), path)?.is_some())
659}
660
661pub fn clear_all_caches(cache_dir: &Path) -> Result<ClearSummary> {
670 let listed = list_caches(cache_dir)?;
671 let mut summary = ClearSummary::default();
672 for status in &listed {
673 if status.is_fdu_snapshot() && clear_cache(&status.path)? {
674 summary.snapshots += 1;
675 }
676 }
677 for status in &listed {
678 if let CacheState::Leftover(kind) = status.state {
679 if clear_leftover(&status.path, kind)? {
680 summary.leftovers += 1;
681 }
682 }
683 }
684 Ok(summary)
685}
686
687fn clear_leftover(path: &Path, kind: LeftoverKind) -> Result<bool> {
694 let Some(status) = status_at(path)? else { return Ok(false) };
695 if status.state != CacheState::Leftover(kind) {
696 return Ok(false);
697 }
698 let removable = match kind {
699 LeftoverKind::StagingTemporary => {
703 let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
704 return Ok(false);
705 };
706 let Ok(modified) = metadata.modified() else { return Ok(false) };
707 std::time::SystemTime::now()
708 .duration_since(modified)
709 .is_ok_and(|age| age >= snapshot::STALE_TEMP_AGE)
710 }
711 LeftoverKind::OrphanedContent => !is_snapshot_image(&sidecar_snapshot_path(path))?,
717 };
718 if !removable {
719 return Ok(false);
720 }
721 remove_present(path)
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use crate::{CachePolicy, OpenFixture, open_fixture as open};
728
729 const VERSION_OFFSET: usize = 8;
731
732 const FINGERPRINT_OFFSET: usize = VERSION_OFFSET + 4;
734
735 fn layout_name(seed: u64) -> String {
737 snapshot_file_name(seed)
738 }
739
740 fn analysis_name(seed: u64) -> String {
741 CachePaths::for_root_hash(Path::new("."), seed)
742 .analysis
743 .file_name()
744 .expect("analysis name")
745 .to_string_lossy()
746 .into_owned()
747 }
748
749 fn seed(tree: &Path, snapshot_path: &Path) {
750 std::fs::write(tree.join("a.txt"), b"hello").expect("write");
751 let config = OpenFixture {
752 cache_path: Some(snapshot_path.to_path_buf()),
753 policy: CachePolicy::Auto,
754 ..OpenFixture::default()
755 };
756 open(tree, &config).expect("seed");
757 }
758
759 fn with_format_version(from: &Path, to: &Path, version: impl FnOnce(u32) -> u32) {
761 let mut bytes = std::fs::read(from).expect("read snapshot");
762 let range = VERSION_OFFSET..FINGERPRINT_OFFSET;
763 let current = u32::from_le_bytes(bytes[range.clone()].try_into().expect("four bytes"));
764 bytes[range].copy_from_slice(&version(current).to_le_bytes());
765 std::fs::write(to, bytes).expect("write stale snapshot");
766 }
767
768 #[test]
769 fn a_snapshot_reports_the_root_it_describes() {
770 let tree = tempfile::tempdir().expect("tempdir");
773 let cache = tempfile::tempdir().expect("cache");
774 let path = cache.path().join(layout_name(1));
775 seed(tree.path(), &path);
776
777 let status = cache_status(&path).expect("status");
778 assert!(status.bytes > 0);
779 let info = status.snapshot().expect("header");
780 assert_eq!(info.root, tree.path().canonicalize().expect("canonical"));
781 assert_eq!(info.entries, 2, "the root plus one file");
782 }
783
784 #[test]
785 fn content_sidecar_is_reported_and_cleared_with_its_snapshot() {
786 let tree = tempfile::tempdir().expect("tempdir");
787 let cache = tempfile::tempdir().expect("cache");
788 let path = cache.path().join(layout_name(1));
789 std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
790 let config = OpenFixture {
791 cache_path: Some(path.clone()),
792 policy: CachePolicy::Auto,
793 analysis: crate::content::AnalysisRequest {
794 profile: crate::content::AnalysisSet::NONE.with_lines(),
795 ..crate::content::AnalysisRequest::default()
796 },
797 ..OpenFixture::default()
798 };
799 let (index, _) = open(tree.path(), &config).expect("seed analyzed cache");
800
801 let listed = list_caches(cache.path()).expect("list");
802 assert_eq!(listed.len(), 1, "a sidecar is grouped with its snapshot");
803 let Some(CacheState::Current(snapshot)) = listed.first().map(|status| &status.state) else {
804 panic!("a current snapshot: {listed:?}");
805 };
806 assert_eq!(snapshot.identity, index.snapshot_identity());
807 let content = listed[0].content.as_ref().expect("its sidecar");
808 assert_eq!(
809 content.state,
810 ContentState::Current(ContentInfo {
811 identity: index.content_identity(config.analysis.profile),
812 records: 1,
813 }),
814 "status reports the identity the sidecar serves"
815 );
816 assert!(clear_cache(&path).expect("clear"));
817 assert!(!path.exists());
818 assert!(!crate::content::content_cache_path(&path).exists());
819 }
820
821 #[test]
822 fn stale_snapshots_are_reported_beside_a_current_one_and_all_are_cleared() {
823 let tree = tempfile::tempdir().expect("tempdir");
827 let cache = tempfile::tempdir().expect("cache");
828 let current = cache.path().join(layout_name(1));
829 seed(tree.path(), ¤t);
830
831 let older = cache.path().join(layout_name(2));
832 with_format_version(¤t, &older, |version| version - 1);
833 let newer = cache.path().join(layout_name(3));
834 with_format_version(¤t, &newer, |version| version + 1);
835 let other_engine = cache.path().join(layout_name(4));
836 let mut bytes = std::fs::read(¤t).expect("read");
837 for byte in &mut bytes[FINGERPRINT_OFFSET..FINGERPRINT_OFFSET + 8] {
838 *byte = !*byte;
839 }
840 std::fs::write(&other_engine, &bytes).expect("write");
841 let truncated = cache.path().join(layout_name(5));
842 let full = std::fs::read(¤t).expect("read");
843 std::fs::write(&truncated, &full[..full.len() - 1]).expect("truncate");
844 let hand_built = cache.path().join(layout_name(6));
847 let mut prologue = b"FDUSNAP\x00".to_vec();
848 prologue.extend_from_slice(&1_u32.to_le_bytes());
849 std::fs::write(&hand_built, &prologue).expect("write");
850
851 let current_version = u32::from_le_bytes(
852 full[VERSION_OFFSET..FINGERPRINT_OFFSET].try_into().expect("four bytes"),
853 );
854 let states = list_caches(cache.path())
855 .expect("list")
856 .into_iter()
857 .map(|status| (status.path, status.state))
858 .collect::<Vec<_>>();
859 assert_eq!(states.len(), 6);
860 assert!(matches!(states[0].1, CacheState::Current(_)));
861 assert_eq!(
862 states[1..],
863 [
864 (
865 older,
866 CacheState::Stale(StaleReason::OlderFormat { version: current_version - 1 })
867 ),
868 (
869 newer,
870 CacheState::Stale(StaleReason::NewerFormat { version: current_version + 1 })
871 ),
872 (other_engine, CacheState::Stale(StaleReason::OtherEngine)),
873 (truncated.clone(), CacheState::Stale(StaleReason::Unreadable)),
874 (hand_built, CacheState::Stale(StaleReason::OlderFormat { version: 1 })),
875 ]
876 );
877
878 assert!(clear_cache(&truncated).expect("clear one"), "a truncated snapshot is still fdu's");
879 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 5);
880 assert!(list_caches(cache.path()).expect("list").is_empty());
881 }
882
883 #[test]
884 fn a_stale_snapshot_takes_its_content_sidecar_with_it() {
885 let tree = tempfile::tempdir().expect("tempdir");
886 let cache = tempfile::tempdir().expect("cache");
887 let path = cache.path().join(layout_name(1));
888 std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
889 let config = OpenFixture {
890 cache_path: Some(path.clone()),
891 policy: CachePolicy::Auto,
892 analysis: crate::content::AnalysisRequest {
893 profile: crate::content::AnalysisSet::NONE.with_lines(),
894 ..crate::content::AnalysisRequest::default()
895 },
896 ..OpenFixture::default()
897 };
898 open(tree.path(), &config).expect("seed analyzed cache");
899 with_format_version(&path, &path, |version| version - 1);
900
901 let listed = list_caches(cache.path()).expect("list");
902 assert_eq!(listed.len(), 1, "a stale snapshot's sidecar is grouped with it");
903 assert!(matches!(listed[0].state, CacheState::Stale(StaleReason::OlderFormat { .. })));
904 assert!(listed[0].content.is_some());
905 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
906 assert!(!crate::content::content_cache_path(&path).exists());
907 }
908
909 #[test]
913 fn a_stale_sidecar_beside_a_current_snapshot_is_labelled_and_cleared() {
914 let tree = tempfile::tempdir().expect("tempdir");
915 let cache = tempfile::tempdir().expect("cache");
916 let path = cache.path().join(layout_name(1));
917 std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
918 let config = OpenFixture {
919 cache_path: Some(path.clone()),
920 policy: CachePolicy::Auto,
921 analysis: crate::content::AnalysisRequest {
922 profile: crate::content::AnalysisSet::NONE.with_lines(),
923 ..crate::content::AnalysisRequest::default()
924 },
925 ..OpenFixture::default()
926 };
927 open(tree.path(), &config).expect("seed analyzed cache");
928 let sidecar = crate::content::content_cache_path(&path);
929 let written = std::fs::read(&sidecar).expect("a sidecar");
930
931 let mut older = written.clone();
932 older[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
933 let mut newer = written.clone();
934 newer[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&99_u32.to_le_bytes());
935 let mut other_engine = written.clone();
936 other_engine[FINGERPRINT_OFFSET] ^= 0xff;
937 let truncated = written[..written.len() - 1].to_vec();
938 let mut unreadable_header = written.clone();
939 let path_encoding_at = FINGERPRINT_OFFSET + 8;
940 unreadable_header[path_encoding_at] ^= 0xff;
941 for (image, reason) in [
942 (older, StaleReason::OlderFormat { version: 4 }),
943 (newer, StaleReason::NewerFormat { version: 99 }),
944 (other_engine, StaleReason::OtherEngine),
945 (truncated, StaleReason::Unreadable),
946 (unreadable_header, StaleReason::Unreadable),
947 ] {
948 std::fs::write(&sidecar, &image).expect("rewrite the sidecar");
949 let listed = list_caches(cache.path()).expect("list");
950 assert_eq!(listed.len(), 1, "a stale sidecar is still grouped with its snapshot");
951 assert!(matches!(listed[0].state, CacheState::Current(_)), "{listed:?}");
952 let content = listed[0].content.as_ref().expect("its sidecar");
953 assert_eq!(content.bytes, u64::try_from(image.len()).expect("small"));
954 assert_eq!(content.state, ContentState::Stale(reason), "{reason:?}");
955 assert_eq!(cache_status(&path).expect("status").content, listed[0].content);
956 }
957
958 assert!(clear_cache(&path).expect("clear"));
959 assert!(!path.exists());
960 assert!(!sidecar.exists(), "the stale sidecar goes with its snapshot");
961 }
962
963 #[test]
964 fn unrecognized_files_are_listed_but_never_removed() {
965 let tree = tempfile::tempdir().expect("tempdir");
966 let cache = tempfile::tempdir().expect("cache");
967 let path = cache.path().join(layout_name(1));
968 seed(tree.path(), &path);
969 let foreign = cache.path().join("notes.txt");
970 std::fs::write(&foreign, b"not a snapshot").expect("write");
971 let impostor = cache.path().join(layout_name(2));
973 std::fs::write(&impostor, b"FDUSNAQ and more bytes").expect("write");
974 let renamed = cache.path().join("backup.bin");
977 std::fs::copy(&path, &renamed).expect("copy");
978
979 let listed = list_caches(cache.path()).expect("list");
980 assert_eq!(
981 listed
982 .iter()
983 .map(|status| (status.path.clone(), status.state.label()))
984 .collect::<Vec<_>>(),
985 [
986 (path.clone(), "current"),
987 (impostor.clone(), "unrecognized"),
988 (renamed.clone(), "unrecognized"),
989 (foreign.clone(), "unrecognized"),
990 ]
991 );
992 assert_eq!(listed[3].bytes, 14, "an unrecognized file still reports its size");
993
994 assert!(!clear_cache(&impostor).expect("clear"));
995 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
996 for survivor in [&foreign, &impostor, &renamed] {
997 assert!(survivor.exists(), "{} must survive", survivor.display());
998 }
999 assert_eq!(list_caches(cache.path()).expect("list").len(), 3);
1000 }
1001
1002 #[cfg(unix)]
1003 #[test]
1004 fn a_symbolic_link_in_the_cache_is_never_followed_or_removed() {
1005 let tree = tempfile::tempdir().expect("tempdir");
1008 let cache = tempfile::tempdir().expect("cache");
1009 let outside = tempfile::tempdir().expect("outside");
1010 let target = outside.path().join(layout_name(9));
1011 seed(tree.path(), &target);
1012 let link = cache.path().join(layout_name(1));
1013 std::os::unix::fs::symlink(&target, &link).expect("symlink");
1014
1015 let listed = list_caches(cache.path()).expect("list");
1016 assert_eq!(listed.len(), 1);
1017 assert_eq!(listed[0].state, CacheState::Unrecognized);
1018 assert_eq!(listed[0].bytes, 0, "a link has no size to report");
1021 assert_eq!(cache_status(&link).expect("status").state, CacheState::Unrecognized);
1022
1023 assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
1024 assert!(!clear_cache(&link).expect("clear"));
1025 assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
1026 assert!(
1027 cache_status(&target).expect("status").snapshot().is_some(),
1028 "and so does its target"
1029 );
1030 }
1031
1032 #[test]
1033 fn a_directory_in_the_cache_is_listed_and_never_removed() {
1034 let tree = tempfile::tempdir().expect("tempdir");
1038 let cache = tempfile::tempdir().expect("cache");
1039 let snapshot = cache.path().join(layout_name(1));
1040 seed(tree.path(), &snapshot);
1041 let nested = cache.path().join(layout_name(2));
1043 std::fs::create_dir(&nested).expect("create dir");
1044
1045 let listed = list_caches(cache.path()).expect("list");
1046 assert_eq!(
1047 listed
1048 .iter()
1049 .map(|status| (status.path.clone(), status.state.label()))
1050 .collect::<Vec<_>>(),
1051 [(snapshot, "current"), (nested.clone(), "unrecognized")]
1052 );
1053 assert_eq!(listed[1].bytes, 0, "a directory has no size to report");
1057 let single = cache_status(&nested).expect("status");
1058 assert_eq!(single.state, CacheState::Unrecognized);
1059 assert_eq!(single.bytes, 0, "and the single-path route agrees");
1060
1061 assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
1062 assert!(!clear_cache(&nested).expect("clear"));
1063 assert!(nested.is_dir(), "a directory is never removed");
1064 }
1065
1066 fn set_modified(path: &Path, at: std::time::SystemTime) {
1069 std::fs::OpenOptions::new()
1070 .write(true)
1071 .open(path)
1072 .expect("open")
1073 .set_modified(at)
1074 .expect("set modified");
1075 }
1076
1077 fn beyond_the_reaper() -> std::time::SystemTime {
1079 std::time::SystemTime::now() - snapshot::STALE_TEMP_AGE - std::time::Duration::from_secs(1)
1080 }
1081
1082 fn staging_name(target: &str) -> String {
1084 format!(".{target}.tmp.7.0123456789abcdef.0")
1085 }
1086
1087 fn describe(status: &CacheStatus) -> String {
1089 match &status.state {
1090 CacheState::Leftover(kind) => format!("leftover/{}", kind.label()),
1091 other => other.label().to_string(),
1092 }
1093 }
1094
1095 #[test]
1096 fn fdus_own_leftovers_are_named_as_fdus_and_reclaimed_only_under_their_rules() {
1097 let tree = tempfile::tempdir().expect("tempdir");
1101 let cache = tempfile::tempdir().expect("cache");
1102 let current = cache.path().join(layout_name(1));
1103 seed(tree.path(), ¤t);
1104 let image = std::fs::read(¤t).expect("read snapshot");
1105
1106 let abandoned = cache.path().join(staging_name(&layout_name(2)));
1109 std::fs::write(&abandoned, &image).expect("write");
1110 set_modified(&abandoned, beyond_the_reaper());
1111 let in_flight = cache.path().join(staging_name(&layout_name(3)));
1113 std::fs::write(&in_flight, &image).expect("write");
1114 let impostor = cache.path().join(staging_name(&layout_name(4)));
1116 std::fs::write(&impostor, b"not a snapshot").expect("write");
1117 set_modified(&impostor, beyond_the_reaper());
1118 let staged_sidecar = cache.path().join(staging_name(&analysis_name(5)));
1120 std::fs::write(&staged_sidecar, b"FDUCTNT\0payload").expect("write");
1121 set_modified(&staged_sidecar, beyond_the_reaper());
1122 let orphan = cache.path().join(analysis_name(6));
1123 std::fs::write(&orphan, b"FDUCTNT\0payload").expect("write");
1124 let foreign_sidecar = cache.path().join(analysis_name(7));
1126 std::fs::write(&foreign_sidecar, b"not a sidecar").expect("write");
1127 let snapshot_under_sidecar_name = cache.path().join(analysis_name(8));
1132 std::fs::write(&snapshot_under_sidecar_name, &image).expect("write");
1133 let snapshot_under_staged_sidecar_name = cache.path().join(staging_name(&analysis_name(9)));
1134 std::fs::write(&snapshot_under_staged_sidecar_name, &image).expect("write");
1135 set_modified(&snapshot_under_staged_sidecar_name, beyond_the_reaper());
1136
1137 let listed = list_caches(cache.path()).expect("list");
1138 assert_eq!(
1139 listed.iter().map(|status| (status.path.clone(), describe(status))).collect::<Vec<_>>(),
1140 [
1141 (abandoned.clone(), "leftover/staging_temporary".to_string()),
1142 (in_flight.clone(), "leftover/staging_temporary".to_string()),
1143 (impostor.clone(), "unrecognized".to_string()),
1144 (staged_sidecar.clone(), "leftover/staging_temporary".to_string()),
1145 (snapshot_under_staged_sidecar_name.clone(), "unrecognized".to_string()),
1146 (current.clone(), "current".to_string()),
1147 (orphan.clone(), "leftover/orphaned_content".to_string()),
1148 (foreign_sidecar.clone(), "unrecognized".to_string()),
1149 (snapshot_under_sidecar_name.clone(), "unrecognized".to_string()),
1150 ]
1151 );
1152 for named in [&snapshot_under_sidecar_name, &snapshot_under_staged_sidecar_name] {
1155 assert_eq!(cache_status(named).expect("status").state, CacheState::Unrecognized);
1156 }
1157
1158 let summary = clear_all_caches(cache.path()).expect("clear");
1159 assert_eq!(summary, ClearSummary { snapshots: 1, leftovers: 3 });
1160 for gone in [&abandoned, &staged_sidecar, &orphan, ¤t] {
1161 assert!(!gone.exists(), "{} should be reclaimed", gone.display());
1162 }
1163 for kept in [
1164 &in_flight,
1165 &impostor,
1166 &foreign_sidecar,
1167 &snapshot_under_sidecar_name,
1168 &snapshot_under_staged_sidecar_name,
1169 ] {
1170 assert!(kept.exists(), "{} must survive", kept.display());
1171 }
1172 assert_eq!(clear_all_caches(cache.path()).expect("clear"), ClearSummary::default());
1174 }
1175
1176 #[test]
1177 fn a_sidecar_is_kept_while_a_snapshot_still_claims_it() {
1178 let tree = tempfile::tempdir().expect("tempdir");
1181 let cache = tempfile::tempdir().expect("cache");
1182 let current = cache.path().join(layout_name(1));
1183 seed(tree.path(), ¤t);
1184 let sidecar = crate::content::content_cache_path(¤t);
1185 std::fs::write(&sidecar, b"FDUCTNT\0payload").expect("write");
1186
1187 let listed = list_caches(cache.path()).expect("list");
1189 assert_eq!(listed.len(), 1);
1190 assert_eq!(listed[0].content_bytes(), Some(15));
1191 assert!(!clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
1192 assert!(sidecar.exists(), "its snapshot is still there");
1193
1194 assert!(remove_present(¤t).expect("remove"));
1196 assert!(clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
1197 assert!(!sidecar.exists());
1198 }
1199
1200 #[cfg(unix)]
1201 #[test]
1202 fn a_symbolic_link_named_like_a_leftover_is_never_followed_or_removed() {
1203 let tree = tempfile::tempdir().expect("tempdir");
1204 let cache = tempfile::tempdir().expect("cache");
1205 let outside = tempfile::tempdir().expect("outside");
1206 let target = outside.path().join(layout_name(9));
1207 seed(tree.path(), &target);
1208 let link = cache.path().join(staging_name(&layout_name(1)));
1211 std::os::unix::fs::symlink(&target, &link).expect("symlink");
1212
1213 let listed = list_caches(cache.path()).expect("list");
1214 assert_eq!(listed.len(), 1);
1215 assert_eq!(listed[0].state, CacheState::Unrecognized);
1216 assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
1217 assert!(!clear_leftover(&link, LeftoverKind::StagingTemporary).expect("clear"));
1218 assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
1219 assert!(target.exists(), "and so does its target");
1220 }
1221
1222 #[test]
1223 fn a_file_that_vanishes_before_removal_is_not_an_error() {
1224 let cache = tempfile::tempdir().expect("cache");
1227 let gone = cache.path().join(layout_name(1));
1228 assert!(!remove_present(&gone).expect("remove"));
1229 assert_eq!(status_at(&gone).expect("status"), None);
1230 }
1231
1232 #[test]
1233 fn clearing_is_idempotent_and_an_absent_directory_is_empty() {
1234 let cache = tempfile::tempdir().expect("cache");
1235 let missing = cache.path().join("does-not-exist");
1236 assert!(list_caches(&missing).expect("list").is_empty());
1237 assert!(clear_all_caches(&missing).expect("clear").is_empty());
1238 let absent = missing.join(layout_name(1));
1239 assert!(!clear_cache(&absent).expect("clear"));
1240 assert_eq!(cache_status(&absent).expect("status").state, CacheState::Absent);
1241 }
1242
1243 #[test]
1244 fn the_layout_names_what_default_cache_path_produces() {
1245 let tree = tempfile::tempdir().expect("tempdir");
1250 let path = crate::default_cache_path(tree.path())
1251 .expect("a user cache directory: set XDG_CACHE_HOME if this platform has none");
1252 assert_eq!(
1253 name_shape(path.file_name().expect("name")),
1254 NameShape::Snapshot,
1255 "{}",
1256 path.display()
1257 );
1258 }
1259
1260 #[test]
1261 fn explicit_metadata_names_keep_distinct_analysis_siblings() {
1262 let first = CachePaths::from_metadata(Path::new("cache/root.a.snap"));
1263 let second = CachePaths::from_metadata(Path::new("cache/root.b.snap"));
1264 assert_eq!(first.analysis, Path::new("cache/root.a.snap.derived.bin"));
1265 assert_eq!(second.analysis, Path::new("cache/root.b.snap.derived.bin"));
1266 assert_ne!(first.analysis, second.analysis);
1267 let conventional =
1268 CachePaths::from_metadata(Path::new("cache/0123456789abcdef.metadata.bin"));
1269 assert_eq!(conventional.analysis, Path::new("cache/0123456789abcdef.analysis.bin"));
1270 let arbitrary = CachePaths::from_metadata(Path::new("cache/foo"));
1271 let conventional = CachePaths::from_metadata(Path::new("cache/foo.metadata.bin"));
1272 assert_ne!(arbitrary.analysis, conventional.analysis);
1273 }
1274
1275 #[test]
1276 fn a_name_is_shaped_like_one_of_fdus_files_or_like_nothing() {
1277 for (name, shape) in [
1280 ("0123456789abcdef.metadata.bin", NameShape::Snapshot),
1281 ("0123456789abcdef.analysis.bin", NameShape::Sidecar),
1282 (
1283 ".0123456789abcdef.metadata.bin.tmp.1.0011223344556677.9",
1284 NameShape::SnapshotTemporary,
1285 ),
1286 (
1287 ".0123456789abcdef.analysis.bin.tmp.1.0011223344556677.9",
1288 NameShape::SidecarTemporary,
1289 ),
1290 ("0123456789ABCDEF.metadata.bin", NameShape::Other),
1293 (".0123456789abcdef.metadata.bin.tmp.", NameShape::Other),
1294 (".notes.txt.tmp.1.0011223344556677.9", NameShape::Other),
1295 ("notes.txt.analysis.bin", NameShape::Other),
1296 ("0123456789abcdef.analysis.bin.analysis.bin", NameShape::Other),
1297 (".metadata.bin.tmp.1", NameShape::Other),
1298 ] {
1299 assert_eq!(name_shape(OsStr::new(name)), shape, "{name}");
1300 }
1301 }
1302
1303 #[test]
1304 fn labels_list_every_variant_in_order() {
1305 let states = [
1306 CacheState::Current(SnapshotInfo {
1307 root: PathBuf::new(),
1308 identity: crate::ScanConfig::default().snapshot_identity(),
1309 entries: 1,
1310 }),
1311 CacheState::Stale(StaleReason::Unreadable),
1312 CacheState::Leftover(LeftoverKind::StagingTemporary),
1313 CacheState::Unrecognized,
1314 CacheState::Absent,
1315 ];
1316 assert_eq!(states.iter().map(CacheState::label).collect::<Vec<_>>(), CacheState::LABELS);
1317 assert_eq!(
1318 [LeftoverKind::StagingTemporary, LeftoverKind::OrphanedContent]
1319 .map(LeftoverKind::label),
1320 LeftoverKind::LABELS
1321 );
1322 let reasons = [
1323 StaleReason::OlderFormat { version: 1 },
1324 StaleReason::NewerFormat { version: 9 },
1325 StaleReason::OtherEngine,
1326 StaleReason::Unreadable,
1327 ];
1328 assert_eq!(reasons.map(StaleReason::label), StaleReason::LABELS);
1329 assert_eq!([CacheScope::Root, CacheScope::All].map(CacheScope::label), CacheScope::LABELS);
1330 assert_eq!(
1331 CacheScope::LABELS.map(CacheScope::parse),
1332 [Some(CacheScope::Root), Some(CacheScope::All)]
1333 );
1334 }
1335}