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-18 03:50:09 +02:00
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
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-18 03:50:09 +02:00
|
|
|
#[error("invalid file root name: {0}")]
|
|
|
|
|
InvalidFileRootName(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-18 03:50:09 +02:00
|
|
|
#[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<BlobHash>,
|
|
|
|
|
pub updated_at_ms: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct FileRootScan {
|
|
|
|
|
pub root: FileRoot,
|
|
|
|
|
pub tree: BlobInfoSummary,
|
|
|
|
|
pub changes: Vec<FileRootChange>,
|
|
|
|
|
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 },
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 03:50:09 +02:00
|
|
|
pub fn diff_tree_objects(
|
|
|
|
|
previous: Option<&CasTreeObject>,
|
|
|
|
|
current: &CasTreeObject,
|
|
|
|
|
) -> Vec<FileRootChange> {
|
|
|
|
|
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::<BTreeSet<_>>();
|
|
|
|
|
let current_paths = current_entries.keys().cloned().collect::<BTreeSet<_>>();
|
|
|
|
|
let deleted = previous_paths
|
|
|
|
|
.difference(¤t_paths)
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let created = current_paths
|
|
|
|
|
.difference(&previous_paths)
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
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<String, &CasTreeEntry> {
|
|
|
|
|
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()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 21:20:24 +02:00
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 03:50:09 +02:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 15:08:20 +02:00
|
|
|
#[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-18 03:50:09 +02:00
|
|
|
|
|
|
|
|
#[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,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-15 15:08:20 +02:00
|
|
|
}
|