use eyre::{Context, Result};
use mbx_cache_core::{CacheDigest, FileDigestScope, FileIdentity, RecordedFileDigest};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const LEDGER_FILE: &str = "file-digests.json";
const LEDGER_VERSION: u8 = 3;
const MAX_PERSISTED_ENTRIES: usize = 256 * 1024;
const SWEEP_ABOVE_ENTRIES: usize = 64 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Ledger {
version: u8,
entries: Vec<LedgerEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LedgerEntry {
scope: FileDigestScope,
file: FileIdentity,
digest: CacheDigest,
}
impl LedgerEntry {
fn new(scope: FileDigestScope, record: RecordedFileDigest) -> Self {
Self {
scope,
file: record.file,
digest: record.digest,
}
}
fn into_record(self) -> (FileDigestScope, RecordedFileDigest) {
(
self.scope,
RecordedFileDigest {
file: self.file,
digest: self.digest,
},
)
}
}
pub(crate) fn path(state_dir: &Path) -> PathBuf {
state_dir.join(LEDGER_FILE)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Stamp {
len: u64,
modified: Option<std::time::SystemTime>,
}
pub(crate) fn stamp(state_dir: &Path) -> Option<Stamp> {
let metadata = std::fs::metadata(path(state_dir)).ok()?;
Some(Stamp {
len: metadata.len(),
modified: metadata.modified().ok(),
})
}
pub(crate) fn load(state_dir: &Path) -> Vec<(FileDigestScope, RecordedFileDigest)> {
read(&path(state_dir))
.map(|ledger| {
ledger
.entries
.into_iter()
.map(LedgerEntry::into_record)
.collect()
})
.unwrap_or_default()
}
pub(crate) fn save(
state_dir: &Path,
entries: Vec<(FileDigestScope, RecordedFileDigest)>,
loaded: Option<Stamp>,
) -> Result<usize> {
save_sweeping_above(state_dir, entries, loaded, SWEEP_ABOVE_ENTRIES)
}
fn save_sweeping_above(
state_dir: &Path,
entries: Vec<(FileDigestScope, RecordedFileDigest)>,
loaded: Option<Stamp>,
sweep_above: usize,
) -> Result<usize> {
let path = path(state_dir);
let unchanged = loaded.is_some() && loaded == stamp(state_dir);
let merged: Vec<(FileDigestScope, RecordedFileDigest)> = if unchanged {
entries
} else {
let mut merged: BTreeMap<(FileDigestScope, PathBuf), RecordedFileDigest> = read(&path)
.map(|ledger| {
ledger
.entries
.into_iter()
.map(LedgerEntry::into_record)
.map(|(scope, record)| ((scope, record.file.path.clone()), record))
.collect()
})
.unwrap_or_default();
for (scope, record) in entries {
merged.insert((scope, record.file.path.clone()), record);
}
merged
.into_iter()
.map(|((scope, _), record)| (scope, record))
.collect()
};
let sweep = merged.len() > sweep_above;
let mut entries = merged
.into_iter()
.filter(|(_, record)| !sweep || record.file.path.is_file())
.map(|(scope, record)| LedgerEntry::new(scope, record))
.collect::<Vec<_>>();
if entries.len() > MAX_PERSISTED_ENTRIES {
entries.drain(..entries.len() - MAX_PERSISTED_ENTRIES);
}
let count = entries.len();
let ledger = Ledger {
version: LEDGER_VERSION,
entries,
};
let bytes = serde_json::to_vec(&ledger)?;
crate::util::write_advisory(&path, &bytes)
.wrap_err_with(|| format!("failed to write the file-digest ledger {}", path.display()))?;
Ok(count)
}
fn read(path: &Path) -> Option<Ledger> {
let bytes = std::fs::read(path).ok()?;
let ledger = serde_json::from_slice::<Ledger>(&bytes).ok()?;
(ledger.version == LEDGER_VERSION).then_some(ledger)
}
#[cfg(test)]
mod tests {
use super::*;
use mbx_cache_core::{CacheDigest, FileIdentity, FileObjectIdentity};
fn record(path: &Path) -> RecordedFileDigest {
let metadata = std::fs::metadata(path).unwrap();
RecordedFileDigest {
file: FileIdentity::describe(path, &metadata).unwrap(),
digest: CacheDigest::blake3_file(path).unwrap(),
}
}
#[test]
fn a_saved_ledger_loads_back_without_entries_for_vanished_files() {
let state = tempfile::tempdir().unwrap();
let files = tempfile::tempdir().unwrap();
let kept = files.path().join("libkept.rlib");
let gone = files.path().join("libgone.rlib");
std::fs::write(&kept, b"kept").unwrap();
std::fs::write(&gone, b"gone").unwrap();
let entries = vec![
(FileDigestScope::Content, record(&kept)),
(FileDigestScope::CcInput, record(&gone)),
];
std::fs::remove_file(&gone).unwrap();
assert_eq!(
save_sweeping_above(state.path(), entries.clone(), None, 0).unwrap(),
1
);
assert_eq!(load(state.path()), vec![entries[0].clone()]);
}
#[test]
fn saving_merges_with_what_another_session_left() {
let state = tempfile::tempdir().unwrap();
let files = tempfile::tempdir().unwrap();
let theirs = files.path().join("libtheirs.rlib");
let shared = files.path().join("libshared.rlib");
std::fs::write(&theirs, b"theirs").unwrap();
std::fs::write(&shared, b"shared").unwrap();
let stale_shared = RecordedFileDigest {
digest: CacheDigest::blake3(b"an older shared"),
..record(&shared)
};
save(
state.path(),
vec![
(FileDigestScope::Content, record(&theirs)),
(FileDigestScope::Content, stale_shared),
],
None,
)
.unwrap();
let ours = record(&shared);
assert_eq!(
save(
state.path(),
vec![(FileDigestScope::Content, ours.clone())],
None
)
.unwrap(),
2
);
let loaded = load(state.path());
assert_eq!(loaded.len(), 2);
assert!(loaded.contains(&(FileDigestScope::Content, record(&theirs))));
assert!(loaded.contains(&(FileDigestScope::Content, ours)));
}
#[test]
fn an_unreadable_or_foreign_ledger_is_an_empty_one() {
let state = tempfile::tempdir().unwrap();
assert!(load(state.path()).is_empty());
std::fs::write(path(state.path()), b"not json").unwrap();
assert!(load(state.path()).is_empty());
std::fs::write(path(state.path()), br#"{"version":2,"entries":[]}"#).unwrap();
assert!(load(state.path()).is_empty());
std::fs::write(path(state.path()), br#"{"version":99,"entries":[]}"#).unwrap();
assert!(load(state.path()).is_empty());
}
#[test]
fn an_nfs_object_identity_survives_a_session_boundary() {
let state = tempfile::tempdir().unwrap();
let files = tempfile::tempdir().unwrap();
let file = files.path().join("libnfs.rlib");
std::fs::write(&file, b"nfs dependency").unwrap();
let mut record = record(&file);
record.file.changed = None;
record.file.object = Some(FileObjectIdentity {
device_major: 0,
device_minor: 51,
mount_id: 12,
inode: 9001,
});
save(
state.path(),
vec![(FileDigestScope::Content, record.clone())],
None,
)
.unwrap();
assert_eq!(load(state.path()), vec![(FileDigestScope::Content, record)]);
}
}