geth/crates/geth-cas/src/lib.rs

354 lines
10 KiB
Rust
Raw Normal View History

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
}