use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::PathBuf;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub struct Cache {
pub root: PathBuf,
}
impl Cache {
pub fn user_default() -> Cache {
let root = std::env::home_dir()
.unwrap_or_default()
.join(".ridl")
.join("cache");
Cache { root }
}
pub fn lookup(&self, url: &str, sha256: &str) -> Option<PathBuf> {
let path = self.entry_dir(url, sha256);
path.is_dir().then_some(path)
}
pub fn store(&self, url: &str, bytes: &[u8]) -> io::Result<(String, PathBuf)> {
let sha256 = sha256_hex(bytes);
let dest = self.entry_dir(url, &sha256);
if dest.is_dir() {
return Ok((sha256, dest));
}
let parent = self.url_dir(url);
fs::create_dir_all(&parent)?;
let staging = parent.join(format!(".staging-{sha256}-{}", std::process::id()));
let _ = fs::remove_dir_all(&staging);
fs::create_dir_all(&staging)?;
let unpack_result = tar::Archive::new(bytes).unpack(&staging);
if let Err(err) = unpack_result {
let _ = fs::remove_dir_all(&staging);
return Err(err);
}
match fs::rename(&staging, &dest) {
Ok(()) => Ok((sha256, dest)),
Err(_) if dest.is_dir() => {
let _ = fs::remove_dir_all(&staging);
Ok((sha256, dest))
}
Err(err) => {
let _ = fs::remove_dir_all(&staging);
Err(err)
}
}
}
fn url_dir(&self, url: &str) -> PathBuf {
self.root.join(sha256_hex(url.as_bytes()))
}
fn entry_dir(&self, url: &str, content_sha: &str) -> PathBuf {
self.url_dir(url).join(content_sha)
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut hex = String::with_capacity(64);
for byte in digest {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"ridl-core-cache-{label}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst),
));
fs::create_dir_all(&path).expect("create the temp dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn make_tar(name: &str, contents: &[u8]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, name, contents)
.expect("append the file to the tar");
builder.into_inner().expect("finish the tar")
}
fn cache(dir: &TempDir) -> Cache {
Cache {
root: dir.path().join("cache"),
}
}
#[test]
fn sha256_hex_matches_the_known_empty_hash() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
}
#[test]
fn store_unpacks_and_lookup_finds_it() {
let dir = TempDir::new("store-lookup");
let cache = cache(&dir);
let url = "https://registry.example.com/veh/common@v1.0.0";
let tar = make_tar("common.typl", b"package veh.common\ntype Speed: km/h\n");
let (sha, path) = cache.store(url, &tar).expect("the artifact stores");
assert_eq!(sha.len(), 64, "the hash is a 64-char hex string");
assert!(path.is_dir(), "the entry directory exists");
let unpacked = fs::read_to_string(path.join("common.typl")).expect("the file unpacked");
assert_eq!(unpacked, "package veh.common\ntype Speed: km/h\n");
assert_eq!(
cache.lookup(url, &sha),
Some(path),
"lookup finds the stored entry",
);
}
#[test]
fn lookup_misses_on_an_unknown_hash() {
let dir = TempDir::new("miss");
let cache = cache(&dir);
let url = "https://registry.example.com/veh/common@v1.0.0";
cache
.store(url, &make_tar("a.typl", b"package veh.common\n"))
.expect("store");
assert_eq!(
cache.lookup(url, &"0".repeat(64)),
None,
"an unstored hash is a cache miss",
);
let sha = sha256_hex(&make_tar("a.typl", b"package veh.common\n"));
assert_eq!(
cache.lookup("https://elsewhere.example.com/veh/common@v1.0.0", &sha),
None,
"the cache is indexed by URL as well as content hash",
);
}
#[test]
fn store_does_not_let_a_tar_escape_the_entry_directory() {
let dir = TempDir::new("traversal");
let cache = cache(&dir);
let url = "https://registry.example.com/veh/evil@v1.0.0";
let (_, benign_path) = cache
.store(
"https://registry.example.com/veh/benign@v1.0.0",
&tar_with_raw_name("benign.typl", b"package veh.benign\n"),
)
.expect("a benign hand-crafted tar stores");
assert!(
benign_path.join("benign.typl").is_file(),
"the hand-crafted tar format is valid and unpacks",
);
let tar = tar_with_raw_name("../poison", b"pwned");
let _ = cache.store(url, &tar);
assert!(
!contains_file_named(dir.path(), "poison"),
"an escaping tar entry must never be written anywhere in the cache tree",
);
}
fn tar_with_raw_name(name: &str, contents: &[u8]) -> Vec<u8> {
let mut header = [0u8; 512];
let name_bytes = name.as_bytes();
header[..name_bytes.len()].copy_from_slice(name_bytes);
header[100..108].copy_from_slice(b"0000644\0"); header[108..116].copy_from_slice(b"0000000\0"); header[116..124].copy_from_slice(b"0000000\0"); header[124..136].copy_from_slice(format!("{:011o}\0", contents.len()).as_bytes()); header[136..148].copy_from_slice(b"00000000000\0"); header[156] = b'0'; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00");
for byte in &mut header[148..156] {
*byte = b' ';
}
let checksum: u32 = header.iter().map(|&byte| u32::from(byte)).sum();
header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
let mut out = Vec::new();
out.extend_from_slice(&header);
out.extend_from_slice(contents);
out.resize(out.len() + (512 - contents.len() % 512) % 512, 0);
out.resize(out.len() + 1024, 0);
out
}
fn contains_file_named(root: &Path, name: &str) -> bool {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|found| found == name) {
return true;
}
if path.is_dir() {
stack.push(path);
}
}
}
false
}
#[test]
fn store_is_idempotent() {
let dir = TempDir::new("idempotent");
let cache = cache(&dir);
let url = "https://registry.example.com/veh/common@v1.0.0";
let tar = make_tar("a.typl", b"package veh.common\n");
let (sha1, path1) = cache.store(url, &tar).expect("first store");
let (sha2, path2) = cache.store(url, &tar).expect("second store");
assert_eq!(sha1, sha2, "the same bytes hash the same");
assert_eq!(path1, path2, "the same entry directory is returned");
}
}