use geth_types::BlobHash; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; pub const CAS_TREE_OBJECT_VERSION: u16 = 1; #[derive(Debug, thiserror::Error)] pub enum CasError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("canonical codec error: {0}")] Codec(#[from] geth_codec::CodecError), #[error("invalid blob hash: {0}")] InvalidHash(String), #[error("blob not found: {0}")] NotFound(String), #[error("CAS tree root is not a directory: {0}")] TreeRootNotDirectory(String), #[error("CAS tree paths must be relative: {0}")] NonRelativeTreePath(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, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CasTreeObject { pub version: u16, pub entries: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CasTreeEntry { pub path: String, pub kind: CasTreeEntryKind, pub blob: Option, pub size_bytes: u64, pub executable: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CasTreeEntryKind { Directory, File, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CasTreeStored { pub tree: CasTreeObject, pub object: BlobInfo, } 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)) } pub fn add_tree_path(&self, root: &Path) -> Result { let tree = build_tree_object(self, root)?; let bytes = geth_codec::encode_canonical(&tree)?; let object = self.add_bytes(&bytes)?; Ok(CasTreeStored { tree, object }) } } pub fn build_tree_object(cas: &LocalCas, root: &Path) -> Result { if !root.is_dir() { return Err(CasError::TreeRootNotDirectory(root.display().to_string())); } let mut entries = Vec::new(); collect_tree_entries(cas, root, root, &mut entries)?; entries.sort_by(|left, right| { left.path .cmp(&right.path) .then_with(|| kind_order(&left.kind).cmp(&kind_order(&right.kind))) }); Ok(CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries, }) } fn collect_tree_entries( cas: &LocalCas, root: &Path, current: &Path, entries: &mut Vec, ) -> Result<(), CasError> { let mut children = std::fs::read_dir(current)?.collect::, _>>()?; children.sort_by_key(|entry| entry.file_name()); for child in children { let path = child.path(); let metadata = child.metadata()?; let relative = relative_tree_path(root, &path)?; if metadata.is_dir() { entries.push(CasTreeEntry { path: relative, kind: CasTreeEntryKind::Directory, blob: None, size_bytes: 0, executable: false, }); collect_tree_entries(cas, root, &path, entries)?; } else if metadata.is_file() { let blob = cas.add_path(&path)?; entries.push(CasTreeEntry { path: relative, kind: CasTreeEntryKind::File, blob: Some(blob.hash), size_bytes: metadata.len(), executable: is_executable(&metadata), }); } } Ok(()) } fn relative_tree_path(root: &Path, path: &Path) -> Result { let relative = path .strip_prefix(root) .map_err(|_| CasError::NonRelativeTreePath(path.display().to_string()))?; Ok(relative .components() .map(|component| component.as_os_str().to_string_lossy()) .collect::>() .join("/")) } fn kind_order(kind: &CasTreeEntryKind) -> u8 { match kind { CasTreeEntryKind::Directory => 0, CasTreeEntryKind::File => 1, } } #[cfg(unix)] fn is_executable(metadata: &std::fs::Metadata) -> bool { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] fn is_executable(_metadata: &std::fs::Metadata) -> bool { false } #[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")); } #[test] fn cas_tree_objects_are_deterministic_and_store_file_blobs() { let dir = tempfile::tempdir().expect("tempdir"); let cas = LocalCas::new(dir.path().join("cas")); let root = dir.path().join("root"); std::fs::create_dir_all(root.join("sub")).expect("create subdir"); std::fs::write(root.join("b.txt"), b"bravo").expect("write b"); std::fs::write(root.join("sub").join("a.txt"), b"alpha").expect("write a"); let first = cas.add_tree_path(&root).expect("first tree"); let second = cas.add_tree_path(&root).expect("second tree"); assert_eq!(first.object.hash, second.object.hash); assert_eq!(first.tree, second.tree); assert_eq!( first .tree .entries .iter() .map(|entry| entry.path.as_str()) .collect::>(), vec!["b.txt", "sub", "sub/a.txt"] ); assert!( first .tree .entries .iter() .find(|entry| entry.path == "b.txt") .and_then(|entry| entry.blob.clone()) .is_some_and(|hash| cas.has(&hash).expect("has blob")) ); assert!(cas.has(&first.object.hash).expect("has tree object")); } #[test] fn cas_tree_rejects_file_root() { let dir = tempfile::tempdir().expect("tempdir"); let cas = LocalCas::new(dir.path().join("cas")); let file = dir.path().join("file.txt"); std::fs::write(&file, b"not a directory").expect("write"); assert!(matches!( cas.add_tree_path(&file), Err(CasError::TreeRootNotDirectory(_)) )); } }