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

954 lines
30 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-21 01:35:00 +02:00
pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 1;
2026-05-17 21:20:24 +02:00
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-21 01:35:00 +02:00
#[error("encrypted blob envelope error: {0}")]
EncryptedEnvelope(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-21 01:35:00 +02:00
#[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<u8>,
pub tag_hex: String,
pub note: String,
}
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>,
}
2026-05-21 01:40:50 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeConflict {
pub path: String,
pub kind: FileConflictKind,
pub detail: String,
}
2026-05-18 03:57:26 +02:00
#[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",
}
}
}
2026-05-21 01:35:00 +02:00
pub fn encrypt_private_blob(
resource: &str,
secret_id: &str,
epoch: u64,
nonce_hex: &str,
plaintext: &[u8],
) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8> {
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)
}
2026-05-18 03:57:26 +02:00
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)
}
2026-05-18 17:18:25 +02:00
pub fn read_bytes(&self, hash: &BlobHash) -> Result<Vec<u8>, CasError> {
let path = self.blob_path(hash)?;
if !path.exists() {
return Err(CasError::NotFound(hash.to_string()));
}
Ok(std::fs::read(path)?)
}
2026-05-15 15:08:20 +02:00
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
}
2026-05-21 01:40:50 +02:00
pub fn detect_tree_conflicts(
base: &CasTreeObject,
local: &CasTreeObject,
remote: &CasTreeObject,
) -> Vec<TreeConflict> {
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
}
2026-05-18 03:50:09 +02:00
#[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-21 01:40:50 +02:00
fn rename_sources(base: &CasTreeObject, current: &CasTreeObject) -> BTreeMap<String, String> {
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()
}
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-18 17:18:25 +02:00
assert_eq!(
cas.read_bytes(&info.hash).expect("read bytes"),
b"hello geth"
);
2026-05-15 15:08:20 +02:00
}
2026-05-16 21:10:25 +02:00
2026-05-21 01:35:00 +02:00
#[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());
}
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);
}
2026-05-21 01:40:50 +02:00
#[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
}));
}
2026-05-18 03:50:09 +02:00
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
}