use crate::CacheDigest;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileIdentity {
pub path: PathBuf,
pub len: u64,
pub modified: SystemTime,
pub changed: Option<(i64, i64)>,
}
impl FileIdentity {
pub fn describe(path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
Some(Self {
path: path.to_path_buf(),
len: metadata.len(),
modified: metadata.modified().ok()?,
changed: change_token(metadata),
})
}
pub fn for_digest_cache(path: &Path, metadata: &std::fs::Metadata) -> io::Result<Option<Self>> {
Ok(digest_cache_identity(
path,
metadata,
metadata_identity_is_unreliable(path)?,
))
}
pub fn still_describes(&self) -> std::io::Result<bool> {
let metadata = std::fs::metadata(&self.path)?;
Ok(Self::describe(&self.path, &metadata).as_ref() == Some(self))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileSnapshot {
identity: FileIdentity,
content: Option<CacheDigest>,
}
impl FileSnapshot {
pub fn capture(path: &Path) -> io::Result<Option<Self>> {
capture_file_snapshot(path, metadata_identity_is_unreliable(path)?)
}
pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
self.content.as_ref().map_or_else(
|| identity == Some(&self.identity),
|before| {
before == content
&& identity.is_some_and(|after| {
self.identity.path == after.path
&& self.identity.len == after.len
&& self.identity.modified == after.modified
})
},
)
}
pub fn proves_content_change(&self) -> bool {
self.content.is_some() || self.identity.changed.is_some()
}
}
impl From<FileIdentity> for FileSnapshot {
fn from(identity: FileIdentity) -> Self {
Self {
identity,
content: None,
}
}
}
#[cfg(target_os = "linux")]
fn metadata_identity_is_unreliable(path: &Path) -> io::Result<bool> {
use std::mem::MaybeUninit;
use std::os::unix::ffi::OsStrExt as _;
let path = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
let mut status = MaybeUninit::<libc::statfs>::zeroed();
let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
if result != 0 {
return Err(io::Error::last_os_error());
}
let status = unsafe { status.assume_init() };
Ok(status.f_type == 0x6969)
}
#[cfg(not(target_os = "linux"))]
fn metadata_identity_is_unreliable(_path: &Path) -> io::Result<bool> {
Ok(false)
}
fn capture_file_snapshot(path: &Path, content_identity: bool) -> io::Result<Option<FileSnapshot>> {
let metadata = std::fs::metadata(path)?;
let Some(identity) = FileIdentity::describe(path, &metadata) else {
return Ok(None);
};
let content = content_identity
.then(|| {
CacheDigest::blake3_file(path).map_err(|error| io::Error::other(error.to_string()))
})
.transpose()?;
Ok(Some(FileSnapshot { identity, content }))
}
fn digest_cache_identity(
path: &Path,
metadata: &std::fs::Metadata,
unreliable: bool,
) -> Option<FileIdentity> {
if unreliable {
None
} else {
FileIdentity::describe(path, metadata)
}
}
#[cfg(unix)]
fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
use std::os::unix::fs::MetadataExt;
Some((metadata.ctime(), metadata.ctime_nsec()))
}
#[cfg(not(unix))]
fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
None
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordedFileDigest {
pub file: FileIdentity,
pub digest: CacheDigest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileDigestScope {
Content,
CcInput,
}
pub trait FileDigestCache: Send + Sync {
fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
}
pub struct NoFileDigestCache;
impl FileDigestCache for NoFileDigestCache {
fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
vec![None; files.len()]
}
fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_identity_describes_the_file_until_it_is_written_or_removed() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("input.rs");
std::fs::write(&path, b"fn main() {}").unwrap();
let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
assert!(identity.still_describes().unwrap());
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&path, b"fn main() { }").unwrap();
assert!(!identity.still_describes().unwrap());
std::fs::remove_file(&path).unwrap();
assert!(identity.still_describes().is_err());
}
#[test]
fn a_metadata_snapshot_detects_a_metadata_change() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("input.rs");
std::fs::write(&path, b"fn main() {}").unwrap();
let snapshot = capture_file_snapshot(&path, false).unwrap().unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
.unwrap();
let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
let digest = CacheDigest::blake3_file(&path).unwrap();
assert!(!snapshot.matches(identity.as_ref(), &digest));
assert_eq!(snapshot.proves_content_change(), cfg!(unix));
}
#[test]
fn a_content_snapshot_ignores_change_token_churn_but_detects_other_changes() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("input.rs");
std::fs::write(&path, b"fn main() {}").unwrap();
let snapshot = capture_file_snapshot(&path, true).unwrap().unwrap();
let mut identity =
FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
identity.changed = identity
.changed
.map(|(seconds, nanos)| (seconds + 1, nanos));
let digest = CacheDigest::blake3_file(&path).unwrap();
assert!(snapshot.matches(Some(&identity), &digest));
assert!(snapshot.proves_content_change());
identity.modified = SystemTime::UNIX_EPOCH;
assert!(!snapshot.matches(Some(&identity), &digest));
std::fs::write(&path, b"fn main(){ }").unwrap();
let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
let digest = CacheDigest::blake3_file(&path).unwrap();
assert!(!snapshot.matches(identity.as_ref(), &digest));
}
#[test]
fn unreliable_metadata_is_not_used_as_a_digest_cache_identity() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("input.rs");
std::fs::write(&path, b"fn main() {}").unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let identity = digest_cache_identity(&path, &metadata, false).unwrap();
assert_eq!(identity.path, path);
assert_eq!(identity.len, 12);
assert!(digest_cache_identity(&identity.path, &metadata, true).is_none());
}
}