use geth_types::BlobHash; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; 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), #[error("invalid file root name: {0}")] InvalidFileRootName(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, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FileRoot { pub id: String, pub resource: String, pub name: String, pub path: String, pub latest_tree: Option, pub updated_at_ms: i64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FileRootScan { pub root: FileRoot, pub tree: BlobInfoSummary, pub changes: Vec, pub note: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BlobInfoSummary { pub hash: BlobHash, pub size_bytes: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum FileRootChange { Created { path: String }, Modified { path: String }, Deleted { path: String }, Renamed { from: String, to: String }, } 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, }) } pub fn diff_tree_objects( previous: Option<&CasTreeObject>, current: &CasTreeObject, ) -> Vec { let Some(previous) = previous else { return current .entries .iter() .map(|entry| FileRootChange::Created { path: entry.path.clone(), }) .collect(); }; let previous_entries = tree_entry_map(previous); let current_entries = tree_entry_map(current); let previous_paths = previous_entries.keys().cloned().collect::>(); let current_paths = current_entries.keys().cloned().collect::>(); let deleted = previous_paths .difference(¤t_paths) .cloned() .collect::>(); let created = current_paths .difference(&previous_paths) .cloned() .collect::>(); let mut changes = Vec::new(); let mut consumed_deleted = BTreeSet::new(); let mut consumed_created = BTreeSet::new(); for deleted_path in &deleted { let Some(deleted_entry) = previous_entries.get(deleted_path) else { continue; }; let Some(deleted_blob) = &deleted_entry.blob else { continue; }; if let Some(created_path) = created.iter().find(|created_path| { !consumed_created.contains(*created_path) && current_entries .get(*created_path) .and_then(|entry| entry.blob.as_ref()) == Some(deleted_blob) }) { changes.push(FileRootChange::Renamed { from: deleted_path.clone(), to: created_path.clone(), }); consumed_deleted.insert(deleted_path.clone()); consumed_created.insert(created_path.clone()); } } for path in deleted { if !consumed_deleted.contains(&path) { changes.push(FileRootChange::Deleted { path }); } } for path in created { if !consumed_created.contains(&path) { changes.push(FileRootChange::Created { path }); } } for path in previous_paths.intersection(¤t_paths) { let previous = previous_entries.get(path).expect("previous entry"); let current = current_entries.get(path).expect("current entry"); if previous.kind != current.kind || previous.blob != current.blob || previous.executable != current.executable || previous.size_bytes != current.size_bytes { changes.push(FileRootChange::Modified { path: path.clone() }); } } changes.sort_by_key(change_sort_key); changes } #[must_use] pub fn file_root_scan_note() -> &'static str { "local scan only; geth never overwrites file roots without a recorded future sync decision" } fn tree_entry_map(tree: &CasTreeObject) -> BTreeMap { tree.entries .iter() .map(|entry| (entry.path.clone(), entry)) .collect() } fn change_sort_key(change: &FileRootChange) -> (u8, String, String) { match change { FileRootChange::Created { path } => (0, path.clone(), String::new()), FileRootChange::Modified { path } => (1, path.clone(), String::new()), FileRootChange::Deleted { path } => (2, path.clone(), String::new()), FileRootChange::Renamed { from, to } => (3, from.clone(), to.clone()), } } 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()) } pub fn validate_file_root_name(name: &str) -> Result<(), CasError> { if name.is_empty() || !name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) { return Err(CasError::InvalidFileRootName(name.to_owned())); } Ok(()) } #[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(_)) )); } #[test] fn cas_tree_diff_detects_create_modify_delete_and_rename() { let previous = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![ tree_file("deleted.txt", "01", 1), tree_file("modified.txt", "02", 1), tree_file("old.txt", "03", 1), ], }; let current = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![ tree_file("created.txt", "04", 1), tree_file("modified.txt", "05", 1), tree_file("new.txt", "03", 1), ], }; let diff = diff_tree_objects(Some(&previous), ¤t); assert!(diff.contains(&FileRootChange::Created { path: "created.txt".to_owned() })); assert!(diff.contains(&FileRootChange::Modified { path: "modified.txt".to_owned() })); assert!(diff.contains(&FileRootChange::Deleted { path: "deleted.txt".to_owned() })); assert!(diff.contains(&FileRootChange::Renamed { from: "old.txt".to_owned(), to: "new.txt".to_owned(), })); } #[test] fn cas_tree_diff_does_not_reuse_rename_targets() { let previous = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![tree_file("one.txt", "01", 1), tree_file("two.txt", "01", 1)], }; let current = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![tree_file("renamed.txt", "01", 1)], }; let diff = diff_tree_objects(Some(&previous), ¤t); let rename_count = diff .iter() .filter(|change| matches!(change, FileRootChange::Renamed { .. })) .count(); let delete_count = diff .iter() .filter(|change| matches!(change, FileRootChange::Deleted { .. })) .count(); assert_eq!(rename_count, 1); assert_eq!(delete_count, 1); } fn tree_file(path: &str, byte: &str, size_bytes: u64) -> CasTreeEntry { CasTreeEntry { path: path.to_owned(), kind: CasTreeEntryKind::File, blob: Some(BlobHash::new(byte.repeat(32))), size_bytes, executable: false, } } }