use crate::cow::cow_clone_file;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::SystemTime;
use tracing::debug;
static CAS_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
pub struct CasStore {
objects_dir: PathBuf,
}
impl CasStore {
pub fn new(base_workdir: &Path) -> Self {
let objects_dir = base_workdir.join("cas").join("objects");
let _ = fs::create_dir_all(&objects_dir);
Self { objects_dir }
}
pub fn objects_dir(&self) -> &Path {
&self.objects_dir
}
pub fn object_path(&self, sha256: &str) -> PathBuf {
if sha256.len() >= 4 {
self.objects_dir
.join(&sha256[..2])
.join(&sha256[2..4])
.join(sha256)
} else {
self.objects_dir.join(sha256)
}
}
pub fn has_object(&self, sha256: &str) -> bool {
self.object_path(sha256).is_file()
}
pub fn put_file(&self, sha256: &str, src_path: &Path) -> io::Result<PathBuf> {
if !src_path.is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Source file does not exist: {}", src_path.display()),
));
}
let actual = fileset::hash_file(src_path)?;
if !actual.eq_ignore_ascii_case(sha256) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"content does not match the supplied hash for {}: \
declared {sha256}, actual {actual}",
src_path.display()
),
));
}
let dst_path = self.object_path(sha256);
if dst_path.is_file() {
return Ok(dst_path);
}
if let Some(parent) = dst_path.parent() {
fs::create_dir_all(parent)?;
}
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let counter = CAS_TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_name = format!(
"{}.tmp.{}.{}.{}",
sha256,
std::process::id(),
nanos,
counter
);
let tmp_path = dst_path.with_file_name(tmp_name);
cow_clone_file(src_path, &tmp_path)?;
if let Err(e) = fs::rename(&tmp_path, &dst_path) {
let _ = fs::remove_file(&tmp_path);
if !dst_path.is_file() {
return Err(e);
}
}
debug!(
"Registered object in CAS: {} ({})",
sha256,
src_path.display()
);
Ok(dst_path)
}
pub fn materialize_to(&self, sha256: &str, dest_path: &Path) -> io::Result<bool> {
let cas_path = self.object_path(sha256);
if !cas_path.is_file() {
return Ok(false);
}
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
cow_clone_file(&cas_path, dest_path)?;
if let Ok(f) = fs::File::options().write(true).open(&cas_path) {
let _ = f.set_modified(SystemTime::now());
}
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_cas_store_put_and_materialize() {
let workdir = tempdir().unwrap();
let cas = CasStore::new(workdir.path());
let src_dir = tempdir().unwrap();
let file_path = src_dir.path().join("code.rs");
fs::write(&file_path, b"fn main() { println!(\"cas\"); }").unwrap();
let hash = fileset::hash_file(&file_path).unwrap();
let hash = hash.as_str();
assert!(!cas.has_object(hash));
cas.put_file(hash, &file_path).unwrap();
assert!(cas.has_object(hash));
let dest_dir = tempdir().unwrap();
let target_file = dest_dir.path().join("sub/hydrated.rs");
let ok = cas.materialize_to(hash, &target_file).unwrap();
assert!(ok);
assert_eq!(
fs::read(&target_file).unwrap(),
b"fn main() { println!(\"cas\"); }"
);
assert!(!cas
.materialize_to("nonexistenthash", &dest_dir.path().join("missing.rs"))
.unwrap());
}
}
#[cfg(test)]
mod concurrency_tests {
use super::*;
use std::sync::Arc;
use tempfile::tempdir;
#[test]
fn test_concurrent_put_file_same_hash_never_clobbers() {
let workdir = tempdir().unwrap();
let cas = Arc::new(CasStore::new(workdir.path()));
let src = tempdir().unwrap();
let src_file = src.path().join("data.bin");
fs::write(&src_file, vec![42u8; 8192]).unwrap();
let hash = fileset::hash_file(&src_file).unwrap();
let mut handles = Vec::new();
for _ in 0..32 {
let cas = Arc::clone(&cas);
let src_file = src_file.clone();
let hash = hash.to_string();
handles.push(std::thread::spawn(move || {
cas.put_file(&hash, &src_file).expect("concurrent put_file")
}));
}
for h in handles {
h.join().unwrap();
}
let stored = fs::read(cas.object_path(&hash)).unwrap();
assert_eq!(
stored,
vec![42u8; 8192],
"object must be intact after the race"
);
}
#[test]
fn test_materialize_touches_object_mtime() {
let workdir = tempdir().unwrap();
let cas = CasStore::new(workdir.path());
let src = tempdir().unwrap();
let src_file = src.path().join("f.bin");
fs::write(&src_file, b"touch-me").unwrap();
let hash = fileset::hash_file(&src_file).unwrap();
cas.put_file(&hash, &src_file).unwrap();
let before = fs::metadata(cas.object_path(&hash))
.unwrap()
.modified()
.unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(600);
{
let f = fs::File::options()
.write(true)
.open(cas.object_path(&hash))
.unwrap();
f.set_modified(old).unwrap();
}
let dest = tempdir().unwrap();
cas.materialize_to(&hash, &dest.path().join("out.bin"))
.unwrap();
let after = fs::metadata(cas.object_path(&hash))
.unwrap()
.modified()
.unwrap();
assert!(
after > old,
"materialize_to must touch the object mtime for LRU accounting"
);
let _ = before;
}
}
#[cfg(test)]
mod integrity_tests {
use super::*;
use tempfile::tempdir;
#[test]
fn put_file_rejects_content_that_does_not_match_the_hash() {
let workdir = tempdir().unwrap();
let cas = CasStore::new(workdir.path());
let src = tempdir().unwrap();
let file = src.path().join("payload.bin");
fs::write(&file, b"the real content").unwrap();
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
let err = cas.put_file(wrong, &file).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(
!cas.has_object(wrong),
"a mismatched object must not be stored at all"
);
let right = fileset::hash_file(&file).unwrap();
cas.put_file(&right, &file).unwrap();
let dest = tempdir().unwrap();
assert!(cas
.materialize_to(&right, &dest.path().join("out.bin"))
.unwrap());
assert_eq!(
fs::read(dest.path().join("out.bin")).unwrap(),
b"the real content"
);
}
}