Add local CAS file roots
This commit is contained in:
parent
64e17ff957
commit
e1f36ffafb
12 changed files with 581 additions and 13 deletions
|
|
@ -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(¤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()),
|
||||
}
|
||||
}
|
||||
|
||||
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), ¤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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue