use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use prov_graph::fs::Metadata;
const MAGIC: &[u8; 8] = b"PROVFIXC";
const VERSION: u32 = 1;
const MAX_ENTRIES: usize = 200_000;
#[derive(Debug, Clone)]
struct Entry {
mtime_ns: i128,
len: u64,
hash: String,
}
#[derive(Debug, Clone)]
pub struct FixityCache {
root: PathBuf,
entries: BTreeMap<PathBuf, Entry>,
dirty: bool,
}
impl FixityCache {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
entries: BTreeMap::new(),
dirty: false,
}
}
pub fn decode(bytes: &[u8], root: &Path) -> Option<Self> {
let mut r = Reader { bytes, at: 0 };
if r.take(MAGIC.len())? != MAGIC {
return None;
}
if r.u32()? != VERSION {
return None;
}
let stored_root = r.string()?;
if Path::new(&stored_root) != root {
return None;
}
let count = r.u32()? as usize;
let mut entries = BTreeMap::new();
for _ in 0..count {
let rel = r.string()?;
let mtime_ns = r.i128()?;
let len = r.u64()?;
let hash = r.string()?;
entries.insert(
PathBuf::from(rel),
Entry {
mtime_ns,
len,
hash,
},
);
}
Some(Self {
root: root.to_path_buf(),
entries,
dirty: false,
})
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.entries.len() * 128 + 64);
out.extend_from_slice(MAGIC);
out.extend_from_slice(&VERSION.to_le_bytes());
push_str(&mut out, &self.root.to_string_lossy());
out.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
for (rel, entry) in &self.entries {
push_str(&mut out, &rel.to_string_lossy());
out.extend_from_slice(&entry.mtime_ns.to_le_bytes());
out.extend_from_slice(&entry.len.to_le_bytes());
push_str(&mut out, &entry.hash);
}
out
}
pub fn get(&self, path: &Path, meta: &Metadata) -> Option<&str> {
let stamp = stamp(meta)?;
let entry = self.entries.get(path)?;
(entry.mtime_ns == stamp && entry.len == meta.len()).then_some(entry.hash.as_str())
}
pub fn put(&mut self, path: &Path, meta: &Metadata, hash: &str) {
let Some(mtime_ns) = stamp(meta) else { return };
if path.to_str().is_none() || hash.is_empty() {
return;
}
let entry = Entry {
mtime_ns,
len: meta.len(),
hash: hash.to_string(),
};
match self.entries.get_mut(path) {
Some(slot) => {
if slot.mtime_ns == entry.mtime_ns
&& slot.len == entry.len
&& slot.hash == entry.hash
{
return;
}
*slot = entry;
}
None => {
if self.entries.len() >= MAX_ENTRIES {
return;
}
self.entries.insert(path.to_path_buf(), entry);
}
}
self.dirty = true;
}
pub fn forget(&mut self, path: &Path) {
if self.entries.remove(path).is_some() {
self.dirty = true;
}
}
pub fn clear(&mut self) {
if !self.entries.is_empty() {
self.entries.clear();
self.dirty = true;
}
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn stamp(meta: &Metadata) -> Option<i128> {
let modified = meta.modified().ok()?;
Some(match modified.duration_since(UNIX_EPOCH) {
Ok(since) => since.as_nanos() as i128,
Err(before) => -(before.duration().as_nanos() as i128),
})
}
fn push_str(out: &mut Vec<u8>, s: &str) {
out.extend_from_slice(&(s.len() as u32).to_le_bytes());
out.extend_from_slice(s.as_bytes());
}
struct Reader<'a> {
bytes: &'a [u8],
at: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
let end = self.at.checked_add(n)?;
let slice = self.bytes.get(self.at..end)?;
self.at = end;
Some(slice)
}
fn u32(&mut self) -> Option<u32> {
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
}
fn u64(&mut self) -> Option<u64> {
Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
}
fn i128(&mut self) -> Option<i128> {
Some(i128::from_le_bytes(self.take(16)?.try_into().ok()?))
}
fn string(&mut self) -> Option<String> {
let len = self.u32()? as usize;
String::from_utf8(self.take(len)?.to_vec()).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use prov_graph::fs::FileType;
use std::time::Duration;
fn meta(secs: u64, len: u64) -> Metadata {
Metadata::new(
FileType::FILE,
len,
Some(UNIX_EPOCH + Duration::from_secs(secs)),
)
}
fn timeless(len: u64) -> Metadata {
Metadata::new(FileType::FILE, len, None)
}
const HASH: &str = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn what_was_remembered_survives_a_round_trip() {
let root = Path::new("/vault");
let mut cache = FixityCache::new(root);
cache.put(Path::new("index.md"), &meta(1_700_000_000, 5), HASH);
cache.put(
Path::new("notes/a.md"),
&meta(1_700_000_001, 9),
"sha256:beef",
);
let bytes = cache.encode();
let reloaded = FixityCache::decode(&bytes, root).unwrap();
assert_eq!(reloaded.len(), 2);
assert_eq!(
reloaded.get(Path::new("index.md"), &meta(1_700_000_000, 5)),
Some(HASH)
);
assert_eq!(
reloaded.get(Path::new("notes/a.md"), &meta(1_700_000_001, 9)),
Some("sha256:beef")
);
assert!(!reloaded.is_dirty(), "a freshly decoded cache is not dirty");
}
#[test]
fn a_changed_file_is_not_served_from_the_cache() {
let mut cache = FixityCache::new("/vault");
let path = Path::new("index.md");
cache.put(path, &meta(1_700_000_000, 5), HASH);
assert!(cache.get(path, &meta(1_700_000_000, 5)).is_some());
assert!(
cache.get(path, &meta(1_700_000_001, 5)).is_none(),
"a newer modification time is a different file"
);
assert!(
cache.get(path, &meta(1_700_000_000, 6)).is_none(),
"a different length is a different file"
);
assert!(
cache.get(path, &timeless(5)).is_none(),
"a backend that cannot say when is never trusted"
);
}
#[test]
fn a_file_with_no_modification_time_is_never_remembered() {
let mut cache = FixityCache::new("/vault");
cache.put(Path::new("index.md"), &timeless(5), HASH);
assert_eq!(cache.len(), 0);
assert!(!cache.is_dirty());
}
#[test]
fn a_write_forgets_the_file_it_wrote() {
let mut cache = FixityCache::new("/vault");
let path = Path::new("index.md");
cache.put(path, &meta(1, 5), HASH);
cache.forget(path);
assert!(cache.get(path, &meta(1, 5)).is_none());
assert_eq!(cache.len(), 0);
}
#[test]
fn a_damaged_or_foreign_cache_decodes_to_nothing() {
let root = Path::new("/vault");
let mut cache = FixityCache::new(root);
cache.put(Path::new("index.md"), &meta(1, 5), HASH);
let good = cache.encode();
assert!(
FixityCache::decode(&good[..good.len() - 3], root).is_none(),
"a truncated cache was read anyway"
);
let mut wrong_magic = good.clone();
wrong_magic[0] = b'X';
assert!(FixityCache::decode(&wrong_magic, root).is_none());
let mut wrong_version = good.clone();
wrong_version[MAGIC.len()] = 0xff;
assert!(FixityCache::decode(&wrong_version, root).is_none());
assert!(
FixityCache::decode(&good, Path::new("/elsewhere")).is_none(),
"a cache written for another workspace was accepted"
);
assert!(FixityCache::decode(b"", root).is_none());
}
#[test]
fn re_recording_the_same_answer_leaves_the_cache_clean() {
let root = Path::new("/vault");
let mut cache = FixityCache::new(root);
cache.put(Path::new("index.md"), &meta(1, 5), HASH);
let mut reloaded = FixityCache::decode(&cache.encode(), root).unwrap();
reloaded.put(Path::new("index.md"), &meta(1, 5), HASH);
assert!(
!reloaded.is_dirty(),
"recording an answer already held marked the cache dirty"
);
reloaded.put(Path::new("index.md"), &meta(2, 5), "sha256:beef");
assert!(
reloaded.is_dirty(),
"a genuinely new answer was not recorded"
);
}
#[test]
fn the_encoding_is_order_independent() {
let mut one = FixityCache::new("/vault");
one.put(Path::new("b.md"), &meta(2, 2), "sha256:bb");
one.put(Path::new("a.md"), &meta(1, 1), "sha256:aa");
let mut two = FixityCache::new("/vault");
two.put(Path::new("a.md"), &meta(1, 1), "sha256:aa");
two.put(Path::new("b.md"), &meta(2, 2), "sha256:bb");
assert_eq!(one.encode(), two.encode());
}
#[test]
fn a_pre_epoch_timestamp_round_trips() {
let root = Path::new("/vault");
let old = Metadata::new(
FileType::FILE,
5,
Some(UNIX_EPOCH - Duration::from_secs(86_400)),
);
let older = Metadata::new(
FileType::FILE,
5,
Some(UNIX_EPOCH - Duration::from_secs(172_800)),
);
let mut cache = FixityCache::new(root);
cache.put(Path::new("relic.md"), &old, HASH);
let reloaded = FixityCache::decode(&cache.encode(), root).unwrap();
assert_eq!(reloaded.get(Path::new("relic.md"), &old), Some(HASH));
assert!(
reloaded.get(Path::new("relic.md"), &older).is_none(),
"two pre-epoch timestamps collapsed onto one another"
);
assert!(
reloaded.get(Path::new("relic.md"), &meta(0, 5)).is_none(),
"a pre-epoch timestamp was clamped to the epoch"
);
}
mod properties {
use super::*;
use proptest::prelude::*;
const ROOT: &str = "/vault";
fn cache() -> impl Strategy<Value = FixityCache> {
prop::collection::vec(
(
"[a-z/]{1,8}",
0..4_000_000_000u64,
0..64u64,
"[a-f0-9]{0,8}",
),
0..5usize,
)
.prop_map(|puts| {
let mut cache = FixityCache::new(ROOT);
for (path, secs, len, hash) in puts {
cache.put(
Path::new(&path),
&meta(secs, len),
&format!("sha256:{hash}"),
);
}
cache
})
}
proptest! {
#[test]
fn what_was_encoded_decodes_back_to_the_same_cache(cache in cache()) {
let bytes = cache.encode();
let reloaded = FixityCache::decode(&bytes, Path::new(ROOT))
.expect("prov's own bytes must decode");
prop_assert_eq!(reloaded.len(), cache.len());
prop_assert_eq!(reloaded.root(), cache.root());
prop_assert!(!reloaded.is_dirty());
prop_assert_eq!(reloaded.encode(), bytes);
}
#[test]
fn arbitrary_bytes_decode_to_nothing_or_to_this_workspace(
bytes in prop::collection::vec(any::<u8>(), 0..96),
) {
if let Some(cache) = FixityCache::decode(&bytes, Path::new(ROOT)) {
prop_assert_eq!(cache.root(), Path::new(ROOT));
prop_assert!(!cache.is_dirty());
}
}
#[test]
fn a_corrupted_encoding_never_panics_and_never_changes_workspace(
cache in cache(),
at in any::<prop::sample::Index>(),
xor in 1..=255u8,
) {
let mut bytes = cache.encode();
let at = at.index(bytes.len());
bytes[at] ^= xor;
if let Some(decoded) = FixityCache::decode(&bytes, Path::new(ROOT)) {
prop_assert_eq!(decoded.root(), Path::new(ROOT));
prop_assert!(!decoded.is_dirty());
}
}
#[test]
fn a_truncated_encoding_decodes_to_nothing(
cache in cache(),
at in any::<prop::sample::Index>(),
) {
let bytes = cache.encode();
let cut = at.index(bytes.len());
prop_assert!(
FixityCache::decode(&bytes[..cut], Path::new(ROOT)).is_none(),
"{cut} of {} bytes still decoded",
bytes.len()
);
}
#[test]
fn a_cache_from_another_workspace_is_refused(cache in cache()) {
prop_assert!(
FixityCache::decode(&cache.encode(), Path::new("/elsewhere")).is_none()
);
}
}
}
}