use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::engine_contract::{Error, Result, ScanScope};
use crate::snapshot::{self, Identity};
use crate::stored_state::{ContentTierIdentity, SnapshotIdentity};
const SNAPSHOT_NAME_HEX_DIGITS: usize = 16;
const SNAPSHOT_NAME_SUFFIX: &str = ".metadata.bin";
const CONTENT_NAME_SUFFIX: &str = ".analysis.bin";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CachePaths {
pub metadata: PathBuf,
pub analysis: PathBuf,
}
impl CachePaths {
pub fn for_root_hash(directory: &Path, root_hash: u64) -> Self {
let key = format!("{root_hash:016x}");
Self {
metadata: directory.join(snapshot_file_name(root_hash)),
analysis: directory.join(format!("{key}{CONTENT_NAME_SUFFIX}")),
}
}
pub fn from_metadata(metadata: &Path) -> Self {
let analysis = metadata
.file_name()
.and_then(OsStr::to_str)
.and_then(|name| name.strip_suffix(SNAPSHOT_NAME_SUFFIX))
.map_or_else(
|| {
let mut name = metadata.as_os_str().to_os_string();
name.push(".derived.bin");
PathBuf::from(name)
},
|stem| metadata.with_file_name(format!("{stem}{CONTENT_NAME_SUFFIX}")),
);
Self { metadata: metadata.to_path_buf(), analysis }
}
fn from_analysis(analysis: &Path) -> Self {
Self {
metadata: analysis.with_extension("").with_extension("metadata.bin"),
analysis: analysis.to_path_buf(),
}
}
}
const TEMP_NAME_INFIX: &str = ".tmp.";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheStatus {
pub path: PathBuf,
pub bytes: u64,
pub content: Option<ContentStatus>,
pub state: CacheState,
}
impl CacheStatus {
pub fn snapshot(&self) -> Option<&SnapshotInfo> {
match &self.state {
CacheState::Current(info) => Some(info),
CacheState::Stale(_)
| CacheState::Leftover(_)
| CacheState::Unrecognized
| CacheState::Absent => None,
}
}
pub fn is_fdu_snapshot(&self) -> bool {
matches!(self.state, CacheState::Current(_) | CacheState::Stale(_))
}
pub fn content_bytes(&self) -> Option<u64> {
self.content.as_ref().map(|content| content.bytes)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContentStatus {
pub bytes: u64,
pub state: ContentState,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ContentState {
Current(ContentInfo),
Stale(StaleReason),
}
impl ContentState {
pub const LABELS: [&'static str; 2] = ["current", "stale"];
pub fn label(&self) -> &'static str {
match self {
Self::Current(_) => "current",
Self::Stale(_) => "stale",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContentInfo {
pub identity: ContentTierIdentity,
pub records: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CacheState {
Current(SnapshotInfo),
Stale(StaleReason),
Leftover(LeftoverKind),
Unrecognized,
Absent,
}
impl CacheState {
pub const LABELS: [&'static str; 5] =
["current", "stale", "leftover", "unrecognized", "absent"];
pub fn label(&self) -> &'static str {
match self {
Self::Current(_) => "current",
Self::Stale(_) => "stale",
Self::Leftover(_) => "leftover",
Self::Unrecognized => "unrecognized",
Self::Absent => "absent",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LeftoverKind {
StagingTemporary,
OrphanedContent,
}
impl LeftoverKind {
pub const LABELS: [&'static str; 2] = ["staging_temporary", "orphaned_content"];
pub fn label(self) -> &'static str {
match self {
Self::StagingTemporary => "staging_temporary",
Self::OrphanedContent => "orphaned_content",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ClearSummary {
pub snapshots: usize,
pub leftovers: usize,
}
impl ClearSummary {
pub fn is_empty(self) -> bool {
self.snapshots == 0 && self.leftovers == 0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StaleReason {
OlderFormat {
version: u32,
},
NewerFormat {
version: u32,
},
OtherEngine,
Unreadable,
}
impl StaleReason {
pub const LABELS: [&'static str; 4] =
["older_format", "newer_format", "other_engine", "unreadable"];
pub fn label(self) -> &'static str {
match self {
Self::OlderFormat { .. } => "older_format",
Self::NewerFormat { .. } => "newer_format",
Self::OtherEngine => "other_engine",
Self::Unreadable => "unreadable",
}
}
pub fn format_version(self) -> Option<u32> {
match self {
Self::OlderFormat { version } | Self::NewerFormat { version } => Some(version),
Self::OtherEngine | Self::Unreadable => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheScope {
Root,
All,
}
impl CacheScope {
pub const LABELS: [&'static str; 2] = ["root", "all"];
pub fn label(self) -> &'static str {
match self {
Self::Root => "root",
Self::All => "all",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"root" => Some(Self::Root),
"all" => Some(Self::All),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SnapshotInfo {
pub root: PathBuf,
pub identity: SnapshotIdentity,
pub entries: u64,
}
impl SnapshotInfo {
pub fn scope(&self) -> ScanScope {
self.identity.scan_scope()
}
}
pub(crate) fn snapshot_file_name(root_hash: u64) -> String {
format!("{root_hash:0SNAPSHOT_NAME_HEX_DIGITS$x}{SNAPSHOT_NAME_SUFFIX}")
}
fn is_snapshot_name_bytes(bytes: &[u8]) -> bool {
bytes.len() == SNAPSHOT_NAME_HEX_DIGITS + SNAPSHOT_NAME_SUFFIX.len()
&& bytes.ends_with(SNAPSHOT_NAME_SUFFIX.as_bytes())
&& bytes[..SNAPSHOT_NAME_HEX_DIGITS]
.iter()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
fn is_sidecar_name_bytes(bytes: &[u8]) -> bool {
bytes.len() == SNAPSHOT_NAME_HEX_DIGITS + CONTENT_NAME_SUFFIX.len()
&& bytes.ends_with(CONTENT_NAME_SUFFIX.as_bytes())
&& bytes[..SNAPSHOT_NAME_HEX_DIGITS]
.iter()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
fn name_shape(name: &OsStr) -> NameShape {
let bytes = name.as_encoded_bytes();
if is_snapshot_name_bytes(bytes) {
return NameShape::Snapshot;
}
if is_sidecar_name_bytes(bytes) {
return NameShape::Sidecar;
}
let Some(rest) = bytes.strip_prefix(b".") else { return NameShape::Other };
let Some(at) =
rest.windows(TEMP_NAME_INFIX.len()).position(|w| w == TEMP_NAME_INFIX.as_bytes())
else {
return NameShape::Other;
};
let (target, suffix) = rest.split_at(at);
if suffix.len() <= TEMP_NAME_INFIX.len() {
return NameShape::Other;
}
if is_snapshot_name_bytes(target) {
NameShape::SnapshotTemporary
} else if is_sidecar_name_bytes(target) {
NameShape::SidecarTemporary
} else {
NameShape::Other
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NameShape {
Snapshot,
Sidecar,
SnapshotTemporary,
SidecarTemporary,
Other,
}
pub fn cache_status(path: &Path) -> Result<CacheStatus> {
Ok(status_at(path)?.unwrap_or_else(|| CacheStatus {
path: path.to_path_buf(),
bytes: 0,
content: None,
state: CacheState::Absent,
}))
}
fn status_at(path: &Path) -> Result<Option<CacheStatus>> {
let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
return Ok(None);
};
if !metadata.file_type().is_file() {
return Ok(Some(unrecognized(path, reportable_bytes(&metadata))));
}
let leftover = match path.file_name().map_or(NameShape::Other, name_shape) {
NameShape::SnapshotTemporary => Some(leftover_or_unrecognized(
path,
metadata.len(),
LeftoverKind::StagingTemporary,
is_snapshot_image(path)?,
)),
NameShape::SidecarTemporary => Some(leftover_or_unrecognized(
path,
metadata.len(),
LeftoverKind::StagingTemporary,
is_sidecar_image(path)?,
)),
NameShape::Sidecar => Some(leftover_or_unrecognized(
path,
metadata.len(),
LeftoverKind::OrphanedContent,
is_sidecar_image(path)?,
)),
NameShape::Snapshot | NameShape::Other => None,
};
if let Some(status) = leftover {
return Ok(Some(status));
}
let state = match snapshot::identify(path)? {
None => return Ok(None),
Some(Identity::Foreign) => return Ok(Some(unrecognized(path, metadata.len()))),
Some(Identity::Current(info)) => CacheState::Current(info),
Some(Identity::Stale(reason)) => CacheState::Stale(reason),
};
let content = crate::content::identify_sidecar(&crate::content::content_cache_path(path))?;
Ok(Some(CacheStatus { path: path.to_path_buf(), bytes: metadata.len(), content, state }))
}
fn is_snapshot_image(path: &Path) -> Result<bool> {
let Some(metadata) = present(fs::symlink_metadata(path), path)? else { return Ok(false) };
if !metadata.file_type().is_file() {
return Ok(false);
}
Ok(matches!(snapshot::identify(path)?, Some(Identity::Current(_) | Identity::Stale(_))))
}
fn is_sidecar_image(path: &Path) -> Result<bool> {
Ok(crate::content::content_sidecar_bytes(path)?.is_some())
}
fn sidecar_snapshot_path(path: &Path) -> PathBuf {
CachePaths::from_analysis(path).metadata
}
fn unrecognized(path: &Path, bytes: u64) -> CacheStatus {
CacheStatus { path: path.to_path_buf(), bytes, content: None, state: CacheState::Unrecognized }
}
fn leftover_or_unrecognized(
path: &Path,
bytes: u64,
kind: LeftoverKind,
has_magic: bool,
) -> CacheStatus {
if has_magic {
CacheStatus {
path: path.to_path_buf(),
bytes,
content: None,
state: CacheState::Leftover(kind),
}
} else {
unrecognized(path, bytes)
}
}
fn reportable_bytes(metadata: &fs::Metadata) -> u64 {
if metadata.file_type().is_file() { metadata.len() } else { 0 }
}
fn present<T>(result: io::Result<T>, path: &Path) -> Result<Option<T>> {
match result {
Ok(value) => Ok(Some(value)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(Error::io(path, error)),
}
}
pub fn list_caches(cache_dir: &Path) -> Result<Vec<CacheStatus>> {
let Some(entries) = present(fs::read_dir(cache_dir), cache_dir)? else {
return Ok(Vec::new());
};
let mut found = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| Error::io(cache_dir, error))?;
let path = entry.path();
let Some(file_type) = present(entry.file_type(), &path)? else { continue };
let status = if file_type.is_file() && name_shape(&entry.file_name()) != NameShape::Other {
status_at(&path)?
} else {
present(fs::symlink_metadata(&path), &path)?
.map(|metadata| unrecognized(&path, reportable_bytes(&metadata)))
};
found.extend(status);
}
let paired_sidecars = found
.iter()
.filter(|status| status.is_fdu_snapshot() && status.content.is_some())
.map(|status| crate::content::content_cache_path(&status.path))
.collect::<std::collections::BTreeSet<_>>();
found.retain(|status| !paired_sidecars.contains(&status.path));
found.sort_by(|left, right| left.path.cmp(&right.path));
Ok(found)
}
pub fn clear_cache(path: &Path) -> Result<bool> {
let Some(status) = status_at(path)? else { return Ok(false) };
if !status.is_fdu_snapshot() {
return Ok(false);
}
if !remove_present(path)? {
return Ok(false);
}
let content_path = crate::content::content_cache_path(path);
if crate::content::content_sidecar_bytes(&content_path)?.is_some() {
remove_present(&content_path)?;
}
Ok(true)
}
fn remove_present(path: &Path) -> Result<bool> {
Ok(present(fs::remove_file(path), path)?.is_some())
}
pub fn clear_all_caches(cache_dir: &Path) -> Result<ClearSummary> {
let listed = list_caches(cache_dir)?;
let mut summary = ClearSummary::default();
for status in &listed {
if status.is_fdu_snapshot() && clear_cache(&status.path)? {
summary.snapshots += 1;
}
}
for status in &listed {
if let CacheState::Leftover(kind) = status.state {
if clear_leftover(&status.path, kind)? {
summary.leftovers += 1;
}
}
}
Ok(summary)
}
fn clear_leftover(path: &Path, kind: LeftoverKind) -> Result<bool> {
let Some(status) = status_at(path)? else { return Ok(false) };
if status.state != CacheState::Leftover(kind) {
return Ok(false);
}
let removable = match kind {
LeftoverKind::StagingTemporary => {
let Some(metadata) = present(fs::symlink_metadata(path), path)? else {
return Ok(false);
};
let Ok(modified) = metadata.modified() else { return Ok(false) };
std::time::SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age >= snapshot::STALE_TEMP_AGE)
}
LeftoverKind::OrphanedContent => !is_snapshot_image(&sidecar_snapshot_path(path))?,
};
if !removable {
return Ok(false);
}
remove_present(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CachePolicy, OpenFixture, open_fixture as open};
const VERSION_OFFSET: usize = 8;
const FINGERPRINT_OFFSET: usize = VERSION_OFFSET + 4;
fn layout_name(seed: u64) -> String {
snapshot_file_name(seed)
}
fn analysis_name(seed: u64) -> String {
CachePaths::for_root_hash(Path::new("."), seed)
.analysis
.file_name()
.expect("analysis name")
.to_string_lossy()
.into_owned()
}
fn seed(tree: &Path, snapshot_path: &Path) {
std::fs::write(tree.join("a.txt"), b"hello").expect("write");
let config = OpenFixture {
cache_path: Some(snapshot_path.to_path_buf()),
policy: CachePolicy::Auto,
..OpenFixture::default()
};
open(tree, &config).expect("seed");
}
fn with_format_version(from: &Path, to: &Path, version: impl FnOnce(u32) -> u32) {
let mut bytes = std::fs::read(from).expect("read snapshot");
let range = VERSION_OFFSET..FINGERPRINT_OFFSET;
let current = u32::from_le_bytes(bytes[range.clone()].try_into().expect("four bytes"));
bytes[range].copy_from_slice(&version(current).to_le_bytes());
std::fs::write(to, bytes).expect("write stale snapshot");
}
#[test]
fn a_snapshot_reports_the_root_it_describes() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let path = cache.path().join(layout_name(1));
seed(tree.path(), &path);
let status = cache_status(&path).expect("status");
assert!(status.bytes > 0);
let info = status.snapshot().expect("header");
assert_eq!(info.root, tree.path().canonicalize().expect("canonical"));
assert_eq!(info.entries, 2, "the root plus one file");
}
#[test]
fn content_sidecar_is_reported_and_cleared_with_its_snapshot() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let path = cache.path().join(layout_name(1));
std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
let config = OpenFixture {
cache_path: Some(path.clone()),
policy: CachePolicy::Auto,
analysis: crate::content::AnalysisRequest {
profile: crate::content::AnalysisSet::NONE.with_lines(),
..crate::content::AnalysisRequest::default()
},
..OpenFixture::default()
};
let (index, _) = open(tree.path(), &config).expect("seed analyzed cache");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(listed.len(), 1, "a sidecar is grouped with its snapshot");
let Some(CacheState::Current(snapshot)) = listed.first().map(|status| &status.state) else {
panic!("a current snapshot: {listed:?}");
};
assert_eq!(snapshot.identity, index.snapshot_identity());
let content = listed[0].content.as_ref().expect("its sidecar");
assert_eq!(
content.state,
ContentState::Current(ContentInfo {
identity: index.content_identity(config.analysis.profile),
records: 1,
}),
"status reports the identity the sidecar serves"
);
assert!(clear_cache(&path).expect("clear"));
assert!(!path.exists());
assert!(!crate::content::content_cache_path(&path).exists());
}
#[test]
fn stale_snapshots_are_reported_beside_a_current_one_and_all_are_cleared() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let current = cache.path().join(layout_name(1));
seed(tree.path(), ¤t);
let older = cache.path().join(layout_name(2));
with_format_version(¤t, &older, |version| version - 1);
let newer = cache.path().join(layout_name(3));
with_format_version(¤t, &newer, |version| version + 1);
let other_engine = cache.path().join(layout_name(4));
let mut bytes = std::fs::read(¤t).expect("read");
for byte in &mut bytes[FINGERPRINT_OFFSET..FINGERPRINT_OFFSET + 8] {
*byte = !*byte;
}
std::fs::write(&other_engine, &bytes).expect("write");
let truncated = cache.path().join(layout_name(5));
let full = std::fs::read(¤t).expect("read");
std::fs::write(&truncated, &full[..full.len() - 1]).expect("truncate");
let hand_built = cache.path().join(layout_name(6));
let mut prologue = b"FDUSNAP\x00".to_vec();
prologue.extend_from_slice(&1_u32.to_le_bytes());
std::fs::write(&hand_built, &prologue).expect("write");
let current_version = u32::from_le_bytes(
full[VERSION_OFFSET..FINGERPRINT_OFFSET].try_into().expect("four bytes"),
);
let states = list_caches(cache.path())
.expect("list")
.into_iter()
.map(|status| (status.path, status.state))
.collect::<Vec<_>>();
assert_eq!(states.len(), 6);
assert!(matches!(states[0].1, CacheState::Current(_)));
assert_eq!(
states[1..],
[
(
older,
CacheState::Stale(StaleReason::OlderFormat { version: current_version - 1 })
),
(
newer,
CacheState::Stale(StaleReason::NewerFormat { version: current_version + 1 })
),
(other_engine, CacheState::Stale(StaleReason::OtherEngine)),
(truncated.clone(), CacheState::Stale(StaleReason::Unreadable)),
(hand_built, CacheState::Stale(StaleReason::OlderFormat { version: 1 })),
]
);
assert!(clear_cache(&truncated).expect("clear one"), "a truncated snapshot is still fdu's");
assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 5);
assert!(list_caches(cache.path()).expect("list").is_empty());
}
#[test]
fn a_stale_snapshot_takes_its_content_sidecar_with_it() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let path = cache.path().join(layout_name(1));
std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
let config = OpenFixture {
cache_path: Some(path.clone()),
policy: CachePolicy::Auto,
analysis: crate::content::AnalysisRequest {
profile: crate::content::AnalysisSet::NONE.with_lines(),
..crate::content::AnalysisRequest::default()
},
..OpenFixture::default()
};
open(tree.path(), &config).expect("seed analyzed cache");
with_format_version(&path, &path, |version| version - 1);
let listed = list_caches(cache.path()).expect("list");
assert_eq!(listed.len(), 1, "a stale snapshot's sidecar is grouped with it");
assert!(matches!(listed[0].state, CacheState::Stale(StaleReason::OlderFormat { .. })));
assert!(listed[0].content.is_some());
assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
assert!(!crate::content::content_cache_path(&path).exists());
}
#[test]
fn a_stale_sidecar_beside_a_current_snapshot_is_labelled_and_cleared() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let path = cache.path().join(layout_name(1));
std::fs::write(tree.path().join("notes.md"), b"one two\n").expect("write");
let config = OpenFixture {
cache_path: Some(path.clone()),
policy: CachePolicy::Auto,
analysis: crate::content::AnalysisRequest {
profile: crate::content::AnalysisSet::NONE.with_lines(),
..crate::content::AnalysisRequest::default()
},
..OpenFixture::default()
};
open(tree.path(), &config).expect("seed analyzed cache");
let sidecar = crate::content::content_cache_path(&path);
let written = std::fs::read(&sidecar).expect("a sidecar");
let mut older = written.clone();
older[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
let mut newer = written.clone();
newer[VERSION_OFFSET..FINGERPRINT_OFFSET].copy_from_slice(&99_u32.to_le_bytes());
let mut other_engine = written.clone();
other_engine[FINGERPRINT_OFFSET] ^= 0xff;
let truncated = written[..written.len() - 1].to_vec();
let mut unreadable_header = written.clone();
let path_encoding_at = FINGERPRINT_OFFSET + 8;
unreadable_header[path_encoding_at] ^= 0xff;
for (image, reason) in [
(older, StaleReason::OlderFormat { version: 4 }),
(newer, StaleReason::NewerFormat { version: 99 }),
(other_engine, StaleReason::OtherEngine),
(truncated, StaleReason::Unreadable),
(unreadable_header, StaleReason::Unreadable),
] {
std::fs::write(&sidecar, &image).expect("rewrite the sidecar");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(listed.len(), 1, "a stale sidecar is still grouped with its snapshot");
assert!(matches!(listed[0].state, CacheState::Current(_)), "{listed:?}");
let content = listed[0].content.as_ref().expect("its sidecar");
assert_eq!(content.bytes, u64::try_from(image.len()).expect("small"));
assert_eq!(content.state, ContentState::Stale(reason), "{reason:?}");
assert_eq!(cache_status(&path).expect("status").content, listed[0].content);
}
assert!(clear_cache(&path).expect("clear"));
assert!(!path.exists());
assert!(!sidecar.exists(), "the stale sidecar goes with its snapshot");
}
#[test]
fn unrecognized_files_are_listed_but_never_removed() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let path = cache.path().join(layout_name(1));
seed(tree.path(), &path);
let foreign = cache.path().join("notes.txt");
std::fs::write(&foreign, b"not a snapshot").expect("write");
let impostor = cache.path().join(layout_name(2));
std::fs::write(&impostor, b"FDUSNAQ and more bytes").expect("write");
let renamed = cache.path().join("backup.bin");
std::fs::copy(&path, &renamed).expect("copy");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(
listed
.iter()
.map(|status| (status.path.clone(), status.state.label()))
.collect::<Vec<_>>(),
[
(path.clone(), "current"),
(impostor.clone(), "unrecognized"),
(renamed.clone(), "unrecognized"),
(foreign.clone(), "unrecognized"),
]
);
assert_eq!(listed[3].bytes, 14, "an unrecognized file still reports its size");
assert!(!clear_cache(&impostor).expect("clear"));
assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
for survivor in [&foreign, &impostor, &renamed] {
assert!(survivor.exists(), "{} must survive", survivor.display());
}
assert_eq!(list_caches(cache.path()).expect("list").len(), 3);
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_in_the_cache_is_never_followed_or_removed() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let outside = tempfile::tempdir().expect("outside");
let target = outside.path().join(layout_name(9));
seed(tree.path(), &target);
let link = cache.path().join(layout_name(1));
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].state, CacheState::Unrecognized);
assert_eq!(listed[0].bytes, 0, "a link has no size to report");
assert_eq!(cache_status(&link).expect("status").state, CacheState::Unrecognized);
assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
assert!(!clear_cache(&link).expect("clear"));
assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
assert!(
cache_status(&target).expect("status").snapshot().is_some(),
"and so does its target"
);
}
#[test]
fn a_directory_in_the_cache_is_listed_and_never_removed() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let snapshot = cache.path().join(layout_name(1));
seed(tree.path(), &snapshot);
let nested = cache.path().join(layout_name(2));
std::fs::create_dir(&nested).expect("create dir");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(
listed
.iter()
.map(|status| (status.path.clone(), status.state.label()))
.collect::<Vec<_>>(),
[(snapshot, "current"), (nested.clone(), "unrecognized")]
);
assert_eq!(listed[1].bytes, 0, "a directory has no size to report");
let single = cache_status(&nested).expect("status");
assert_eq!(single.state, CacheState::Unrecognized);
assert_eq!(single.bytes, 0, "and the single-path route agrees");
assert_eq!(clear_all_caches(cache.path()).expect("clear").snapshots, 1);
assert!(!clear_cache(&nested).expect("clear"));
assert!(nested.is_dir(), "a directory is never removed");
}
fn set_modified(path: &Path, at: std::time::SystemTime) {
std::fs::OpenOptions::new()
.write(true)
.open(path)
.expect("open")
.set_modified(at)
.expect("set modified");
}
fn beyond_the_reaper() -> std::time::SystemTime {
std::time::SystemTime::now() - snapshot::STALE_TEMP_AGE - std::time::Duration::from_secs(1)
}
fn staging_name(target: &str) -> String {
format!(".{target}.tmp.7.0123456789abcdef.0")
}
fn describe(status: &CacheStatus) -> String {
match &status.state {
CacheState::Leftover(kind) => format!("leftover/{}", kind.label()),
other => other.label().to_string(),
}
}
#[test]
fn fdus_own_leftovers_are_named_as_fdus_and_reclaimed_only_under_their_rules() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let current = cache.path().join(layout_name(1));
seed(tree.path(), ¤t);
let image = std::fs::read(¤t).expect("read snapshot");
let abandoned = cache.path().join(staging_name(&layout_name(2)));
std::fs::write(&abandoned, &image).expect("write");
set_modified(&abandoned, beyond_the_reaper());
let in_flight = cache.path().join(staging_name(&layout_name(3)));
std::fs::write(&in_flight, &image).expect("write");
let impostor = cache.path().join(staging_name(&layout_name(4)));
std::fs::write(&impostor, b"not a snapshot").expect("write");
set_modified(&impostor, beyond_the_reaper());
let staged_sidecar = cache.path().join(staging_name(&analysis_name(5)));
std::fs::write(&staged_sidecar, b"FDUCTNT\0payload").expect("write");
set_modified(&staged_sidecar, beyond_the_reaper());
let orphan = cache.path().join(analysis_name(6));
std::fs::write(&orphan, b"FDUCTNT\0payload").expect("write");
let foreign_sidecar = cache.path().join(analysis_name(7));
std::fs::write(&foreign_sidecar, b"not a sidecar").expect("write");
let snapshot_under_sidecar_name = cache.path().join(analysis_name(8));
std::fs::write(&snapshot_under_sidecar_name, &image).expect("write");
let snapshot_under_staged_sidecar_name = cache.path().join(staging_name(&analysis_name(9)));
std::fs::write(&snapshot_under_staged_sidecar_name, &image).expect("write");
set_modified(&snapshot_under_staged_sidecar_name, beyond_the_reaper());
let listed = list_caches(cache.path()).expect("list");
assert_eq!(
listed.iter().map(|status| (status.path.clone(), describe(status))).collect::<Vec<_>>(),
[
(abandoned.clone(), "leftover/staging_temporary".to_string()),
(in_flight.clone(), "leftover/staging_temporary".to_string()),
(impostor.clone(), "unrecognized".to_string()),
(staged_sidecar.clone(), "leftover/staging_temporary".to_string()),
(snapshot_under_staged_sidecar_name.clone(), "unrecognized".to_string()),
(current.clone(), "current".to_string()),
(orphan.clone(), "leftover/orphaned_content".to_string()),
(foreign_sidecar.clone(), "unrecognized".to_string()),
(snapshot_under_sidecar_name.clone(), "unrecognized".to_string()),
]
);
for named in [&snapshot_under_sidecar_name, &snapshot_under_staged_sidecar_name] {
assert_eq!(cache_status(named).expect("status").state, CacheState::Unrecognized);
}
let summary = clear_all_caches(cache.path()).expect("clear");
assert_eq!(summary, ClearSummary { snapshots: 1, leftovers: 3 });
for gone in [&abandoned, &staged_sidecar, &orphan, ¤t] {
assert!(!gone.exists(), "{} should be reclaimed", gone.display());
}
for kept in [
&in_flight,
&impostor,
&foreign_sidecar,
&snapshot_under_sidecar_name,
&snapshot_under_staged_sidecar_name,
] {
assert!(kept.exists(), "{} must survive", kept.display());
}
assert_eq!(clear_all_caches(cache.path()).expect("clear"), ClearSummary::default());
}
#[test]
fn a_sidecar_is_kept_while_a_snapshot_still_claims_it() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let current = cache.path().join(layout_name(1));
seed(tree.path(), ¤t);
let sidecar = crate::content::content_cache_path(¤t);
std::fs::write(&sidecar, b"FDUCTNT\0payload").expect("write");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].content_bytes(), Some(15));
assert!(!clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
assert!(sidecar.exists(), "its snapshot is still there");
assert!(remove_present(¤t).expect("remove"));
assert!(clear_leftover(&sidecar, LeftoverKind::OrphanedContent).expect("clear"));
assert!(!sidecar.exists());
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_named_like_a_leftover_is_never_followed_or_removed() {
let tree = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache");
let outside = tempfile::tempdir().expect("outside");
let target = outside.path().join(layout_name(9));
seed(tree.path(), &target);
let link = cache.path().join(staging_name(&layout_name(1)));
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let listed = list_caches(cache.path()).expect("list");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].state, CacheState::Unrecognized);
assert!(clear_all_caches(cache.path()).expect("clear").is_empty());
assert!(!clear_leftover(&link, LeftoverKind::StagingTemporary).expect("clear"));
assert!(std::fs::symlink_metadata(&link).is_ok(), "the link stays");
assert!(target.exists(), "and so does its target");
}
#[test]
fn a_file_that_vanishes_before_removal_is_not_an_error() {
let cache = tempfile::tempdir().expect("cache");
let gone = cache.path().join(layout_name(1));
assert!(!remove_present(&gone).expect("remove"));
assert_eq!(status_at(&gone).expect("status"), None);
}
#[test]
fn clearing_is_idempotent_and_an_absent_directory_is_empty() {
let cache = tempfile::tempdir().expect("cache");
let missing = cache.path().join("does-not-exist");
assert!(list_caches(&missing).expect("list").is_empty());
assert!(clear_all_caches(&missing).expect("clear").is_empty());
let absent = missing.join(layout_name(1));
assert!(!clear_cache(&absent).expect("clear"));
assert_eq!(cache_status(&absent).expect("status").state, CacheState::Absent);
}
#[test]
fn the_layout_names_what_default_cache_path_produces() {
let tree = tempfile::tempdir().expect("tempdir");
let path = crate::default_cache_path(tree.path())
.expect("a user cache directory: set XDG_CACHE_HOME if this platform has none");
assert_eq!(
name_shape(path.file_name().expect("name")),
NameShape::Snapshot,
"{}",
path.display()
);
}
#[test]
fn explicit_metadata_names_keep_distinct_analysis_siblings() {
let first = CachePaths::from_metadata(Path::new("cache/root.a.snap"));
let second = CachePaths::from_metadata(Path::new("cache/root.b.snap"));
assert_eq!(first.analysis, Path::new("cache/root.a.snap.derived.bin"));
assert_eq!(second.analysis, Path::new("cache/root.b.snap.derived.bin"));
assert_ne!(first.analysis, second.analysis);
let conventional =
CachePaths::from_metadata(Path::new("cache/0123456789abcdef.metadata.bin"));
assert_eq!(conventional.analysis, Path::new("cache/0123456789abcdef.analysis.bin"));
let arbitrary = CachePaths::from_metadata(Path::new("cache/foo"));
let conventional = CachePaths::from_metadata(Path::new("cache/foo.metadata.bin"));
assert_ne!(arbitrary.analysis, conventional.analysis);
}
#[test]
fn a_name_is_shaped_like_one_of_fdus_files_or_like_nothing() {
for (name, shape) in [
("0123456789abcdef.metadata.bin", NameShape::Snapshot),
("0123456789abcdef.analysis.bin", NameShape::Sidecar),
(
".0123456789abcdef.metadata.bin.tmp.1.0011223344556677.9",
NameShape::SnapshotTemporary,
),
(
".0123456789abcdef.analysis.bin.tmp.1.0011223344556677.9",
NameShape::SidecarTemporary,
),
("0123456789ABCDEF.metadata.bin", NameShape::Other),
(".0123456789abcdef.metadata.bin.tmp.", NameShape::Other),
(".notes.txt.tmp.1.0011223344556677.9", NameShape::Other),
("notes.txt.analysis.bin", NameShape::Other),
("0123456789abcdef.analysis.bin.analysis.bin", NameShape::Other),
(".metadata.bin.tmp.1", NameShape::Other),
] {
assert_eq!(name_shape(OsStr::new(name)), shape, "{name}");
}
}
#[test]
fn labels_list_every_variant_in_order() {
let states = [
CacheState::Current(SnapshotInfo {
root: PathBuf::new(),
identity: crate::ScanConfig::default().snapshot_identity(),
entries: 1,
}),
CacheState::Stale(StaleReason::Unreadable),
CacheState::Leftover(LeftoverKind::StagingTemporary),
CacheState::Unrecognized,
CacheState::Absent,
];
assert_eq!(states.iter().map(CacheState::label).collect::<Vec<_>>(), CacheState::LABELS);
assert_eq!(
[LeftoverKind::StagingTemporary, LeftoverKind::OrphanedContent]
.map(LeftoverKind::label),
LeftoverKind::LABELS
);
let reasons = [
StaleReason::OlderFormat { version: 1 },
StaleReason::NewerFormat { version: 9 },
StaleReason::OtherEngine,
StaleReason::Unreadable,
];
assert_eq!(reasons.map(StaleReason::label), StaleReason::LABELS);
assert_eq!([CacheScope::Root, CacheScope::All].map(CacheScope::label), CacheScope::LABELS);
assert_eq!(
CacheScope::LABELS.map(CacheScope::parse),
[Some(CacheScope::Root), Some(CacheScope::All)]
);
}
}