2026-05-15 15:08:20 +02:00
|
|
|
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<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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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-15 15:08:20 +02:00
|
|
|
}
|