use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::ingress_meta::FetchMeta;
const CACHE_ENV: &str = "ARCFORM_FETCH_CACHE";
const OFF: &str = "off";
const DEFAULT_SUBDIR: &str = "fetch-cache";
pub struct FetchCache {
root: PathBuf,
}
impl FetchCache {
pub fn from_env() -> Option<Self> {
Self::resolve_root(std::env::var_os(CACHE_ENV), dirs::home_dir()).map(Self::at)
}
pub fn at(root: PathBuf) -> Self {
Self { root }
}
fn resolve_root(env: Option<std::ffi::OsString>, home: Option<PathBuf>) -> Option<PathBuf> {
if let Some(dir) = env
&& !dir.is_empty()
{
if dir.to_string_lossy().eq_ignore_ascii_case(OFF) {
return None;
}
return Some(PathBuf::from(dir));
}
home.map(|home| home.join(".arcform").join(DEFAULT_SUBDIR))
}
fn object_path(&self, sha256: &str) -> Option<PathBuf> {
parse_digest(sha256).map(|digest| self.root.join("objects").join(digest))
}
fn locator_path(&self, url: &str) -> PathBuf {
self.root.join("urls").join(format!(
"{}.arcmeta",
crate::state::content_hash(url.as_bytes())
))
}
pub fn holds(&self, url: &str) -> bool {
self.locator_path(url).exists()
}
pub fn lookup(&self, url: &str) -> Option<FetchMeta> {
let record = std::fs::read_to_string(self.locator_path(url)).ok()?;
let meta: FetchMeta = serde_yaml::from_str(&record).ok()?;
if self.verified_object(&meta.sha256).is_none() {
self.evict(url, Some(&meta.sha256));
return None;
}
Some(meta)
}
pub fn pinned_object(&self, sha256: &str) -> Option<PathBuf> {
self.verified_object(sha256)
}
pub fn materialise(&self, meta: &FetchMeta, out: &Path) -> std::io::Result<()> {
let object = self
.object_path(&meta.sha256)
.ok_or_else(|| not_a_digest(&meta.sha256))?;
let tmp = out.with_extension("part");
let copied = copy_hashing(&object, &tmp).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})?;
if !copied.eq_ignore_ascii_case(meta.sha256.trim()) {
let _ = std::fs::remove_file(&tmp);
self.evict(&meta.url, Some(&meta.sha256));
return Err(std::io::Error::other(format!(
"cached copy of {} did not survive the read (filed under {}, read {copied}) \
— the entry is discarded",
meta.url, meta.sha256
)));
}
std::fs::rename(&tmp, out)
}
pub fn store(&self, meta: &FetchMeta, source: &Path) -> std::io::Result<()> {
let object = self
.object_path(&meta.sha256)
.ok_or_else(|| not_a_digest(&meta.sha256))?;
let sha = meta.sha256.trim();
let refuse = |hashed: String| {
std::io::Error::other(format!(
"refusing to file {} under {sha}: its bytes hash to {hashed}",
source.display()
))
};
if object.exists() {
let on_disk = hash_file(source)?;
if !on_disk.eq_ignore_ascii_case(sha) {
return Err(refuse(on_disk));
}
} else {
if let Some(parent) = object.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = object.with_extension(format!("part-{}", std::process::id()));
let copied = copy_hashing(source, &tmp)?;
if !copied.eq_ignore_ascii_case(sha) {
let _ = std::fs::remove_file(&tmp);
return Err(refuse(copied));
}
std::fs::rename(&tmp, &object)?;
}
let locator = self.locator_path(&meta.url);
if let Some(parent) = locator.parent() {
std::fs::create_dir_all(parent)?;
}
let record = serde_yaml::to_string(meta).map_err(std::io::Error::other)?;
let tmp = locator.with_extension(format!("part-{}", std::process::id()));
std::fs::write(&tmp, record)?;
std::fs::rename(&tmp, &locator)
}
fn evict(&self, url: &str, sha256: Option<&str>) {
let _ = std::fs::remove_file(self.locator_path(url));
if let Some(object) = sha256.and_then(|sha| self.object_path(sha)) {
let _ = std::fs::remove_file(object);
}
}
fn verified_object(&self, sha256: &str) -> Option<PathBuf> {
let object = self.object_path(sha256)?;
hash_file(&object)
.is_ok_and(|on_disk| on_disk.eq_ignore_ascii_case(sha256.trim()))
.then_some(object)
}
}
fn not_a_digest(key: &str) -> std::io::Error {
std::io::Error::other(format!(
"refusing `{key}` as a cache key: it is not a SHA-256 (expected 64 hex characters)"
))
}
fn copy_hashing(from: &Path, to: &Path) -> std::io::Result<String> {
let mut reader = std::fs::File::open(from)?;
let mut file = std::fs::File::create(to)?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
file.write_all(&buf[..n])?;
hasher.update(&buf[..n]);
}
let _ = file.sync_all();
Ok(format!("{:x}", hasher.finalize()))
}
pub fn hash_file(path: &Path) -> std::io::Result<String> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn parse_digest(value: &str) -> Option<String> {
let v = value.trim();
(v.len() == 64 && v.bytes().all(|b| b.is_ascii_hexdigit())).then(|| v.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(url: &str, bytes: &[u8]) -> FetchMeta {
FetchMeta {
url: url.to_string(),
etag: Some("\"v1\"".to_string()),
sha256: crate::state::content_hash(bytes),
..Default::default()
}
}
#[test]
fn a_stored_artifact_is_found_by_url_and_materialises_byte_identically() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let first = tempfile::tempdir().unwrap();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let source = first.path().join("edgar.parquet");
std::fs::write(&source, bytes).unwrap();
let m = meta("https://example.invalid/edgar.parquet", bytes);
cache.store(&m, &source).unwrap();
assert!(cache.holds(&m.url));
let found = cache.lookup(&m.url).expect("the URL locates the entry");
assert_eq!(found.sha256, m.sha256, "the entry is keyed by content hash");
assert_eq!(
found.etag, m.etag,
"and carries the validators to revalidate"
);
let second = tempfile::tempdir().unwrap();
let out = second.path().join("reference.parquet");
cache.materialise(&found, &out).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), bytes);
assert!(
!out.with_extension("part").exists(),
"the temp file is renamed, not left behind"
);
}
#[test]
fn an_unknown_url_is_a_miss() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
assert!(!cache.holds("https://example.invalid/never-fetched"));
assert!(
cache
.lookup("https://example.invalid/never-fetched")
.is_none()
);
}
#[test]
fn a_corrupt_object_is_refused_and_evicted() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let dir = tempfile::tempdir().unwrap();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let source = dir.path().join("edgar.parquet");
std::fs::write(&source, bytes).unwrap();
let m = meta("https://example.invalid/edgar.parquet", bytes);
cache.store(&m, &source).unwrap();
let object = cache.object_path(&m.sha256).expect("the key is a digest");
std::fs::write(&object, b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT395\n").unwrap();
assert!(
cache.lookup(&m.url).is_none(),
"a corrupt entry must not be served"
);
assert!(
!object.exists(),
"and must not be left for the next consumer"
);
assert!(!cache.holds(&m.url), "its locator goes with it");
}
#[test]
fn a_failed_materialise_leaves_no_partial_file_in_the_protocol() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let dir = tempfile::tempdir().unwrap();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let source = dir.path().join("edgar.parquet");
std::fs::write(&source, bytes).unwrap();
let m = meta("https://example.invalid/edgar.parquet", bytes);
cache.store(&m, &source).unwrap();
let object = cache.object_path(&m.sha256).expect("the key is a digest");
std::fs::remove_file(&object).unwrap();
std::fs::create_dir(&object).unwrap();
let out = dir.path().join("build/artifact.bin");
std::fs::create_dir_all(out.parent().unwrap()).unwrap();
assert!(cache.materialise(&m, &out).is_err());
assert!(
!out.exists(),
"a half-copied artifact was renamed into place"
);
assert!(
!out.with_extension("part").exists(),
"the partial copy was left in the Protocol directory"
);
}
#[test]
fn a_pinned_digest_finds_only_the_object_it_names() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let dir = tempfile::tempdir().unwrap();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let source = dir.path().join("edgar.parquet");
std::fs::write(&source, bytes).unwrap();
let m = meta("https://example.invalid/edgar.parquet", bytes);
cache.store(&m, &source).unwrap();
assert_eq!(cache.pinned_object(&m.sha256), cache.object_path(&m.sha256));
assert!(
cache
.pinned_object(&crate::state::content_hash(b"other bytes"))
.is_none()
);
}
#[test]
fn an_unhashed_artifact_is_not_stored() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("edgar.parquet");
std::fs::write(&source, b"bytes").unwrap();
let mut m = meta("https://example.invalid/edgar.parquet", b"bytes");
m.sha256 = String::new();
assert!(cache.store(&m, &source).is_err());
assert!(!cache.holds(&m.url));
}
#[test]
fn a_key_that_is_not_a_digest_is_refused_by_every_entry_point() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let elsewhere = tempfile::tempdir().unwrap();
let victim = elsewhere.path().join("important.txt");
let contents = b"someone else's work";
std::fs::write(&victim, contents).unwrap();
let source = elsewhere.path().join("source.bin");
std::fs::write(&source, b"bytes").unwrap();
let url = "https://example.invalid/edgar.parquet";
let keys = [
victim.to_string_lossy().into_owned(),
format!(
"../../../{}/important.txt",
elsewhere.path().file_name().unwrap().to_string_lossy()
),
"deadbeef".to_string(),
String::new(),
];
for key in keys {
let mut m = meta(url, b"bytes");
m.sha256 = key.clone();
assert!(cache.store(&m, &source).is_err(), "store took `{key}`");
assert!(!cache.holds(url), "store filed a locator under `{key}`");
let out = elsewhere.path().join("materialised.bin");
assert!(
cache.materialise(&m, &out).is_err(),
"materialise took `{key}`"
);
assert!(!out.exists(), "materialise wrote from `{key}`");
assert!(victim.exists(), "materialise unlinked `{key}`");
let locator = cache.locator_path(url);
std::fs::create_dir_all(locator.parent().unwrap()).unwrap();
std::fs::write(&locator, serde_yaml::to_string(&m).unwrap()).unwrap();
assert!(cache.lookup(url).is_none(), "lookup served `{key}`");
assert!(victim.exists(), "evicting `{key}` unlinked it");
assert!(!locator.exists(), "the unusable locator was left in place");
assert!(
cache.pinned_object(&key).is_none(),
"pinned_object served `{key}`"
);
assert!(victim.exists(), "pinned_object's read unlinked `{key}`");
}
assert_eq!(
std::fs::read(&victim).unwrap(),
contents,
"the file outside the store is untouched, byte for byte"
);
}
#[test]
fn store_verifies_the_bytes_even_when_the_object_is_already_filed() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let dir = tempfile::tempdir().unwrap();
let real = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let source = dir.path().join("edgar.parquet");
std::fs::write(&source, real).unwrap();
let honest = meta("https://a.invalid/edgar.parquet", real);
cache.store(&honest, &source).unwrap();
let other = dir.path().join("other.parquet");
std::fs::write(&other, b"not those bytes at all\n").unwrap();
let liar = FetchMeta {
url: "https://b.invalid/edgar.parquet".to_string(),
..honest.clone()
};
assert!(
cache.store(&liar, &other).is_err(),
"bytes that are not the key's bytes were filed under it"
);
assert!(
!cache.holds(&liar.url),
"and a locator was pointed at them anyway"
);
assert!(
cache.lookup(&honest.url).is_some(),
"the entry that was filed honestly is untouched"
);
}
#[test]
fn two_urls_with_the_same_bytes_share_one_object() {
let home = tempfile::tempdir().unwrap();
let cache = FetchCache::at(home.path().join("cache"));
let dir = tempfile::tempdir().unwrap();
let bytes = b"cik,lei\n0000320193,HWUPKR0MPOU8FGXBT394\n";
let source = dir.path().join("edgar.parquet");
std::fs::write(&source, bytes).unwrap();
cache
.store(&meta("https://a.invalid/edgar.parquet", bytes), &source)
.unwrap();
cache
.store(&meta("https://b.invalid/edgar.parquet", bytes), &source)
.unwrap();
let objects: Vec<_> = std::fs::read_dir(cache.root.join("objects"))
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(objects.len(), 1, "the bytes are filed under their digest");
assert!(cache.lookup("https://a.invalid/edgar.parquet").is_some());
assert!(cache.lookup("https://b.invalid/edgar.parquet").is_some());
}
#[test]
fn a_locator_path_is_a_digest_not_a_url() {
let cache = FetchCache::at(PathBuf::from("/tmp/arcform-cache-root"));
let hostile = "https://example.invalid/../../etc/passwd?a=b#c";
let path = cache.locator_path(hostile);
assert!(path.starts_with("/tmp/arcform-cache-root/urls"));
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
format!("{}.arcmeta", crate::state::content_hash(hostile.as_bytes()))
);
}
#[test]
fn the_environment_chooses_the_root_and_can_switch_the_cache_off() {
use std::ffi::OsString;
let home = PathBuf::from("/home/analyst");
assert_eq!(
FetchCache::resolve_root(None, Some(home.clone())),
Some(home.join(".arcform").join("fetch-cache")),
"no environment: beside the history store"
);
assert_eq!(
FetchCache::resolve_root(Some(OsString::from("")), Some(home.clone())),
Some(home.join(".arcform").join("fetch-cache")),
"an empty value is not a path"
);
assert_eq!(
FetchCache::resolve_root(
Some(OsString::from("/mnt/scratch/cache")),
Some(home.clone())
),
Some(PathBuf::from("/mnt/scratch/cache"))
);
assert_eq!(
FetchCache::resolve_root(Some(OsString::from("OFF")), Some(home)),
None,
"`off` in any case disables the cache"
);
assert_eq!(
FetchCache::resolve_root(None, None),
None,
"no home directory, no cache — and no error"
);
}
#[test]
fn test_fresh_hash_file_matches_content_hash() {
let dir = tempfile::tempdir().unwrap();
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let out = dir.path().join("big.bin");
std::fs::write(&out, &bytes).unwrap();
assert_eq!(
hash_file(&out).unwrap(),
crate::state::content_hash(&bytes),
"chunked and whole-slice digests agree"
);
}
#[test]
fn a_pinned_digest_is_64_hex_characters_or_nothing() {
let digest = crate::state::content_hash(b"bytes");
assert_eq!(parse_digest(&digest), Some(digest.clone()));
assert_eq!(
parse_digest(&format!(" {} ", digest.to_uppercase())),
Some(digest),
"trimmed and lowercased, so a pasted digest matches"
);
assert!(parse_digest("deadbeef").is_none(), "too short");
assert!(parse_digest(&"z".repeat(64)).is_none(), "not hex");
assert!(parse_digest("").is_none());
}
}