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 = ".fdu";
35
36/// What a content sidecar's name adds to its snapshot's, per `content_cache_path`.
37const CONTENT_NAME_SUFFIX: &str = ".content";
38
39/// What a staging name inserts between its target's name and the discriminator, per
40/// `snapshot::temp_name`.
41const TEMP_NAME_INFIX: &str = ".tmp.";
42
43/// What is known about one file in the cache directory.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct CacheStatus {
46    /// Where the file lives.
47    pub path: PathBuf,
48    /// Size on disk, in bytes.
49    pub bytes: u64,
50    /// The content sidecar fdu wrote beside this snapshot, current or stale, when there is
51    /// one.
52    pub content: Option<ContentStatus>,
53    /// What the file is, as far as this build can tell.
54    pub state: CacheState,
55}
56
57impl CacheStatus {
58    /// The header of a snapshot this build can serve.
59    pub fn snapshot(&self) -> Option<&SnapshotInfo> {
60        match &self.state {
61            CacheState::Current(info) => Some(info),
62            CacheState::Stale(_)
63            | CacheState::Leftover(_)
64            | CacheState::Unrecognized
65            | CacheState::Absent => None,
66        }
67    }
68
69    /// Whether this file is one of fdu's snapshots, current or stale, which is exactly
70    /// what clearing removes.
71    pub fn is_fdu_snapshot(&self) -> bool {
72        matches!(self.state, CacheState::Current(_) | CacheState::Stale(_))
73    }
74
75    /// Size of the content sidecar beside this snapshot, current or stale.
76    pub fn content_bytes(&self) -> Option<u64> {
77        self.content.as_ref().map(|content| content.bytes)
78    }
79}
80
81/// What is known about the content sidecar beside a snapshot.
82///
83/// Whether a sidecar is there is decided by its magic, as for a snapshot, so a sidecar this
84/// build cannot serve is still reported and cleared with its snapshot. Whether it is
85/// current is decided by its own header: its format version and engine fingerprint.
86/// Whether it pairs with the snapshot beside it is a question of identity equality, which
87/// loading asks and status reports by carrying both identities.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct ContentStatus {
90    /// Size on disk, in bytes.
91    pub bytes: u64,
92    /// What the sidecar is, as far as this build can tell.
93    pub state: ContentState,
94}
95
96/// What a content sidecar beside a snapshot holds.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum ContentState {
99    /// A sidecar this build reads.
100    Current(ContentInfo),
101    /// One of fdu's sidecars that this build cannot serve: another format or engine, or a
102    /// header this build cannot read.
103    Stale(StaleReason),
104}
105
106impl ContentState {
107    /// Every label a content sidecar's state can carry, in declaration order.
108    ///
109    /// The two a sidecar can be, which is narrower than [`CacheState::LABELS`]: a sidecar
110    /// is never leftover, unrecognized, or absent under its own status, because it is
111    /// reported only where one was found beside a snapshot.
112    pub const LABELS: [&'static str; 2] = ["current", "stale"];
113
114    /// The label machine output carries, one of [`Self::LABELS`].
115    pub fn label(&self) -> &'static str {
116        match self {
117            Self::Current(_) => "current",
118            Self::Stale(_) => "stale",
119        }
120    }
121}
122
123/// The header facts that identify a content sidecar.
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct ContentInfo {
126    /// The identity of the content tier it holds.
127    pub identity: ContentTierIdentity,
128    /// How many file records it holds.
129    pub records: u64,
130}
131
132/// What a path in the cache holds.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum CacheState {
135    /// A snapshot this build reads and can serve.
136    Current(SnapshotInfo),
137    /// One of fdu's snapshots that this build cannot serve. Clearing removes it.
138    Stale(StaleReason),
139    /// A file fdu wrote that is not a snapshot in place: a staging file a killed writer
140    /// never renamed, or a content sidecar whose snapshot is gone.
141    ///
142    /// Clearing the whole directory reclaims it, under the rules on [`LeftoverKind`].
143    Leftover(LeftoverKind),
144    /// A file fdu cannot identify as one of its own, which clearing never removes.
145    ///
146    /// Its contents lack the magic its name implies, or it is not a regular file (a
147    /// symbolic link is never followed, and a directory is never descended into), or it
148    /// was found by listing a directory under a name the cache gives nothing.
149    Unrecognized,
150    /// Nothing is there. Only a status for one path can be absent; a listing reports the
151    /// files it found.
152    Absent,
153}
154
155impl CacheState {
156    /// Every label [`CacheState::label`] returns, in declaration order.
157    pub const LABELS: [&'static str; 5] =
158        ["current", "stale", "leftover", "unrecognized", "absent"];
159
160    /// The label machine output carries.
161    pub fn label(&self) -> &'static str {
162        match self {
163            Self::Current(_) => "current",
164            Self::Stale(_) => "stale",
165            Self::Leftover(_) => "leftover",
166            Self::Unrecognized => "unrecognized",
167            Self::Absent => "absent",
168        }
169    }
170}
171
172/// Which of fdu's own files a leftover is.
173///
174/// Both are named by fdu and carry one of fdu's magics, and both are reclaimed only by
175/// clearing the whole directory: a root's clear reaches the one path that root's snapshot
176/// occupies, and neither of these is at that path.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum LeftoverKind {
179    /// The staging file a killed writer left beside its target, never renamed into place.
180    ///
181    /// Removed only once it is older than the age at which a later writer would reap it,
182    /// so clearing can never take a file a running writer still holds.
183    StagingTemporary,
184    /// A content sidecar whose snapshot is gone.
185    ///
186    /// Removed only when no snapshot for it exists after the clear, so a sidecar a later
187    /// analyzed scan could still reuse stays with the snapshot it belongs to. Orphaned is
188    /// what a listing shows: a sidecar whose snapshot is present is grouped with that
189    /// snapshot and never listed on its own.
190    OrphanedContent,
191}
192
193impl LeftoverKind {
194    /// Every label [`LeftoverKind::label`] returns, in declaration order.
195    pub const LABELS: [&'static str; 2] = ["staging_temporary", "orphaned_content"];
196
197    /// The label machine output carries.
198    pub fn label(self) -> &'static str {
199        match self {
200            Self::StagingTemporary => "staging_temporary",
201            Self::OrphanedContent => "orphaned_content",
202        }
203    }
204}
205
206/// What one clear removed.
207///
208/// Two counts rather than one total: "cleared 4 snapshots" and "reclaimed 2 files fdu left
209/// behind" are different facts, and a destructive command that reports one number for both
210/// would understate what it did.
211#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
212pub struct ClearSummary {
213    /// Snapshots removed, current or stale, each with its content sidecar.
214    pub snapshots: usize,
215    /// Leftover files reclaimed.
216    pub leftovers: usize,
217}
218
219impl ClearSummary {
220    /// Whether this clear removed nothing at all.
221    pub fn is_empty(self) -> bool {
222        self.snapshots == 0 && self.leftovers == 0
223    }
224}
225
226/// Why a snapshot fdu wrote cannot be served by this build.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228pub enum StaleReason {
229    /// Written in an earlier snapshot format.
230    OlderFormat {
231        /// The format version its header names.
232        version: u32,
233    },
234    /// Written in a later snapshot format, by a newer fdu.
235    NewerFormat {
236        /// The format version its header names.
237        version: u32,
238    },
239    /// The format matches but the engine fingerprint does not: another fdu version, or
240    /// other classification rules.
241    OtherEngine,
242    /// The format and engine match but the header cannot be read: a truncated or corrupt
243    /// file, or one written with another platform's path encoding.
244    Unreadable,
245}
246
247impl StaleReason {
248    /// Every label [`StaleReason::label`] returns, in declaration order.
249    pub const LABELS: [&'static str; 4] =
250        ["older_format", "newer_format", "other_engine", "unreadable"];
251
252    /// The label machine output carries.
253    pub fn label(self) -> &'static str {
254        match self {
255            Self::OlderFormat { .. } => "older_format",
256            Self::NewerFormat { .. } => "newer_format",
257            Self::OtherEngine => "other_engine",
258            Self::Unreadable => "unreadable",
259        }
260    }
261
262    /// The format version the header names, when that version is why it is stale.
263    pub fn format_version(self) -> Option<u32> {
264        match self {
265            Self::OlderFormat { version } | Self::NewerFormat { version } => Some(version),
266            Self::OtherEngine | Self::Unreadable => None,
267        }
268    }
269}
270
271/// Which snapshots a cache-lifecycle request covers.
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
273pub enum CacheScope {
274    /// The one file a root's snapshot occupies.
275    Root,
276    /// Every file in the cache directory.
277    All,
278}
279
280impl CacheScope {
281    /// Every label [`CacheScope::label`] returns, in declaration order.
282    pub const LABELS: [&'static str; 2] = ["root", "all"];
283
284    /// The label the command line and the Python package accept.
285    pub fn label(self) -> &'static str {
286        match self {
287            Self::Root => "root",
288            Self::All => "all",
289        }
290    }
291
292    /// Parse a label, ignoring case and surrounding whitespace.
293    pub fn parse(value: &str) -> Option<Self> {
294        match value.trim().to_ascii_lowercase().as_str() {
295            "root" => Some(Self::Root),
296            "all" => Some(Self::All),
297            _ => None,
298        }
299    }
300}
301
302/// The header facts that identify a snapshot.
303#[derive(Clone, Debug, PartialEq, Eq)]
304pub struct SnapshotInfo {
305    /// Absolute path of the tree this snapshot describes.
306    pub root: PathBuf,
307    /// The identity of every tier it holds.
308    pub identity: SnapshotIdentity,
309    /// How many entries it holds.
310    pub entries: u64,
311}
312
313impl SnapshotInfo {
314    /// The scan scope an index loaded from it records.
315    pub fn scope(&self) -> ScanScope {
316        self.identity.scan_scope()
317    }
318}
319
320/// The name the cache gives the snapshot of a root with this hash.
321pub(crate) fn snapshot_file_name(root_hash: u64) -> String {
322    format!("{root_hash:0SNAPSHOT_NAME_HEX_DIGITS$x}{SNAPSHOT_NAME_SUFFIX}")
323}
324
325/// Whether a name is one [`snapshot_file_name`] produces.
326///
327/// On bytes rather than on an [`OsStr`], because a sidecar's and a staging file's names
328/// each *contain* one, and rebuilding an `OsStr` from a slice of one needs `unsafe`.
329/// [`name_shape`] is the entry point; this is what all four of its answers are built from.
330fn is_snapshot_name_bytes(bytes: &[u8]) -> bool {
331    bytes.len() == SNAPSHOT_NAME_HEX_DIGITS + SNAPSHOT_NAME_SUFFIX.len()
332        && bytes.ends_with(SNAPSHOT_NAME_SUFFIX.as_bytes())
333        && bytes[..SNAPSHOT_NAME_HEX_DIGITS]
334            .iter()
335            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
336}
337
338/// Whether a name is the content sidecar of a snapshot name.
339fn is_sidecar_name_bytes(bytes: &[u8]) -> bool {
340    bytes.strip_suffix(CONTENT_NAME_SUFFIX.as_bytes()).is_some_and(is_snapshot_name_bytes)
341}
342
343/// Which of fdu's names a file in the cache directory is shaped like.
344///
345/// Shape only: the magic decides whether the file really is fdu's, and every caller checks
346/// it. Keeping the two apart is what stops a name from being evidence, which is the same
347/// rule a snapshot follows.
348fn name_shape(name: &OsStr) -> NameShape {
349    let bytes = name.as_encoded_bytes();
350    if is_snapshot_name_bytes(bytes) {
351        return NameShape::Snapshot;
352    }
353    if is_sidecar_name_bytes(bytes) {
354        return NameShape::Sidecar;
355    }
356    // `.{target}.tmp.{pid}.{entropy}.{sequence}`, the staging name the snapshot writer
357    // gives every file it publishes by rename, for either target.
358    let Some(rest) = bytes.strip_prefix(b".") else { return NameShape::Other };
359    let Some(at) =
360        rest.windows(TEMP_NAME_INFIX.len()).position(|w| w == TEMP_NAME_INFIX.as_bytes())
361    else {
362        return NameShape::Other;
363    };
364    let (target, suffix) = rest.split_at(at);
365    if suffix.len() <= TEMP_NAME_INFIX.len() {
366        // No discriminator after the infix: not a name the writer produces.
367        return NameShape::Other;
368    }
369    if is_snapshot_name_bytes(target) {
370        NameShape::SnapshotTemporary
371    } else if is_sidecar_name_bytes(target) {
372        NameShape::SidecarTemporary
373    } else {
374        NameShape::Other
375    }
376}
377
378/// What a file name in the cache directory is shaped like.
379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
380enum NameShape {
381    /// `{16 hex}.fdu`: the snapshot of some root.
382    Snapshot,
383    /// `{16 hex}.fdu.content`: the content sidecar of that snapshot.
384    Sidecar,
385    /// `.{16 hex}.fdu.tmp.*`: a snapshot being staged.
386    SnapshotTemporary,
387    /// `.{16 hex}.fdu.content.tmp.*`: a sidecar being staged.
388    SidecarTemporary,
389    /// A name the cache gives nothing.
390    Other,
391}
392
393/// Read one cache file's status without materializing its index.
394///
395/// The file is identified by its contents, because the caller named it: only a name fdu
396/// gives its own staging files and sidecars is read as one, and even then the magic
397/// decides. A symbolic link at `path` is reported unrecognized rather than followed.
398///
399/// One path is all this sees, so a sidecar is judged by its own name and magic:
400/// [`LeftoverKind::OrphanedContent`] here says the file is a sidecar, not that no snapshot
401/// claims it. Whether one does is a fact about the directory, which [`list_caches`]
402/// answers and a clear asks again at the moment of removal.
403pub fn cache_status(path: &Path) -> Result<CacheStatus> {
404    Ok(status_at(path)?.unwrap_or_else(|| CacheStatus {
405        path: path.to_path_buf(),
406        bytes: 0,
407        content: None,
408        state: CacheState::Absent,
409    }))
410}
411
412/// The status of whatever is at `path`, or `None` when nothing is.
413///
414/// Only a regular file is opened. A symbolic link, directory, or special file is described
415/// from its own metadata, so nothing here follows a link out of the cache directory or
416/// blocks on opening a FIFO.
417fn status_at(path: &Path) -> Result<Option<CacheStatus>> {
418    let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
419        return Ok(None);
420    };
421    if !metadata.file_type().is_file() {
422        return Ok(Some(unrecognized(path, reportable_bytes(&metadata))));
423    }
424    // A staging file holds a complete image the moment before its rename, so identifying
425    // it by contents alone would call it a current snapshot. The name is what says it was
426    // never published, and only fdu's own staging names carry that meaning.
427    //
428    // A leftover's name is answered here and nowhere below: the name has already said
429    // which magic to expect, so contents that are not it leave the file unrecognized.
430    // Falling through to the snapshot identification would read a snapshot image under a
431    // sidecar's name as a snapshot, and then clear it under a name no snapshot is given
432    // and without the age rule that name carries.
433    let leftover = match path.file_name().map_or(NameShape::Other, name_shape) {
434        NameShape::SnapshotTemporary => Some(leftover_or_unrecognized(
435            path,
436            metadata.len(),
437            LeftoverKind::StagingTemporary,
438            is_snapshot_image(path)?,
439        )),
440        NameShape::SidecarTemporary => Some(leftover_or_unrecognized(
441            path,
442            metadata.len(),
443            LeftoverKind::StagingTemporary,
444            is_sidecar_image(path)?,
445        )),
446        NameShape::Sidecar => Some(leftover_or_unrecognized(
447            path,
448            metadata.len(),
449            LeftoverKind::OrphanedContent,
450            is_sidecar_image(path)?,
451        )),
452        NameShape::Snapshot | NameShape::Other => None,
453    };
454    if let Some(status) = leftover {
455        return Ok(Some(status));
456    }
457    let state = match snapshot::identify(path)? {
458        None => return Ok(None),
459        Some(Identity::Foreign) => return Ok(Some(unrecognized(path, metadata.len()))),
460        Some(Identity::Current(info)) => CacheState::Current(info),
461        Some(Identity::Stale(reason)) => CacheState::Stale(reason),
462    };
463    let content = crate::content::identify_sidecar(&crate::content::content_cache_path(path))?;
464    Ok(Some(CacheStatus { path: path.to_path_buf(), bytes: metadata.len(), content, state }))
465}
466
467/// Whether a regular file at `path` begins with the snapshot magic.
468///
469/// Only a regular file is opened: a symbolic link must not be followed out of the cache
470/// directory, and opening a directory succeeds on some platforms and fails on the first
471/// read, which would turn a question into an error.
472fn is_snapshot_image(path: &Path) -> Result<bool> {
473    let Some(metadata) = present(fs::symlink_metadata(path), path)? else { return Ok(false) };
474    if !metadata.file_type().is_file() {
475        return Ok(false);
476    }
477    Ok(matches!(snapshot::identify(path)?, Some(Identity::Current(_) | Identity::Stale(_))))
478}
479
480/// Whether the file at `path` begins with the content-sidecar magic.
481fn is_sidecar_image(path: &Path) -> Result<bool> {
482    Ok(crate::content::content_sidecar_bytes(path)?.is_some())
483}
484
485/// The snapshot a content sidecar belongs to: the inverse of `content_cache_path`.
486///
487/// `with_extension("")` drops the *final* extension, which is `.content` for every name
488/// this is called with — [`NameShape::Sidecar`] is what proves that.
489fn sidecar_snapshot_path(path: &Path) -> PathBuf {
490    path.with_extension("")
491}
492
493fn unrecognized(path: &Path, bytes: u64) -> CacheStatus {
494    CacheStatus { path: path.to_path_buf(), bytes, content: None, state: CacheState::Unrecognized }
495}
496
497/// A file under one of fdu's own leftover names: the leftover when its contents are the
498/// magic that name implies, and unrecognized when they are not.
499fn leftover_or_unrecognized(
500    path: &Path,
501    bytes: u64,
502    kind: LeftoverKind,
503    has_magic: bool,
504) -> CacheStatus {
505    if has_magic {
506        CacheStatus {
507            path: path.to_path_buf(),
508            bytes,
509            content: None,
510            state: CacheState::Leftover(kind),
511        }
512    } else {
513        unrecognized(path, bytes)
514    }
515}
516
517/// The byte count to report for a cache entry.
518///
519/// Only a regular file has a size a report is about. A directory's `st_size` is that
520/// directory entry's own accounting, which differs per filesystem and is zero on Windows,
521/// and a symbolic link's is the length of the path it holds. Either would be read as a
522/// file's size and summed into the unrecognized total, so a non-regular entry reports no
523/// bytes.
524fn reportable_bytes(metadata: &fs::Metadata) -> u64 {
525    if metadata.file_type().is_file() { metadata.len() } else { 0 }
526}
527
528/// Keep a filesystem answer, reading "not found" as nothing there rather than an error.
529///
530/// A path can be absent before the call or removed by another process during it, and a
531/// status or clear that races a concurrent clear must not fail because of that.
532fn present<T>(result: io::Result<T>, path: &Path) -> Result<Option<T>> {
533    match result {
534        Ok(value) => Ok(Some(value)),
535        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
536        Err(error) => Err(Error::io(path, error)),
537    }
538}
539
540/// Enumerate every entry in the cache directory.
541///
542/// Unrecognized entries are listed rather than hidden: a directory this code will not
543/// delete from is a directory the caller should still be able to see, and an entry left
544/// out of the listing is one nothing can report. A file counts as a snapshot only under a
545/// name the cache gives snapshots and with the snapshot magic, so neither a stray copy
546/// under another name nor another program's file under a snapshot's name is mistaken for
547/// one. Symbolic links and directories are listed from their own metadata, never followed
548/// or descended into, and report no bytes, because what the filesystem calls their size is
549/// its own accounting rather than anything a clear could reclaim.
550pub fn list_caches(cache_dir: &Path) -> Result<Vec<CacheStatus>> {
551    let Some(entries) = present(fs::read_dir(cache_dir), cache_dir)? else {
552        // An absent cache directory is an empty cache, not an error.
553        return Ok(Vec::new());
554    };
555
556    let mut found = Vec::new();
557    for entry in entries {
558        let entry = entry.map_err(|error| Error::io(cache_dir, error))?;
559        let path = entry.path();
560        // `DirEntry::file_type` describes the entry itself, never a link's target.
561        let Some(file_type) = present(entry.file_type(), &path)? else { continue };
562        let status = if file_type.is_file() && name_shape(&entry.file_name()) != NameShape::Other {
563            status_at(&path)?
564        } else {
565            present(fs::symlink_metadata(&path), &path)?
566                .map(|metadata| unrecognized(&path, reportable_bytes(&metadata)))
567        };
568        found.extend(status);
569    }
570    let paired_sidecars = found
571        .iter()
572        .filter(|status| status.is_fdu_snapshot() && status.content.is_some())
573        .map(|status| crate::content::content_cache_path(&status.path))
574        .collect::<std::collections::BTreeSet<_>>();
575    found.retain(|status| !paired_sidecars.contains(&status.path));
576    // Deterministic order, so two runs of a status command agree.
577    found.sort_by(|left, right| left.path.cmp(&right.path));
578    Ok(found)
579}
580
581/// Remove one snapshot, current or stale, with its content sidecar.
582///
583/// Returns whether this call removed it. Idempotent: clearing an absent cache succeeds,
584/// and so does clearing one another process removed first. A file that is not one of
585/// fdu's snapshots is left in place and reported as not removed.
586pub fn clear_cache(path: &Path) -> Result<bool> {
587    let Some(status) = status_at(path)? else { return Ok(false) };
588    if !status.is_fdu_snapshot() {
589        // Refusing to delete what this code cannot identify is the whole safety property:
590        // the cache directory may hold files fdu did not write.
591        return Ok(false);
592    }
593    // `remove_file` unlinks a symbolic link rather than its target, so a link swapped in
594    // after the check above can cost that link and never the file it points at.
595    if !remove_present(path)? {
596        return Ok(false);
597    }
598    let content_path = crate::content::content_cache_path(path);
599    if crate::content::content_sidecar_bytes(&content_path)?.is_some() {
600        remove_present(&content_path)?;
601    }
602    Ok(true)
603}
604
605/// Remove a file, returning whether this call removed it.
606fn remove_present(path: &Path) -> Result<bool> {
607    Ok(present(fs::remove_file(path), path)?.is_some())
608}
609
610/// Remove every fdu snapshot in a cache directory, current or stale, and reclaim the files
611/// fdu left behind, leaving anything else alone.
612///
613/// Each file is identified again immediately before it is removed, so one replaced since
614/// the listing by something that is not fdu's survives.
615///
616/// Snapshots go first and leftovers second, so "no snapshot for this sidecar" is decided
617/// against the directory this call leaves rather than the one it found.
618pub fn clear_all_caches(cache_dir: &Path) -> Result<ClearSummary> {
619    let listed = list_caches(cache_dir)?;
620    let mut summary = ClearSummary::default();
621    for status in &listed {
622        if status.is_fdu_snapshot() && clear_cache(&status.path)? {
623            summary.snapshots += 1;
624        }
625    }
626    for status in &listed {
627        if let CacheState::Leftover(kind) = status.state {
628            if clear_leftover(&status.path, kind)? {
629                summary.leftovers += 1;
630            }
631        }
632    }
633    Ok(summary)
634}
635
636/// Remove one file fdu left behind, if the rules for its kind allow it now.
637///
638/// Returns whether this call removed it. The status is read again here rather than trusted
639/// from a listing: both rules are about the state of the directory at the moment of
640/// removal, and a leftover is the one thing in the cache that another process may still be
641/// writing.
642fn clear_leftover(path: &Path, kind: LeftoverKind) -> Result<bool> {
643    let Some(status) = status_at(path)? else { return Ok(false) };
644    if status.state != CacheState::Leftover(kind) {
645        return Ok(false);
646    }
647    let removable = match kind {
648        // The reaper's threshold, not a second one: a temporary this old cannot belong to
649        // a writer that is still running, which is what makes removing it safe without a
650        // liveness check no platform can give.
651        LeftoverKind::StagingTemporary => {
652            let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
653                return Ok(false);
654            };
655            let Ok(modified) = metadata.modified() else { return Ok(false) };
656            std::time::SystemTime::now()
657                .duration_since(modified)
658                .is_ok_and(|age| age >= snapshot::STALE_TEMP_AGE)
659        }
660        // Kept only while a snapshot image is at its snapshot path: one this clear left,
661        // or one a writer published since the listing, either of which a later analyzed
662        // scan can still use it for. Anything else there is not a snapshot and cannot
663        // want this sidecar, so the sidecar goes and the scan that writes a snapshot at
664        // that path writes a fresh one.
665        LeftoverKind::OrphanedContent => !is_snapshot_image(&sidecar_snapshot_path(path))?,
666    };
667    if !removable {
668        return Ok(false);
669    }
670    remove_present(path)
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::{CachePolicy, OpenFixture, open_fixture as open};
677
678    /// Byte offset of the format version: it follows the eight-byte magic.
679    const VERSION_OFFSET: usize = 8;
680
681    /// Byte offset of the engine fingerprint: it follows the four-byte format version.
682    const FINGERPRINT_OFFSET: usize = VERSION_OFFSET + 4;
683
684    /// A name the cache gives snapshots, distinct per `seed`.
685    fn layout_name(seed: u64) -> String {
686        snapshot_file_name(seed)
687    }
688
689    fn seed(tree: &Path, snapshot_path: &Path) {
690        std::fs::write(tree.join("a.txt"), b"hello").expect("write");
691        let config = OpenFixture {
692            cache_path: Some(snapshot_path.to_path_buf()),
693            policy: CachePolicy::Auto,
694            ..OpenFixture::default()
695        };
696        open(tree, &config).expect("seed");
697    }
698
699    /// Copy the snapshot at `from` to `to`, overwriting its format version.
700    fn with_format_version(from: &Path, to: &Path, version: impl FnOnce(u32) -> u32) {
701        let mut bytes = std::fs::read(from).expect("read snapshot");
702        let range = VERSION_OFFSET..FINGERPRINT_OFFSET;
703        let current = u32::from_le_bytes(bytes[range.clone()].try_into().expect("four bytes"));
704        bytes[range].copy_from_slice(&version(current).to_le_bytes());
705        std::fs::write(to, bytes).expect("write stale snapshot");
706    }
707
708    #[test]
709    fn a_snapshot_reports_the_root_it_describes() {
710        // The property that makes the cache inspectable: a hash-named file can say which
711        // tree it belongs to.
712        let tree = tempfile::tempdir().expect("tempdir");
713        let cache = tempfile::tempdir().expect("cache");
714        let path = cache.path().join("snap.fdu");
715        seed(tree.path(), &path);
716
717        let status = cache_status(&path).expect("status");
718        assert!(status.bytes > 0);
719        let info = status.snapshot().expect("header");
720        assert_eq!(info.root, tree.path().canonicalize().expect("canonical"));
721        assert_eq!(info.entries, 2, "the root plus one file");
722    }
723
724    #[test]
725    fn content_sidecar_is_reported_and_cleared_with_its_snapshot() {
726        let tree = tempfile::tempdir().expect("tempdir");
727        let cache = tempfile::tempdir().expect("cache");
728        let path = cache.path().join(layout_name(1));
729        std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
730        let config = OpenFixture {
731            cache_path: Some(path.clone()),
732            policy: CachePolicy::Auto,
733            analysis: crate::content::AnalysisRequest {
734                profile: crate::content::AnalysisSet::NONE.with_lines(),
735                ..crate::content::AnalysisRequest::default()
736            },
737            ..OpenFixture::default()
738        };
739        let (index, _) = open(tree.path(), &config).expect("seed analyzed cache");
740
741        let listed = list_caches(cache.path()).expect("list");
742        assert_eq!(listed.len(), 1, "a sidecar is grouped with its snapshot");
743        let Some(CacheState::Current(snapshot)) = listed.first().map(|status| &status.state) else {
744            panic!("a current snapshot: {listed:?}");
745        };
746        assert_eq!(snapshot.identity, index.snapshot_identity());
747        let content = listed[0].content.as_ref().expect("its sidecar");
748        assert_eq!(
749            content.state,
750            ContentState::Current(ContentInfo {
751                identity: index.content_identity(config.analysis.profile),
752                records: 1,
753            }),
754            "status reports the identity the sidecar serves"
755        );
756        assert!(clear_cache(&path).expect("clear"));
757        assert!(!path.exists());
758        assert!(!crate::content::content_cache_path(&path).exists());
759    }
760
761    #[test]
762    fn stale_snapshots_are_reported_beside_a_current_one_and_all_are_cleared() {
763        // Every release changes the engine fingerprint and some change the format, so a
764        // cache that only recognised what this build writes would strand everything an
765        // earlier build left. Each way of being stale is reported with its reason.
766        let tree = tempfile::tempdir().expect("tempdir");
767        let cache = tempfile::tempdir().expect("cache");
768        let current = cache.path().join(layout_name(1));
769        seed(tree.path(), &current);
770
771        let older = cache.path().join(layout_name(2));
772        with_format_version(&current, &older, |version| version - 1);
773        let newer = cache.path().join(layout_name(3));
774        with_format_version(&current, &newer, |version| version + 1);
775        let other_engine = cache.path().join(layout_name(4));
776        let mut bytes = std::fs::read(&current).expect("read");
777        for byte in &mut bytes[FINGERPRINT_OFFSET..FINGERPRINT_OFFSET + 8] {
778            *byte = !*byte;
779        }
780        std::fs::write(&other_engine, &bytes).expect("write");
781        let truncated = cache.path().join(layout_name(5));
782        let full = std::fs::read(&current).expect("read");
783        std::fs::write(&truncated, &full[..full.len() - 1]).expect("truncate");
784        // Hand-built rather than copied: only the magic and a lower version, the least an
785        // earlier format is guaranteed to share with this one.
786        let hand_built = cache.path().join(layout_name(6));
787        let mut prologue = b"FDUSNAP\x00".to_vec();
788        prologue.extend_from_slice(&1_u32.to_le_bytes());
789        std::fs::write(&hand_built, &prologue).expect("write");
790
791        let current_version = u32::from_le_bytes(
792            full[VERSION_OFFSET..FINGERPRINT_OFFSET].try_into().expect("four bytes"),
793        );
794        let states = list_caches(cache.path())
795            .expect("list")
796            .into_iter()
797            .map(|status| (status.path, status.state))
798            .collect::<Vec<_>>();
799        assert_eq!(states.len(), 6);
800        assert!(matches!(states[0].1, CacheState::Current(_)));
801        assert_eq!(
802            states[1..],
803            [
804                (
805                    older,
806                    CacheState::Stale(StaleReason::OlderFormat { version: current_version - 1 })
807                ),
808                (
809                    newer,
810                    CacheState::Stale(StaleReason::NewerFormat { version: current_version + 1 })
811                ),
812                (other_engine, CacheState::Stale(StaleReason::OtherEngine)),
813                (truncated.clone(), CacheState::Stale(StaleReason::Unreadable)),
814                (hand_built, CacheState::Stale(StaleReason::OlderFormat { version: 1 })),
815            ]
816        );
817
818        assert!(clear_cache(&truncated).expect("clear one"), "a truncated snapshot is still fdu's");
819        assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 5);
820        assert!(list_caches(cache.path()).expect("list").is_empty());
821    }
822
823    #[test]
824    fn a_stale_snapshot_takes_its_content_sidecar_with_it() {
825        let tree = tempfile::tempdir().expect("tempdir");
826        let cache = tempfile::tempdir().expect("cache");
827        let path = cache.path().join(layout_name(1));
828        std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
829        let config = OpenFixture {
830            cache_path: Some(path.clone()),
831            policy: CachePolicy::Auto,
832            analysis: crate::content::AnalysisRequest {
833                profile: crate::content::AnalysisSet::NONE.with_lines(),
834                ..crate::content::AnalysisRequest::default()
835            },
836            ..OpenFixture::default()
837        };
838        open(tree.path(), &config).expect("seed analyzed cache");
839        with_format_version(&path, &path, |version| version - 1);
840
841        let listed = list_caches(cache.path()).expect("list");
842        assert_eq!(listed.len(), 1, "a stale snapshot's sidecar is grouped with it");
843        assert!(matches!(listed[0].state, CacheState::Stale(StaleReason::OlderFormat { .. })));
844        assert!(listed[0].content.is_some());
845        assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
846        assert!(!crate::content::content_cache_path(&path).exists());
847    }
848
849    /// A sidecar this build cannot serve beside a snapshot it can is still fdu's: status
850    /// labels it stale with the reason, whatever its contents after the magic, and
851    /// clearing the snapshot takes it too, because pairing and clearing decide by magic.
852    #[test]
853    fn a_stale_sidecar_beside_a_current_snapshot_is_labelled_and_cleared() {
854        let tree = tempfile::tempdir().expect("tempdir");
855        let cache = tempfile::tempdir().expect("cache");
856        let path = cache.path().join(layout_name(1));
857        std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
858        let config = OpenFixture {
859            cache_path: Some(path.clone()),
860            policy: CachePolicy::Auto,
861            analysis: crate::content::AnalysisRequest {
862                profile: crate::content::AnalysisSet::NONE.with_lines(),
863                ..crate::content::AnalysisRequest::default()
864            },
865            ..OpenFixture::default()
866        };
867        open(tree.path(), &config).expect("seed analyzed cache");
868        let sidecar = crate::content::content_cache_path(&path);
869        let written = std::fs::read(&sidecar).expect("a sidecar");
870
871        let mut older = written.clone();
872        older[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
873        let mut newer = written.clone();
874        newer[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&99_u32.to_le_bytes());
875        let mut other_engine = written.clone();
876        other_engine[FINGERPRINT_OFFSET] ^= 0xff;
877        let truncated = written[..written.len() - 1].to_vec();
878        let mut unreadable_header = written.clone();
879        let path_encoding_at = FINGERPRINT_OFFSET + 8;
880        unreadable_header[path_encoding_at] ^= 0xff;
881        for (image, reason) in [
882            (older, StaleReason::OlderFormat { version: 4 }),
883            (newer, StaleReason::NewerFormat { version: 99 }),
884            (other_engine, StaleReason::OtherEngine),
885            (truncated, StaleReason::Unreadable),
886            (unreadable_header, StaleReason::Unreadable),
887        ] {
888            std::fs::write(&sidecar, &image).expect("rewrite the sidecar");
889            let listed = list_caches(cache.path()).expect("list");
890            assert_eq!(listed.len(), 1, "a stale sidecar is still grouped with its snapshot");
891            assert!(matches!(listed[0].state, CacheState::Current(_)), "{listed:?}");
892            let content = listed[0].content.as_ref().expect("its sidecar");
893            assert_eq!(content.bytes, u64::try_from(image.len()).expect("small"));
894            assert_eq!(content.state, ContentState::Stale(reason), "{reason:?}");
895            assert_eq!(cache_status(&path).expect("status").content, listed[0].content);
896        }
897
898        assert!(clear_cache(&path).expect("clear"));
899        assert!(!path.exists());
900        assert!(!sidecar.exists(), "the stale sidecar goes with its snapshot");
901    }
902
903    #[test]
904    fn unrecognized_files_are_listed_but_never_removed() {
905        let tree = tempfile::tempdir().expect("tempdir");
906        let cache = tempfile::tempdir().expect("cache");
907        let path = cache.path().join(layout_name(1));
908        seed(tree.path(), &path);
909        let foreign = cache.path().join("notes.txt");
910        std::fs::write(&foreign, b"not a snapshot").expect("write");
911        // Another program's file under a snapshot's name: the name proves nothing.
912        let impostor = cache.path().join(layout_name(2));
913        std::fs::write(&impostor, b"FDUSNAQ and more bytes").expect("write");
914        // One of fdu's snapshots under a name the cache never gives one, such as a backup
915        // someone made: a listing cannot tell it from a user's own file.
916        let renamed = cache.path().join("backup.fdu");
917        std::fs::copy(&path, &renamed).expect("copy");
918
919        let listed = list_caches(cache.path()).expect("list");
920        assert_eq!(
921            listed
922                .iter()
923                .map(|status| (status.path.clone(), status.state.label()))
924                .collect::<Vec<_>>(),
925            [
926                (path.clone(), "current"),
927                (impostor.clone(), "unrecognized"),
928                (renamed.clone(), "unrecognized"),
929                (foreign.clone(), "unrecognized"),
930            ]
931        );
932        assert_eq!(listed[3].bytes, 14, "an unrecognized file still reports its size");
933
934        assert!(!clear_cache(&impostor).expect("clear"));
935        assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
936        for survivor in [&foreign, &impostor, &renamed] {
937            assert!(survivor.exists(), "{} must survive", survivor.display());
938        }
939        assert_eq!(list_caches(cache.path()).expect("list").len(), 3);
940    }
941
942    #[cfg(unix)]
943    #[test]
944    fn a_symbolic_link_in_the_cache_is_never_followed_or_removed() {
945        // A link named like a snapshot and pointing at a real snapshot outside the cache
946        // directory: following it would report, and then delete, what it points at.
947        let tree = tempfile::tempdir().expect("tempdir");
948        let cache = tempfile::tempdir().expect("cache");
949        let outside = tempfile::tempdir().expect("outside");
950        let target = outside.path().join(layout_name(9));
951        seed(tree.path(), &target);
952        let link = cache.path().join(layout_name(1));
953        std::os::unix::fs::symlink(&target, &link).expect("symlink");
954
955        let listed = list_caches(cache.path()).expect("list");
956        assert_eq!(listed.len(), 1);
957        assert_eq!(listed[0].state, CacheState::Unrecognized);
958        // A link's `st_size` is the length of the path it holds, which is the tempdir's,
959        // so reporting it would put a machine-specific number in a byte total.
960        assert_eq!(listed[0].bytes, 0, "a link has no size to report");
961        assert_eq!(cache_status(&link).expect("status").state, CacheState::Unrecognized);
962
963        assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
964        assert!(!clear_cache(&link).expect("clear"));
965        assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
966        assert!(
967            cache_status(&target).expect("status").snapshot().is_some(),
968            "and so does its target"
969        );
970    }
971
972    #[test]
973    fn a_directory_in_the_cache_is_listed_and_never_removed() {
974        // Skipping directories hid them from every report: `--cache-status=all` showed
975        // nothing and `--cache-clear=all` counted nothing, while the docs promised that
976        // anything which is not a regular file is listed as unrecognized.
977        let tree = tempfile::tempdir().expect("tempdir");
978        let cache = tempfile::tempdir().expect("cache");
979        let snapshot = cache.path().join(layout_name(1));
980        seed(tree.path(), &snapshot);
981        // Under a snapshot's own name, so the name cannot be what saves it.
982        let nested = cache.path().join(layout_name(2));
983        std::fs::create_dir(&nested).expect("create dir");
984
985        let listed = list_caches(cache.path()).expect("list");
986        assert_eq!(
987            listed
988                .iter()
989                .map(|status| (status.path.clone(), status.state.label()))
990                .collect::<Vec<_>>(),
991            [(snapshot, "current"), (nested.clone(), "unrecognized")]
992        );
993        // No byte count: `st_size` for a directory is 4096 on one filesystem, 0 on
994        // Windows, and something else on the next, and it would be summed into the bytes
995        // a status attributes to unrecognized files.
996        assert_eq!(listed[1].bytes, 0, "a directory has no size to report");
997        let single = cache_status(&nested).expect("status");
998        assert_eq!(single.state, CacheState::Unrecognized);
999        assert_eq!(single.bytes, 0, "and the single-path route agrees");
1000
1001        assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
1002        assert!(!clear_cache(&nested).expect("clear"));
1003        assert!(nested.is_dir(), "a directory is never removed");
1004    }
1005
1006    /// Move a file's modification time, so an age rule is tested by stating an age rather
1007    /// than by waiting for one.
1008    fn set_modified(path: &Path, at: std::time::SystemTime) {
1009        std::fs::OpenOptions::new()
1010            .write(true)
1011            .open(path)
1012            .expect("open")
1013            .set_modified(at)
1014            .expect("set modified");
1015    }
1016
1017    /// A moment old enough that no writer can still hold what was written then.
1018    fn beyond_the_reaper() -> std::time::SystemTime {
1019        std::time::SystemTime::now() - snapshot::STALE_TEMP_AGE - std::time::Duration::from_secs(1)
1020    }
1021
1022    /// The staging name the writer gives `target`, with an arbitrary discriminator.
1023    fn staging_name(target: &str) -> String {
1024        format!(".{target}.tmp.7.0123456789abcdef.0")
1025    }
1026
1027    /// A status as one string, so a listing's states and leftover kinds compare at once.
1028    fn describe(status: &CacheStatus) -> String {
1029        match &status.state {
1030            CacheState::Leftover(kind) => format!("leftover/{}", kind.label()),
1031            other => other.label().to_string(),
1032        }
1033    }
1034
1035    #[test]
1036    fn fdus_own_leftovers_are_named_as_fdus_and_reclaimed_only_under_their_rules() {
1037        // Both files begin with an fdu magic and neither is a snapshot in place, so
1038        // calling them "not an fdu snapshot" told the user to leave fdu's own debris
1039        // alone, and nothing collected it.
1040        let tree = tempfile::tempdir().expect("tempdir");
1041        let cache = tempfile::tempdir().expect("cache");
1042        let current = cache.path().join(layout_name(1));
1043        seed(tree.path(), &current);
1044        let image = std::fs::read(&current).expect("read snapshot");
1045
1046        // A writer killed between staging and rename, long enough ago that it cannot be
1047        // running.
1048        let abandoned = cache.path().join(staging_name(&layout_name(2)));
1049        std::fs::write(&abandoned, &image).expect("write");
1050        set_modified(&abandoned, beyond_the_reaper());
1051        // The same shape, written a moment ago: this one may be a live writer's.
1052        let in_flight = cache.path().join(staging_name(&layout_name(3)));
1053        std::fs::write(&in_flight, &image).expect("write");
1054        // A staging name over contents that are not fdu's: the name alone proves nothing.
1055        let impostor = cache.path().join(staging_name(&layout_name(4)));
1056        std::fs::write(&impostor, b"not a snapshot").expect("write");
1057        set_modified(&impostor, beyond_the_reaper());
1058        // A staged sidecar, and a sidecar whose snapshot is gone.
1059        let staged_sidecar =
1060            cache.path().join(staging_name(&format!("{}.content", layout_name(5))));
1061        std::fs::write(&staged_sidecar, b"FDUCTNT\0payload").expect("write");
1062        set_modified(&staged_sidecar, beyond_the_reaper());
1063        let orphan = cache.path().join(format!("{}.content", layout_name(6)));
1064        std::fs::write(&orphan, b"FDUCTNT\0payload").expect("write");
1065        // A sidecar name over contents that are not fdu's.
1066        let foreign_sidecar = cache.path().join(format!("{}.content", layout_name(7)));
1067        std::fs::write(&foreign_sidecar, b"not a sidecar").expect("write");
1068        // A snapshot image under a sidecar's name, and under a staged sidecar's name. The
1069        // name says which magic to expect and the snapshot's is not it, so both are
1070        // unrecognized: reading them as snapshots would clear them under a name no
1071        // snapshot is given, and the staged one without the age rule its name carries.
1072        let snapshot_under_sidecar_name = cache.path().join(format!("{}.content", layout_name(8)));
1073        std::fs::write(&snapshot_under_sidecar_name, &image).expect("write");
1074        let snapshot_under_staged_sidecar_name =
1075            cache.path().join(staging_name(&format!("{}.content", layout_name(9))));
1076        std::fs::write(&snapshot_under_staged_sidecar_name, &image).expect("write");
1077        set_modified(&snapshot_under_staged_sidecar_name, beyond_the_reaper());
1078
1079        let listed = list_caches(cache.path()).expect("list");
1080        assert_eq!(
1081            listed.iter().map(|status| (status.path.clone(), describe(status))).collect::<Vec<_>>(),
1082            [
1083                (abandoned.clone(), "leftover/staging_temporary".to_string()),
1084                (in_flight.clone(), "leftover/staging_temporary".to_string()),
1085                (impostor.clone(), "unrecognized".to_string()),
1086                (staged_sidecar.clone(), "leftover/staging_temporary".to_string()),
1087                (snapshot_under_staged_sidecar_name.clone(), "unrecognized".to_string()),
1088                (current.clone(), "current".to_string()),
1089                (orphan.clone(), "leftover/orphaned_content".to_string()),
1090                (foreign_sidecar.clone(), "unrecognized".to_string()),
1091                (snapshot_under_sidecar_name.clone(), "unrecognized".to_string()),
1092            ]
1093        );
1094        // The single-path route says the same, so neither depends on the listing to be
1095        // safe.
1096        for named in [&snapshot_under_sidecar_name, &snapshot_under_staged_sidecar_name] {
1097            assert_eq!(cache_status(named).expect("status").state, CacheState::Unrecognized);
1098        }
1099
1100        let summary = clear_all_caches(cache.path()).expect("clear");
1101        assert_eq!(summary, ClearSummary { snapshots: 1, leftovers: 3 });
1102        for gone in [&abandoned, &staged_sidecar, &orphan, &current] {
1103            assert!(!gone.exists(), "{} should be reclaimed", gone.display());
1104        }
1105        for kept in [
1106            &in_flight,
1107            &impostor,
1108            &foreign_sidecar,
1109            &snapshot_under_sidecar_name,
1110            &snapshot_under_staged_sidecar_name,
1111        ] {
1112            assert!(kept.exists(), "{} must survive", kept.display());
1113        }
1114        // Idempotent, and the young temporary is still nobody's business to remove.
1115        assert_eq!(clear_all_caches(cache.path()).expect("clear"), ClearSummary::default());
1116    }
1117
1118    #[test]
1119    fn a_sidecar_is_kept_while_a_snapshot_still_claims_it() {
1120        // The other half of the orphan rule: what makes a sidecar collectable is that no
1121        // snapshot is left to want it, decided at the moment of removal.
1122        let tree = tempfile::tempdir().expect("tempdir");
1123        let cache = tempfile::tempdir().expect("cache");
1124        let current = cache.path().join(layout_name(1));
1125        seed(tree.path(), &current);
1126        let sidecar = crate::content::content_cache_path(&current);
1127        std::fs::write(&sidecar, b"FDUCTNT\0payload").expect("write");
1128
1129        // Grouped with its snapshot rather than listed as debris.
1130        let listed = list_caches(cache.path()).expect("list");
1131        assert_eq!(listed.len(), 1);
1132        assert_eq!(listed[0].content_bytes(), Some(15));
1133        assert!(!clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
1134        assert!(sidecar.exists(), "its snapshot is still there");
1135
1136        // Once the snapshot is gone, the same call collects it.
1137        assert!(remove_present(&current).expect("remove"));
1138        assert!(clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
1139        assert!(!sidecar.exists());
1140    }
1141
1142    #[cfg(unix)]
1143    #[test]
1144    fn a_symbolic_link_named_like_a_leftover_is_never_followed_or_removed() {
1145        let tree = tempfile::tempdir().expect("tempdir");
1146        let cache = tempfile::tempdir().expect("cache");
1147        let outside = tempfile::tempdir().expect("outside");
1148        let target = outside.path().join(layout_name(9));
1149        seed(tree.path(), &target);
1150        // No mtime is set: opening the link to age it would age its target instead, and
1151        // the rule that saves it here is that a link is not a regular file at all.
1152        let link = cache.path().join(staging_name(&layout_name(1)));
1153        std::os::unix::fs::symlink(&target, &link).expect("symlink");
1154
1155        let listed = list_caches(cache.path()).expect("list");
1156        assert_eq!(listed.len(), 1);
1157        assert_eq!(listed[0].state, CacheState::Unrecognized);
1158        assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
1159        assert!(!clear_leftover(&link, LeftoverKind::StagingTemporary).expect("clear"));
1160        assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
1161        assert!(target.exists(), "and so does its target");
1162    }
1163
1164    #[test]
1165    fn a_file_that_vanishes_before_removal_is_not_an_error() {
1166        // What a concurrent clear leaves this one to find: the file was identified, then
1167        // someone else removed it.
1168        let cache = tempfile::tempdir().expect("cache");
1169        let gone = cache.path().join(layout_name(1));
1170        assert!(!remove_present(&gone).expect("remove"));
1171        assert_eq!(status_at(&gone).expect("status"), None);
1172    }
1173
1174    #[test]
1175    fn clearing_is_idempotent_and_an_absent_directory_is_empty() {
1176        let cache = tempfile::tempdir().expect("cache");
1177        let missing = cache.path().join("does-not-exist");
1178        assert!(list_caches(&missing).expect("list").is_empty());
1179        assert!(clear_all_caches(&missing).expect("clear").is_empty());
1180        let absent = missing.join(layout_name(1));
1181        assert!(!clear_cache(&absent).expect("clear"));
1182        assert_eq!(cache_status(&absent).expect("status").state, CacheState::Absent);
1183    }
1184
1185    #[test]
1186    fn the_layout_names_what_default_cache_path_produces() {
1187        // `expect`, not `if let`: with no cache directory this test would otherwise assert
1188        // only its negative cases and pass without touching its subject. Every supported
1189        // platform resolves one, and `XDG_CACHE_HOME` names it everywhere if the platform
1190        // location does not.
1191        let tree = tempfile::tempdir().expect("tempdir");
1192        let path = crate::default_cache_path(tree.path())
1193            .expect("a user cache directory: set XDG_CACHE_HOME if this platform has none");
1194        assert_eq!(
1195            name_shape(path.file_name().expect("name")),
1196            NameShape::Snapshot,
1197            "{}",
1198            path.display()
1199        );
1200    }
1201
1202    #[test]
1203    fn a_name_is_shaped_like_one_of_fdus_files_or_like_nothing() {
1204        // The shape is a gate, never evidence: each of these still has to carry the right
1205        // magic before the state that matches it can be reported.
1206        for (name, shape) in [
1207            ("0123456789abcdef.fdu", NameShape::Snapshot),
1208            ("0123456789abcdef.fdu.content", NameShape::Sidecar),
1209            (".0123456789abcdef.fdu.tmp.1.0011223344556677.9", NameShape::SnapshotTemporary),
1210            (".0123456789abcdef.fdu.content.tmp.1.0011223344556677.9", NameShape::SidecarTemporary),
1211            // Near misses: uppercase hex, a missing discriminator, a target that is not a
1212            // name the cache gives, and the sidecar of something that is not a snapshot.
1213            ("0123456789ABCDEF.fdu", NameShape::Other),
1214            (".0123456789abcdef.fdu.tmp.", NameShape::Other),
1215            (".notes.txt.tmp.1.0011223344556677.9", NameShape::Other),
1216            ("notes.txt.content", NameShape::Other),
1217            ("0123456789abcdef.fdu.content.content", NameShape::Other),
1218            (".fdu.tmp.1", NameShape::Other),
1219        ] {
1220            assert_eq!(name_shape(OsStr::new(name)), shape, "{name}");
1221        }
1222    }
1223
1224    #[test]
1225    fn labels_list_every_variant_in_order() {
1226        let states = [
1227            CacheState::Current(SnapshotInfo {
1228                root: PathBuf::new(),
1229                identity: crate::ScanConfig::default().snapshot_identity(),
1230                entries: 1,
1231            }),
1232            CacheState::Stale(StaleReason::Unreadable),
1233            CacheState::Leftover(LeftoverKind::StagingTemporary),
1234            CacheState::Unrecognized,
1235            CacheState::Absent,
1236        ];
1237        assert_eq!(states.iter().map(CacheState::label).collect::<Vec<_>>(), CacheState::LABELS);
1238        assert_eq!(
1239            [LeftoverKind::StagingTemporary, LeftoverKind::OrphanedContent]
1240                .map(LeftoverKind::label),
1241            LeftoverKind::LABELS
1242        );
1243        let reasons = [
1244            StaleReason::OlderFormat { version: 1 },
1245            StaleReason::NewerFormat { version: 9 },
1246            StaleReason::OtherEngine,
1247            StaleReason::Unreadable,
1248        ];
1249        assert_eq!(reasons.map(StaleReason::label), StaleReason::LABELS);
1250        assert_eq!([CacheScope::Root, CacheScope::All].map(CacheScope::label), CacheScope::LABELS);
1251        assert_eq!(
1252            CacheScope::LABELS.map(CacheScope::parse),
1253            [Some(CacheScope::Root), Some(CacheScope::All)]
1254        );
1255    }
1256}