Add local CAS file roots

This commit is contained in:
Eric Wendland 2026-05-18 03:50:09 +02:00
commit e1f36ffafb
12 changed files with 581 additions and 13 deletions

View file

@ -1,5 +1,6 @@
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;
@ -18,6 +19,8 @@ pub enum CasError {
TreeRootNotDirectory(String),
#[error("CAS tree paths must be relative: {0}")]
NonRelativeTreePath(String),
#[error("invalid file root name: {0}")]
InvalidFileRootName(String),
}
#[derive(Clone, Debug)]
@ -60,6 +63,39 @@ pub struct CasTreeStored {
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<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 },
}
impl LocalCas {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
@ -188,6 +224,107 @@ pub fn build_tree_object(cas: &LocalCas, root: &Path) -> Result<CasTreeObject, C
})
}
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(&current_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(&current_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()),
}
}
fn collect_tree_entries(
cas: &LocalCas,
root: &Path,
@ -277,6 +414,17 @@ 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::*;
@ -351,4 +499,75 @@ mod tests {
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), &current);
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), &current);
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,
}
}
}