Skip to main content

fdu_core/
cache.rs

1//! Inspecting and clearing the snapshot cache.
2//!
3//! Snapshot files are named by a hash of their root, which keeps two trees from
4//! colliding but leaves a directory of opaque names with no way to answer "which tree is
5//! this?". These functions read each file's bounded header to recover that mapping, so
6//! the cache can be inspected and cleared without guesswork.
7//!
8//! A file is one of fdu's snapshots only when its contents begin with the snapshot magic;
9//! a name alone proves nothing. A snapshot this build cannot serve is still fdu's: an
10//! older or newer format, another engine fingerprint, or a header this build cannot read
11//! is reported as stale and cleared like a current one. The engine fingerprint mixes in
12//! the crate version, so without that every release would strand every snapshot the one
13//! before it wrote.
14//!
15//! Two more kinds of file here are fdu's without being a snapshot in place: a staging file
16//! a killed writer never renamed, and a content sidecar whose snapshot is gone. Both are
17//! reported as leftovers, and clearing the directory reclaims them under rules that keep a
18//! clear from racing a live writer or discarding a sidecar a snapshot still wants.
19//! Anything else is reported as unrecognized and never deleted.
20
21use 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
30/// Hex digits in a snapshot's file name, one per nibble of the 64-bit root hash.
31const SNAPSHOT_NAME_HEX_DIGITS: usize = 16;
32
33/// The suffix every snapshot name in the cache directory ends with.
34const SNAPSHOT_NAME_SUFFIX: &str = ".metadata.bin";
35
36/// Suffix of the derived analysis sibling.
37const CONTENT_NAME_SUFFIX: &str = ".analysis.bin";
38
39/// The two files for one canonical root key in an application cache directory.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct CachePaths {
42    /// Filesystem entries and metadata.
43    pub metadata: PathBuf,
44    /// Derived analyzer results.
45    pub analysis: PathBuf,
46}
47
48impl CachePaths {
49    /// Construct the conventional sibling paths for a canonical root hash.
50    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    /// Derive the sibling of an explicit metadata path.
59    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                    // An arbitrary explicit name owns its full spelling. Appending the
67                    // role suffix keeps distinct metadata paths from sharing a sidecar.
68                    let mut name = metadata.as_os_str().to_os_string();
69                    // Keep arbitrary explicit paths in a disjoint namespace from
70                    // conventional metadata names, including `foo` versus
71                    // `foo.metadata.bin`.
72                    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    /// Derive the metadata sibling of an analysis path.
81    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
89/// What a staging name inserts between its target's name and the discriminator, per
90/// `snapshot::temp_name`.
91const TEMP_NAME_INFIX: &str = ".tmp.";
92
93/// What is known about one file in the cache directory.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct CacheStatus {
96    /// Where the file lives.
97    pub path: PathBuf,
98    /// Size on disk, in bytes.
99    pub bytes: u64,
100    /// The content sidecar fdu wrote beside this snapshot, current or stale, when there is
101    /// one.
102    pub content: Option<ContentStatus>,
103    /// What the file is, as far as this build can tell.
104    pub state: CacheState,
105}
106
107impl CacheStatus {
108    /// The header of a snapshot this build can serve.
109    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    /// Whether this file is one of fdu's snapshots, current or stale, which is exactly
120    /// what clearing removes.
121    pub fn is_fdu_snapshot(&self) -> bool {
122        matches!(self.state, CacheState::Current(_) | CacheState::Stale(_))
123    }
124
125    /// Size of the content sidecar beside this snapshot, current or stale.
126    pub fn content_bytes(&self) -> Option<u64> {
127        self.content.as_ref().map(|content| content.bytes)
128    }
129}
130
131/// What is known about the content sidecar beside a snapshot.
132///
133/// Whether a sidecar is there is decided by its magic, as for a snapshot, so a sidecar this
134/// build cannot serve is still reported and cleared with its snapshot. Whether it is
135/// current is decided by its own header: its format version and engine fingerprint.
136/// Whether it pairs with the snapshot beside it is a question of identity equality, which
137/// loading asks and status reports by carrying both identities.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct ContentStatus {
140    /// Size on disk, in bytes.
141    pub bytes: u64,
142    /// What the sidecar is, as far as this build can tell.
143    pub state: ContentState,
144}
145
146/// What a content sidecar beside a snapshot holds.
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub enum ContentState {
149    /// A sidecar this build reads.
150    Current(ContentInfo),
151    /// One of fdu's sidecars that this build cannot serve: another format or engine, or a
152    /// header this build cannot read.
153    Stale(StaleReason),
154}
155
156impl ContentState {
157    /// Every label a content sidecar's state can carry, in declaration order.
158    ///
159    /// The two a sidecar can be, which is narrower than [`CacheState::LABELS`]: a sidecar
160    /// is never leftover, unrecognized, or absent under its own status, because it is
161    /// reported only where one was found beside a snapshot.
162    pub const LABELS: [&'static str; 2] = ["current", "stale"];
163
164    /// The label machine output carries, one of [`Self::LABELS`].
165    pub fn label(&self) -> &'static str {
166        match self {
167            Self::Current(_) => "current",
168            Self::Stale(_) => "stale",
169        }
170    }
171}
172
173/// The header facts that identify a content sidecar.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct ContentInfo {
176    /// The identity of the content tier it holds.
177    pub identity: ContentTierIdentity,
178    /// How many file records it holds.
179    pub records: u64,
180}
181
182/// What a path in the cache holds.
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub enum CacheState {
185    /// A snapshot this build reads and can serve.
186    Current(SnapshotInfo),
187    /// One of fdu's snapshots that this build cannot serve. Clearing removes it.
188    Stale(StaleReason),
189    /// A file fdu wrote that is not a snapshot in place: a staging file a killed writer
190    /// never renamed, or a content sidecar whose snapshot is gone.
191    ///
192    /// Clearing the whole directory reclaims it, under the rules on [`LeftoverKind`].
193    Leftover(LeftoverKind),
194    /// A file fdu cannot identify as one of its own, which clearing never removes.
195    ///
196    /// Its contents lack the magic its name implies, or it is not a regular file (a
197    /// symbolic link is never followed, and a directory is never descended into), or it
198    /// was found by listing a directory under a name the cache gives nothing.
199    Unrecognized,
200    /// Nothing is there. Only a status for one path can be absent; a listing reports the
201    /// files it found.
202    Absent,
203}
204
205impl CacheState {
206    /// Every label [`CacheState::label`] returns, in declaration order.
207    pub const LABELS: [&'static str; 5] =
208        ["current", "stale", "leftover", "unrecognized", "absent"];
209
210    /// The label machine output carries.
211    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/// Which of fdu's own files a leftover is.
223///
224/// Both are named by fdu and carry one of fdu's magics, and both are reclaimed only by
225/// clearing the whole directory: a root's clear reaches the one path that root's snapshot
226/// occupies, and neither of these is at that path.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228pub enum LeftoverKind {
229    /// The staging file a killed writer left beside its target, never renamed into place.
230    ///
231    /// Removed only once it is older than the age at which a later writer would reap it,
232    /// so clearing can never take a file a running writer still holds.
233    StagingTemporary,
234    /// A content sidecar whose snapshot is gone.
235    ///
236    /// Removed only when no snapshot for it exists after the clear, so a sidecar a later
237    /// analyzed scan could still reuse stays with the snapshot it belongs to. Orphaned is
238    /// what a listing shows: a sidecar whose snapshot is present is grouped with that
239    /// snapshot and never listed on its own.
240    OrphanedContent,
241}
242
243impl LeftoverKind {
244    /// Every label [`LeftoverKind::label`] returns, in declaration order.
245    pub const LABELS: [&'static str; 2] = ["staging_temporary", "orphaned_content"];
246
247    /// The label machine output carries.
248    pub fn label(self) -> &'static str {
249        match self {
250            Self::StagingTemporary => "staging_temporary",
251            Self::OrphanedContent => "orphaned_content",
252        }
253    }
254}
255
256/// What one clear removed.
257///
258/// Two counts rather than one total: "cleared 4 snapshots" and "reclaimed 2 files fdu left
259/// behind" are different facts, and a destructive command that reports one number for both
260/// would understate what it did.
261#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
262pub struct ClearSummary {
263    /// Snapshots removed, current or stale, each with its content sidecar.
264    pub snapshots: usize,
265    /// Leftover files reclaimed.
266    pub leftovers: usize,
267}
268
269impl ClearSummary {
270    /// Whether this clear removed nothing at all.
271    pub fn is_empty(self) -> bool {
272        self.snapshots == 0 && self.leftovers == 0
273    }
274}
275
276/// Why a snapshot fdu wrote cannot be served by this build.
277#[derive(Clone, Copy, Debug, PartialEq, Eq)]
278pub enum StaleReason {
279    /// Written in an earlier snapshot format.
280    OlderFormat {
281        /// The format version its header names.
282        version: u32,
283    },
284    /// Written in a later snapshot format, by a newer fdu.
285    NewerFormat {
286        /// The format version its header names.
287        version: u32,
288    },
289    /// The format matches but the engine fingerprint does not: another fdu version, or
290    /// other classification rules.
291    OtherEngine,
292    /// The format and engine match but the header cannot be read: a truncated or corrupt
293    /// file, or one written with another platform's path encoding.
294    Unreadable,
295}
296
297impl StaleReason {
298    /// Every label [`StaleReason::label`] returns, in declaration order.
299    pub const LABELS: [&'static str; 4] =
300        ["older_format", "newer_format", "other_engine", "unreadable"];
301
302    /// The label machine output carries.
303    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    /// The format version the header names, when that version is why it is stale.
313    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/// Which snapshots a cache-lifecycle request covers.
322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
323pub enum CacheScope {
324    /// The one file a root's snapshot occupies.
325    Root,
326    /// Every file in the cache directory.
327    All,
328}
329
330impl CacheScope {
331    /// Every label [`CacheScope::label`] returns, in declaration order.
332    pub const LABELS: [&'static str; 2] = ["root", "all"];
333
334    /// The label the command line and the Python package accept.
335    pub fn label(self) -> &'static str {
336        match self {
337            Self::Root => "root",
338            Self::All => "all",
339        }
340    }
341
342    /// Parse a label, ignoring case and surrounding whitespace.
343    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/// The header facts that identify a snapshot.
353#[derive(Clone, Debug, PartialEq, Eq)]
354pub struct SnapshotInfo {
355    /// Absolute path of the tree this snapshot describes.
356    pub root: PathBuf,
357    /// The identity of every tier it holds.
358    pub identity: SnapshotIdentity,
359    /// How many entries it holds.
360    pub entries: u64,
361}
362
363impl SnapshotInfo {
364    /// The scan scope an index loaded from it records.
365    pub fn scope(&self) -> ScanScope {
366        self.identity.scan_scope()
367    }
368}
369
370/// The name the cache gives the snapshot of a root with this hash.
371pub(crate) fn snapshot_file_name(root_hash: u64) -> String {
372    format!("{root_hash:0SNAPSHOT_NAME_HEX_DIGITS$x}{SNAPSHOT_NAME_SUFFIX}")
373}
374
375/// Whether a name is one [`snapshot_file_name`] produces.
376///
377/// On bytes rather than on an [`OsStr`], because a sidecar's and a staging file's names
378/// each *contain* one, and rebuilding an `OsStr` from a slice of one needs `unsafe`.
379/// [`name_shape`] is the entry point; this is what all four of its answers are built from.
380fn 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
388/// Whether a name is the content sidecar of a snapshot name.
389fn 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
397/// Which of fdu's names a file in the cache directory is shaped like.
398///
399/// Shape only: the magic decides whether the file really is fdu's, and every caller checks
400/// it. Keeping the two apart is what stops a name from being evidence, which is the same
401/// rule a snapshot follows.
402fn 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    // `.{target}.tmp.{pid}.{entropy}.{sequence}`, the staging name the snapshot writer
411    // gives every file it publishes by rename, for either target.
412    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        // No discriminator after the infix: not a name the writer produces.
421        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/// What a file name in the cache directory is shaped like.
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434enum NameShape {
435    /// `{16 hex}.metadata.bin`: the snapshot of some root.
436    Snapshot,
437    /// `{16 hex}.analysis.bin`: the content sidecar of that snapshot.
438    Sidecar,
439    /// `.{16 hex}.metadata.bin.tmp.*`: a snapshot being staged.
440    SnapshotTemporary,
441    /// `.{16 hex}.analysis.bin.tmp.*`: a sidecar being staged.
442    SidecarTemporary,
443    /// A name the cache gives nothing.
444    Other,
445}
446
447/// Read one cache file's status without materializing its index.
448///
449/// The file is identified by its contents, because the caller named it: only a name fdu
450/// gives its own staging files and sidecars is read as one, and even then the magic
451/// decides. A symbolic link at `path` is reported unrecognized rather than followed.
452///
453/// One path is all this sees, so a sidecar is judged by its own name and magic:
454/// [`LeftoverKind::OrphanedContent`] here says the file is a sidecar, not that no snapshot
455/// claims it. Whether one does is a fact about the directory, which [`list_caches`]
456/// answers and a clear asks again at the moment of removal.
457pub 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
466/// The status of whatever is at `path`, or `None` when nothing is.
467///
468/// Only a regular file is opened. A symbolic link, directory, or special file is described
469/// from its own metadata, so nothing here follows a link out of the cache directory or
470/// blocks on opening a FIFO.
471fn 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    // A staging file holds a complete image the moment before its rename, so identifying
479    // it by contents alone would call it a current snapshot. The name is what says it was
480    // never published, and only fdu's own staging names carry that meaning.
481    //
482    // A leftover's name is answered here and nowhere below: the name has already said
483    // which magic to expect, so contents that are not it leave the file unrecognized.
484    // Falling through to the snapshot identification would read a snapshot image under a
485    // sidecar's name as a snapshot, and then clear it under a name no snapshot is given
486    // and without the age rule that name carries.
487    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
521/// Whether a regular file at `path` begins with the snapshot magic.
522///
523/// Only a regular file is opened: a symbolic link must not be followed out of the cache
524/// directory, and opening a directory succeeds on some platforms and fails on the first
525/// read, which would turn a question into an error.
526fn 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
534/// Whether the file at `path` begins with the content-sidecar magic.
535fn is_sidecar_image(path: &Path) -> Result<bool> {
536    Ok(crate::content::content_sidecar_bytes(path)?.is_some())
537}
538
539/// The snapshot a content sidecar belongs to: the inverse of `content_cache_path`.
540fn 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
548/// A file under one of fdu's own leftover names: the leftover when its contents are the
549/// magic that name implies, and unrecognized when they are not.
550fn 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
568/// The byte count to report for a cache entry.
569///
570/// Only a regular file has a size a report is about. A directory's `st_size` is that
571/// directory entry's own accounting, which differs per filesystem and is zero on Windows,
572/// and a symbolic link's is the length of the path it holds. Either would be read as a
573/// file's size and summed into the unrecognized total, so a non-regular entry reports no
574/// bytes.
575fn reportable_bytes(metadata: &fs::Metadata) -> u64 {
576    if metadata.file_type().is_file() { metadata.len() } else { 0 }
577}
578
579/// Keep a filesystem answer, reading "not found" as nothing there rather than an error.
580///
581/// A path can be absent before the call or removed by another process during it, and a
582/// status or clear that races a concurrent clear must not fail because of that.
583fn 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
591/// Enumerate every entry in the cache directory.
592///
593/// Unrecognized entries are listed rather than hidden: a directory this code will not
594/// delete from is a directory the caller should still be able to see, and an entry left
595/// out of the listing is one nothing can report. A file counts as a snapshot only under a
596/// name the cache gives snapshots and with the snapshot magic, so neither a stray copy
597/// under another name nor another program's file under a snapshot's name is mistaken for
598/// one. Symbolic links and directories are listed from their own metadata, never followed
599/// or descended into, and report no bytes, because what the filesystem calls their size is
600/// its own accounting rather than anything a clear could reclaim.
601pub fn list_caches(cache_dir: &Path) -> Result<Vec<CacheStatus>> {
602    let Some(entries) = present(fs::read_dir(cache_dir), cache_dir)? else {
603        // An absent cache directory is an empty cache, not an error.
604        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        // `DirEntry::file_type` describes the entry itself, never a link's target.
612        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    // Deterministic order, so two runs of a status command agree.
628    found.sort_by(|left, right| left.path.cmp(&right.path));
629    Ok(found)
630}
631
632/// Remove one snapshot, current or stale, with its content sidecar.
633///
634/// Returns whether this call removed it. Idempotent: clearing an absent cache succeeds,
635/// and so does clearing one another process removed first. A file that is not one of
636/// fdu's snapshots is left in place and reported as not removed.
637pub 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        // Refusing to delete what this code cannot identify is the whole safety property:
641        // the cache directory may hold files fdu did not write.
642        return Ok(false);
643    }
644    // `remove_file` unlinks a symbolic link rather than its target, so a link swapped in
645    // after the check above can cost that link and never the file it points at.
646    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
656/// Remove a file, returning whether this call removed it.
657fn remove_present(path: &Path) -> Result<bool> {
658    Ok(present(fs::remove_file(path), path)?.is_some())
659}
660
661/// Remove every fdu snapshot in a cache directory, current or stale, and reclaim the files
662/// fdu left behind, leaving anything else alone.
663///
664/// Each file is identified again immediately before it is removed, so one replaced since
665/// the listing by something that is not fdu's survives.
666///
667/// Snapshots go first and leftovers second, so "no snapshot for this sidecar" is decided
668/// against the directory this call leaves rather than the one it found.
669pub 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
687/// Remove one file fdu left behind, if the rules for its kind allow it now.
688///
689/// Returns whether this call removed it. The status is read again here rather than trusted
690/// from a listing: both rules are about the state of the directory at the moment of
691/// removal, and a leftover is the one thing in the cache that another process may still be
692/// writing.
693fn 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        // The reaper's threshold, not a second one: a temporary this old cannot belong to
700        // a writer that is still running, which is what makes removing it safe without a
701        // liveness check no platform can give.
702        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        // Kept only while a snapshot image is at its snapshot path: one this clear left,
712        // or one a writer published since the listing, either of which a later analyzed
713        // scan can still use it for. Anything else there is not a snapshot and cannot
714        // want this sidecar, so the sidecar goes and the scan that writes a snapshot at
715        // that path writes a fresh one.
716        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    /// Byte offset of the format version: it follows the eight-byte magic.
730    const VERSION_OFFSET: usize = 8;
731
732    /// Byte offset of the engine fingerprint: it follows the four-byte format version.
733    const FINGERPRINT_OFFSET: usize = VERSION_OFFSET + 4;
734
735    /// A name the cache gives snapshots, distinct per `seed`.
736    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    /// Copy the snapshot at `from` to `to`, overwriting its format version.
760    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        // The property that makes the cache inspectable: a hash-named file can say which
771        // tree it belongs to.
772        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        // Every release changes the engine fingerprint and some change the format, so a
824        // cache that only recognised what this build writes would strand everything an
825        // earlier build left. Each way of being stale is reported with its reason.
826        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(), &current);
830
831        let older = cache.path().join(layout_name(2));
832        with_format_version(&current, &older, |version| version - 1);
833        let newer = cache.path().join(layout_name(3));
834        with_format_version(&current, &newer, |version| version + 1);
835        let other_engine = cache.path().join(layout_name(4));
836        let mut bytes = std::fs::read(&current).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(&current).expect("read");
843        std::fs::write(&truncated, &full[..full.len() - 1]).expect("truncate");
844        // Hand-built rather than copied: only the magic and a lower version, the least an
845        // earlier format is guaranteed to share with this one.
846        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    /// A sidecar this build cannot serve beside a snapshot it can is still fdu's: status
910    /// labels it stale with the reason, whatever its contents after the magic, and
911    /// clearing the snapshot takes it too, because pairing and clearing decide by magic.
912    #[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        // Another program's file under a snapshot's name: the name proves nothing.
972        let impostor = cache.path().join(layout_name(2));
973        std::fs::write(&impostor, b"FDUSNAQ and more bytes").expect("write");
974        // One of fdu's snapshots under a name the cache never gives one, such as a backup
975        // someone made: a listing cannot tell it from a user's own file.
976        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        // A link named like a snapshot and pointing at a real snapshot outside the cache
1006        // directory: following it would report, and then delete, what it points at.
1007        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        // A link's `st_size` is the length of the path it holds, which is the tempdir's,
1019        // so reporting it would put a machine-specific number in a byte total.
1020        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        // Skipping directories hid them from every report: `--cache-status=all` showed
1035        // nothing and `--cache-clear=all` counted nothing, while the docs promised that
1036        // anything which is not a regular file is listed as unrecognized.
1037        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        // Under a snapshot's own name, so the name cannot be what saves it.
1042        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        // No byte count: `st_size` for a directory is 4096 on one filesystem, 0 on
1054        // Windows, and something else on the next, and it would be summed into the bytes
1055        // a status attributes to unrecognized files.
1056        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    /// Move a file's modification time, so an age rule is tested by stating an age rather
1067    /// than by waiting for one.
1068    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    /// A moment old enough that no writer can still hold what was written then.
1078    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    /// The staging name the writer gives `target`, with an arbitrary discriminator.
1083    fn staging_name(target: &str) -> String {
1084        format!(".{target}.tmp.7.0123456789abcdef.0")
1085    }
1086
1087    /// A status as one string, so a listing's states and leftover kinds compare at once.
1088    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        // Both files begin with an fdu magic and neither is a snapshot in place, so
1098        // calling them "not an fdu snapshot" told the user to leave fdu's own debris
1099        // alone, and nothing collected it.
1100        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(), &current);
1104        let image = std::fs::read(&current).expect("read snapshot");
1105
1106        // A writer killed between staging and rename, long enough ago that it cannot be
1107        // running.
1108        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        // The same shape, written a moment ago: this one may be a live writer's.
1112        let in_flight = cache.path().join(staging_name(&layout_name(3)));
1113        std::fs::write(&in_flight, &image).expect("write");
1114        // A staging name over contents that are not fdu's: the name alone proves nothing.
1115        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        // A staged sidecar, and a sidecar whose snapshot is gone.
1119        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        // A sidecar name over contents that are not fdu's.
1125        let foreign_sidecar = cache.path().join(analysis_name(7));
1126        std::fs::write(&foreign_sidecar, b"not a sidecar").expect("write");
1127        // A snapshot image under a sidecar's name, and under a staged sidecar's name. The
1128        // name says which magic to expect and the snapshot's is not it, so both are
1129        // unrecognized: reading them as snapshots would clear them under a name no
1130        // snapshot is given, and the staged one without the age rule its name carries.
1131        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        // The single-path route says the same, so neither depends on the listing to be
1153        // safe.
1154        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, &current] {
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        // Idempotent, and the young temporary is still nobody's business to remove.
1173        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        // The other half of the orphan rule: what makes a sidecar collectable is that no
1179        // snapshot is left to want it, decided at the moment of removal.
1180        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(), &current);
1184        let sidecar = crate::content::content_cache_path(&current);
1185        std::fs::write(&sidecar, b"FDUCTNT\0payload").expect("write");
1186
1187        // Grouped with its snapshot rather than listed as debris.
1188        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        // Once the snapshot is gone, the same call collects it.
1195        assert!(remove_present(&current).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        // No mtime is set: opening the link to age it would age its target instead, and
1209        // the rule that saves it here is that a link is not a regular file at all.
1210        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        // What a concurrent clear leaves this one to find: the file was identified, then
1225        // someone else removed it.
1226        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        // `expect`, not `if let`: with no cache directory this test would otherwise assert
1246        // only its negative cases and pass without touching its subject. Every supported
1247        // platform resolves one, and `XDG_CACHE_HOME` names it everywhere if the platform
1248        // location does not.
1249        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        // The shape is a gate, never evidence: each of these still has to carry the right
1278        // magic before the state that matches it can be reported.
1279        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            // Near misses: uppercase hex, a missing discriminator, a target that is not a
1291            // name the cache gives, and the sidecar of something that is not a snapshot.
1292            ("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}