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; pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 1; #[derive(Debug, thiserror::Error)] pub enum CasError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("canonical codec error: {0}")] Codec(#[from] geth_codec::CodecError), #[error("invalid blob hash: {0}")] InvalidHash(String), #[error("blob not found: {0}")] NotFound(String), #[error("CAS tree root is not a directory: {0}")] TreeRootNotDirectory(String), #[error("CAS tree paths must be relative: {0}")] NonRelativeTreePath(String), #[error("invalid file root name: {0}")] InvalidFileRootName(String), #[error("invalid file conflict kind: {0}")] InvalidFileConflictKind(String), #[error("invalid file conflict resolution: {0}")] InvalidFileConflictResolution(String), #[error("invalid file conflict status: {0}")] InvalidFileConflictStatus(String), #[error("encrypted blob envelope error: {0}")] EncryptedEnvelope(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, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EncryptedBlobEnvelope { pub version: u16, pub algorithm: String, pub resource: String, pub epoch: u64, pub plaintext_hash: BlobHash, pub nonce_hex: String, pub ciphertext: Vec, pub tag_hex: String, pub note: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CasTreeObject { pub version: u16, pub entries: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CasTreeEntry { pub path: String, pub kind: CasTreeEntryKind, pub blob: Option, 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, } #[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, pub updated_at_ms: i64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FileRootScan { pub root: FileRoot, pub tree: BlobInfoSummary, pub changes: Vec, 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 }, } #[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, pub local_tree: Option, pub remote_tree: Option, pub detail: String, pub resolution: Option, pub resolution_note: Option, pub created_at_ms: i64, pub resolved_at_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TreeConflict { pub path: String, pub kind: FileConflictKind, pub detail: String, } #[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 { 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", } } } pub fn encrypt_private_blob( resource: &str, secret_id: &str, epoch: u64, nonce_hex: &str, plaintext: &[u8], ) -> Result, CasError> { let key = private_blob_key(resource, secret_id, epoch); let nonce = decode_32_byte_hex(nonce_hex)?; let ciphertext = xor_keystream(&key, &nonce, plaintext); let tag_hex = private_blob_tag(&key, &nonce, &ciphertext); let envelope = EncryptedBlobEnvelope { version: ENCRYPTED_BLOB_ENVELOPE_VERSION, algorithm: "geth.blake3-xor.v0.prototype".to_owned(), resource: resource.to_owned(), epoch, plaintext_hash: hash_bytes(plaintext), nonce_hex: nonce_hex.to_owned(), ciphertext, tag_hex, note: "prototype private blob envelope; not an audited AEAD and does not claim forward secrecy or post-compromise security".to_owned(), }; geth_codec::encode_canonical(&envelope).map_err(CasError::from) } pub fn decrypt_private_blob( resource: &str, secret_id: &str, envelope_bytes: &[u8], ) -> Result, CasError> { let envelope: EncryptedBlobEnvelope = geth_codec::decode_canonical(envelope_bytes)?; if envelope.version != ENCRYPTED_BLOB_ENVELOPE_VERSION { return Err(CasError::EncryptedEnvelope(format!( "unsupported envelope version {}", envelope.version ))); } if envelope.resource != resource { return Err(CasError::EncryptedEnvelope(format!( "envelope resource {} does not match requested resource {resource}", envelope.resource ))); } let key = private_blob_key(resource, secret_id, envelope.epoch); let nonce = decode_32_byte_hex(&envelope.nonce_hex)?; let expected_tag = private_blob_tag(&key, &nonce, &envelope.ciphertext); if expected_tag != envelope.tag_hex { return Err(CasError::EncryptedEnvelope( "encrypted blob tag verification failed".to_owned(), )); } let plaintext = xor_keystream(&key, &nonce, &envelope.ciphertext); let plaintext_hash = hash_bytes(&plaintext); if plaintext_hash != envelope.plaintext_hash { return Err(CasError::EncryptedEnvelope( "encrypted blob plaintext hash verification failed".to_owned(), )); } Ok(plaintext) } fn private_blob_key(resource: &str, secret_id: &str, epoch: u64) -> [u8; 32] { let material = format!("geth.private-blob.v0\0{resource}\0{secret_id}\0{epoch}"); *blake3::hash(material.as_bytes()).as_bytes() } fn xor_keystream(key: &[u8; 32], nonce: &[u8; 32], input: &[u8]) -> Vec { let mut out = Vec::with_capacity(input.len()); for (chunk_index, chunk) in input.chunks(32).enumerate() { let mut block_input = Vec::with_capacity(40); block_input.extend_from_slice(nonce); block_input.extend_from_slice(&(chunk_index as u64).to_le_bytes()); let block = blake3::keyed_hash(key, &block_input); out.extend( chunk .iter() .zip(block.as_bytes().iter()) .map(|(byte, mask)| byte ^ mask), ); } out } fn private_blob_tag(key: &[u8; 32], nonce: &[u8; 32], ciphertext: &[u8]) -> String { let mut input = Vec::with_capacity(nonce.len() + ciphertext.len()); input.extend_from_slice(nonce); input.extend_from_slice(ciphertext); blake3::keyed_hash(key, &input).to_hex().to_string() } fn decode_32_byte_hex(hex: &str) -> Result<[u8; 32], CasError> { if hex.len() != 64 { return Err(CasError::EncryptedEnvelope( "nonce must be 32 bytes encoded as lowercase hex".to_owned(), )); } let mut bytes = [0_u8; 32]; for index in 0..32 { bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16) .map_err(|error| CasError::EncryptedEnvelope(format!("invalid nonce hex: {error}")))?; } Ok(bytes) } impl FileConflictResolution { pub fn parse(value: &str) -> Result { 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 { 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", } } } impl LocalCas { #[must_use] pub fn new(root: impl Into) -> Self { Self { root: root.into() } } pub fn add_path(&self, path: &Path) -> Result { let bytes = std::fs::read(path)?; self.add_bytes(&bytes) } pub fn add_bytes(&self, bytes: &[u8]) -> Result { 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 { 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 read_bytes(&self, hash: &BlobHash) -> Result, CasError> { let path = self.blob_path(hash)?; if !path.exists() { return Err(CasError::NotFound(hash.to_string())); } Ok(std::fs::read(path)?) } pub fn has(&self, hash: &BlobHash) -> Result { Ok(self.blob_path(hash)?.exists()) } pub fn remove(&self, hash: &BlobHash) -> Result { let path = self.blob_path(hash)?; if !path.exists() { return Ok(false); } std::fs::remove_file(path)?; Ok(true) } pub fn list(&self) -> Result, 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 { validate_hash(hash)?; let hash = hash.as_str(); Ok(self .root .join("blobs") .join(&hash[0..2]) .join(&hash[2..4]) .join(hash)) } pub fn add_tree_path(&self, root: &Path) -> Result { 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 { 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, }) } pub fn diff_tree_objects( previous: Option<&CasTreeObject>, current: &CasTreeObject, ) -> Vec { 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::>(); let current_paths = current_entries.keys().cloned().collect::>(); let deleted = previous_paths .difference(¤t_paths) .cloned() .collect::>(); let created = current_paths .difference(&previous_paths) .cloned() .collect::>(); 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 } pub fn detect_tree_conflicts( base: &CasTreeObject, local: &CasTreeObject, remote: &CasTreeObject, ) -> Vec { let base_entries = tree_entry_map(base); let local_entries = tree_entry_map(local); let remote_entries = tree_entry_map(remote); let mut paths = BTreeSet::new(); paths.extend(base_entries.keys().cloned()); paths.extend(local_entries.keys().cloned()); paths.extend(remote_entries.keys().cloned()); let local_renames = rename_sources(base, local); let remote_renames = rename_sources(base, remote); let mut conflicts = Vec::new(); for (from, local_to) in &local_renames { if let Some(remote_to) = remote_renames.get(from) { if local_to == remote_to { continue; } conflicts.push(TreeConflict { path: from.clone(), kind: FileConflictKind::Rename, detail: format!( "local renamed {from} to {local_to}, while remote renamed it to {remote_to}" ), }); } } for path in paths { let base_entry = base_entries.get(&path).copied(); let local_entry = local_entries.get(&path).copied(); let remote_entry = remote_entries.get(&path).copied(); let local_changed = base_entry != local_entry; let remote_changed = base_entry != remote_entry; if !local_changed || !remote_changed || local_entry == remote_entry { continue; } if conflicts.iter().any(|conflict| conflict.path == path) { continue; } let kind = if base_entry.is_some() && (local_entry.is_none() || remote_entry.is_none()) { FileConflictKind::DeleteEdit } else { FileConflictKind::ConcurrentEdit }; let detail = match kind { FileConflictKind::ConcurrentEdit => { format!("local and remote both changed {path} from the previous imported base") } FileConflictKind::DeleteEdit => { format!("one side deleted {path} while the other side changed it") } FileConflictKind::Rename => unreachable!("rename conflicts are detected above"), }; conflicts.push(TreeConflict { path, kind, detail }); } conflicts.sort_by(|left, right| { left.path .cmp(&right.path) .then_with(|| left.kind.as_str().cmp(right.kind.as_str())) }); conflicts } #[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 { 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 rename_sources(base: &CasTreeObject, current: &CasTreeObject) -> BTreeMap { diff_tree_objects(Some(base), current) .into_iter() .filter_map(|change| match change { FileRootChange::Renamed { from, to } => Some((from, to)), FileRootChange::Created { .. } | FileRootChange::Modified { .. } | FileRootChange::Deleted { .. } => None, }) .collect() } fn collect_tree_entries( cas: &LocalCas, root: &Path, current: &Path, entries: &mut Vec, ) -> Result<(), CasError> { let mut children = std::fs::read_dir(current)?.collect::, _>>()?; 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 { 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::>() .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 } #[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 { 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()) } 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::*; #[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"); assert_eq!( cas.read_bytes(&info.hash).expect("read bytes"), b"hello geth" ); } #[test] fn private_blob_envelope_roundtrips_and_checks_resource() { let plaintext = b"private geth bytes"; let nonce = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; let envelope = encrypt_private_blob("resource:cas:private", "secret:test", 1, nonce, plaintext) .expect("encrypt"); let decrypted = decrypt_private_blob("resource:cas:private", "secret:test", &envelope) .expect("decrypt"); assert_eq!(decrypted, plaintext); assert!(decrypt_private_blob("resource:cas:other", "secret:test", &envelope).is_err()); assert!(decrypt_private_blob("resource:cas:private", "secret:other", &envelope).is_err()); } #[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")); } #[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!["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(_)) )); } #[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); } #[test] fn tree_conflicts_detect_concurrent_edit_delete_edit_and_rename() { let base = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![ tree_file("edit.txt", "01", 1), tree_file("delete-edit.txt", "02", 1), tree_file("rename.txt", "03", 1), ], }; let local = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![ tree_file("edit.txt", "04", 1), tree_file("delete-edit.txt", "05", 1), tree_file("local-rename.txt", "03", 1), ], }; let remote = CasTreeObject { version: CAS_TREE_OBJECT_VERSION, entries: vec![ tree_file("edit.txt", "06", 1), tree_file("remote-rename.txt", "03", 1), ], }; let conflicts = detect_tree_conflicts(&base, &local, &remote); assert!(conflicts.iter().any(|conflict| { conflict.path == "edit.txt" && conflict.kind == FileConflictKind::ConcurrentEdit })); assert!(conflicts.iter().any(|conflict| { conflict.path == "delete-edit.txt" && conflict.kind == FileConflictKind::DeleteEdit })); assert!(conflicts.iter().any(|conflict| { conflict.path == "rename.txt" && conflict.kind == FileConflictKind::Rename })); } 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, } } }