2026-05-15 15:08:20 +02:00
|
|
|
use geth_types::BlobHash;
|
2026-05-17 21:20:24 +02:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-15 15:08:20 +02:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
2026-05-17 21:20:24 +02:00
|
|
|
pub const CAS_TREE_OBJECT_VERSION: u16 = 1;
|
|
|
|
|
|
2026-05-15 15:08:20 +02:00
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
pub enum CasError {
|
|
|
|
|
#[error("io error: {0}")]
|
|
|
|
|
Io(#[from] std::io::Error),
|
2026-05-17 21:20:24 +02:00
|
|
|
#[error("canonical codec error: {0}")]
|
|
|
|
|
Codec(#[from] geth_codec::CodecError),
|
2026-05-15 15:08:20 +02:00
|
|
|
#[error("invalid blob hash: {0}")]
|
|
|
|
|
InvalidHash(String),
|
|
|
|
|
#[error("blob not found: {0}")]
|
|
|
|
|
NotFound(String),
|
2026-05-17 21:20:24 +02:00
|
|
|
#[error("CAS tree root is not a directory: {0}")]
|
|
|
|
|
TreeRootNotDirectory(String),
|
|
|
|
|
#[error("CAS tree paths must be relative: {0}")]
|
|
|
|
|
NonRelativeTreePath(String),
|
2026-05-15 15:08:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 21:20:24 +02:00
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct CasTreeObject {
|
|
|
|
|
pub version: u16,
|
|
|
|
|
pub entries: Vec<CasTreeEntry>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct CasTreeEntry {
|
|
|
|
|
pub path: String,
|
|
|
|
|
pub kind: CasTreeEntryKind,
|
|
|
|
|
pub blob: Option<BlobHash>,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 15:08:20 +02:00
|
|
|
impl LocalCas {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
|
|
|
|
Self { root: root.into() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_path(&self, path: &Path) -> Result<BlobInfo, CasError> {
|
|
|
|
|
let bytes = std::fs::read(path)?;
|
|
|
|
|
self.add_bytes(&bytes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_bytes(&self, bytes: &[u8]) -> Result<BlobInfo, CasError> {
|
|
|
|
|
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<u64, CasError> {
|
|
|
|
|
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<bool, CasError> {
|
|
|
|
|
Ok(self.blob_path(hash)?.exists())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 21:10:25 +02:00
|
|
|
pub fn remove(&self, hash: &BlobHash) -> Result<bool, CasError> {
|
|
|
|
|
let path = self.blob_path(hash)?;
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
std::fs::remove_file(path)?;
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 15:08:20 +02:00
|
|
|
pub fn list(&self) -> Result<Vec<BlobInfo>, 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<PathBuf, CasError> {
|
|
|
|
|
validate_hash(hash)?;
|
|
|
|
|
let hash = hash.as_str();
|
|
|
|
|
Ok(self
|
|
|
|
|
.root
|
|
|
|
|
.join("blobs")
|
|
|
|
|
.join(&hash[0..2])
|
|
|
|
|
.join(&hash[2..4])
|
|
|
|
|
.join(hash))
|
|
|
|
|
}
|
2026-05-17 21:20:24 +02:00
|
|
|
|
|
|
|
|
pub fn add_tree_path(&self, root: &Path) -> Result<CasTreeStored, CasError> {
|
|
|
|
|
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<CasTreeObject, CasError> {
|
|
|
|
|
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<CasTreeEntry>,
|
|
|
|
|
) -> Result<(), CasError> {
|
|
|
|
|
let mut children = std::fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
|
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<String, CasError> {
|
|
|
|
|
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::<Vec<_>>()
|
|
|
|
|
.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
|
2026-05-15 15:08:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<BlobHash, CasError> {
|
|
|
|
|
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");
|
|
|
|
|
}
|
2026-05-16 21:10:25 +02:00
|
|
|
|
|
|
|
|
#[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"));
|
|
|
|
|
}
|
2026-05-17 21:20:24 +02:00
|
|
|
|
|
|
|
|
#[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<_>>(),
|
|
|
|
|
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(_))
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-05-15 15:08:20 +02:00
|
|
|
}
|