use geth_types::BlobHash; use std::path::{Path, PathBuf}; #[derive(Debug, thiserror::Error)] pub enum CasError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("invalid blob hash: {0}")] InvalidHash(String), #[error("blob not found: {0}")] NotFound(String), } #[derive(Clone, Debug)] pub struct LocalCas { root: PathBuf, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct BlobInfo { pub hash: BlobHash, pub size_bytes: u64, pub path: PathBuf, } impl LocalCas { #[must_use] pub fn new(root: impl Into) -> Self { Self { root: root.into() } } pub fn add_path(&self, path: &Path) -> Result { let bytes = std::fs::read(path)?; self.add_bytes(&bytes) } pub fn add_bytes(&self, bytes: &[u8]) -> Result { let hash = hash_bytes(bytes); let path = self.blob_path(&hash)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } if !path.exists() { let tmp = path.with_extension("tmp"); std::fs::write(&tmp, bytes)?; std::fs::rename(tmp, &path)?; } Ok(BlobInfo { hash, size_bytes: bytes.len() as u64, path, }) } pub fn get_to_path(&self, hash: &BlobHash, out: &Path) -> Result { let path = self.blob_path(hash)?; if !path.exists() { return Err(CasError::NotFound(hash.to_string())); } if let Some(parent) = out.parent() { std::fs::create_dir_all(parent)?; } std::fs::copy(path, out).map_err(CasError::from) } pub fn has(&self, hash: &BlobHash) -> Result { Ok(self.blob_path(hash)?.exists()) } pub fn remove(&self, hash: &BlobHash) -> Result { let path = self.blob_path(hash)?; if !path.exists() { return Ok(false); } std::fs::remove_file(path)?; Ok(true) } pub fn list(&self) -> Result, CasError> { let blobs = self.root.join("blobs"); if !blobs.exists() { return Ok(Vec::new()); } let mut infos = Vec::new(); for first in std::fs::read_dir(blobs)? { let first = first?; if !first.file_type()?.is_dir() { continue; } for second in std::fs::read_dir(first.path())? { let second = second?; if !second.file_type()?.is_dir() { continue; } for entry in std::fs::read_dir(second.path())? { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let hash = entry.file_name().to_string_lossy().to_string(); if is_valid_hash(&hash) { let meta = entry.metadata()?; infos.push(BlobInfo { hash: BlobHash::new(hash), size_bytes: meta.len(), path: entry.path(), }); } } } } infos.sort_by(|a, b| a.hash.as_str().cmp(b.hash.as_str())); Ok(infos) } pub fn blob_path(&self, hash: &BlobHash) -> Result { validate_hash(hash)?; let hash = hash.as_str(); Ok(self .root .join("blobs") .join(&hash[0..2]) .join(&hash[2..4]) .join(hash)) } } #[must_use] pub fn hash_bytes(bytes: &[u8]) -> BlobHash { BlobHash::new(blake3::hash(bytes).to_hex().to_string()) } pub fn hash_path(path: &Path) -> Result { let bytes = std::fs::read(path)?; Ok(hash_bytes(&bytes)) } pub fn validate_hash(hash: &BlobHash) -> Result<(), CasError> { if is_valid_hash(hash.as_str()) { Ok(()) } else { Err(CasError::InvalidHash(hash.to_string())) } } #[must_use] pub fn is_valid_hash(hash: &str) -> bool { hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) } #[cfg(test)] mod tests { use super::*; #[test] fn cas_add_get_has_list() { let dir = tempfile::tempdir().expect("tempdir"); let cas = LocalCas::new(dir.path()); let info = cas.add_bytes(b"hello geth").expect("add"); assert!(cas.has(&info.hash).expect("has")); assert_eq!(cas.list().expect("list").len(), 1); let out = dir.path().join("out.txt"); cas.get_to_path(&info.hash, &out).expect("get"); assert_eq!(std::fs::read(out).expect("read"), b"hello geth"); } #[test] fn cas_remove_deletes_blob_file() { let dir = tempfile::tempdir().expect("tempdir"); let cas = LocalCas::new(dir.path()); let info = cas.add_bytes(b"remove me").expect("add"); assert!(cas.remove(&info.hash).expect("remove")); assert!(!cas.has(&info.hash).expect("has after remove")); assert!(!cas.remove(&info.hash).expect("remove missing")); } }