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

681 lines
20 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-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-18 03:57:26 +02:00
#[error("invalid file conflict kind: {0}")]
InvalidFileConflictKind(String),
#[error("invalid file conflict resolution: {0}")]
InvalidFileConflictResolution(String),
#[error("invalid file conflict status: {0}")]
InvalidFileConflictStatus(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-18 03:57:26 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileConflict {
pub id: String,
pub root: String,
pub resource: String,
pub path: String,
pub kind: FileConflictKind,
pub status: FileConflictStatus,
pub base_tree: Option<BlobHash>,
pub local_tree: Option<BlobHash>,
pub remote_tree: Option<BlobHash>,
pub detail: String,
pub resolution: Option<FileConflictResolution>,
pub resolution_note: Option<String>,
pub created_at_ms: i64,
pub resolved_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictKind {
ConcurrentEdit,
DeleteEdit,
Rename,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictStatus {
Open,
Resolved,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileConflictResolution {
KeepLocal,
AcceptRemote,
KeepBoth,
Manual,
}
impl FileConflictKind {
pub fn parse(value: &str) -> Result<Self, CasError> {
match value {
"concurrent-edit" => Ok(Self::ConcurrentEdit),
"delete-edit" => Ok(Self::DeleteEdit),
"rename" => Ok(Self::Rename),
_ => Err(CasError::InvalidFileConflictKind(value.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::ConcurrentEdit => "concurrent-edit",
Self::DeleteEdit => "delete-edit",
Self::Rename => "rename",
}
}
}
impl FileConflictResolution {
pub fn parse(value: &str) -> Result<Self, CasError> {
match value {
"keep-local" => Ok(Self::KeepLocal),
"accept-remote" => Ok(Self::AcceptRemote),
"keep-both" => Ok(Self::KeepBoth),
"manual" => Ok(Self::Manual),
_ => Err(CasError::InvalidFileConflictResolution(value.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::KeepLocal => "keep-local",
Self::AcceptRemote => "accept-remote",
Self::KeepBoth => "keep-both",
Self::Manual => "manual",
}
}
}
impl FileConflictStatus {
pub fn parse(value: &str) -> Result<Self, CasError> {
match value {
"open" => Ok(Self::Open),
"resolved" => Ok(Self::Resolved),
_ => Err(CasError::InvalidFileConflictStatus(value.to_owned())),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Resolved => "resolved",
}
}
}
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(&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()),
}
}
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), &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,
}
}
2026-05-15 15:08:20 +02:00
}