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-07-05 23:02:59 +02:00
pub const ENCRYPTED_BLOB_ENVELOPE_VERSION : u16 = 2 ;
pub const PRIVATE_BLOB_AEAD_ALGORITHM : & str = " geth.aes-256-gcm.v1 " ;
pub const PRIVATE_BLOB_PROTOTYPE_ALGORITHM : & str = " geth.blake3-xor.v0.prototype " ;
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 ,
plaintext : & [ u8 ] ,
) -> Result < Vec < u8 > , CasError > {
2026-07-05 23:02:59 +02:00
let nonce : [ u8 ; 12 ] = rand ::random ( ) ;
encrypt_private_blob_with_nonce ( resource , secret_id , epoch , nonce , plaintext )
}
fn encrypt_private_blob_with_nonce (
resource : & str ,
secret_id : & str ,
epoch : u64 ,
nonce : [ u8 ; 12 ] ,
plaintext : & [ u8 ] ,
) -> Result < Vec < u8 > , CasError > {
use aes_gcm ::{
Aes256Gcm , Nonce ,
aead ::{ Aead , KeyInit , Payload } ,
} ;
2026-05-21 01:35:00 +02:00
let key = private_blob_key ( resource , secret_id , epoch ) ;
2026-07-05 23:02:59 +02:00
let cipher = Aes256Gcm ::new_from_slice ( & key )
. map_err ( | error | CasError ::EncryptedEnvelope ( error . to_string ( ) ) ) ? ;
let plaintext_hash = hash_bytes ( plaintext ) ;
let aad = private_blob_aad ( resource , epoch , & plaintext_hash ) ;
let ciphertext = cipher
. encrypt (
Nonce ::from_slice ( & nonce ) ,
Payload {
msg : plaintext ,
aad : aad . as_bytes ( ) ,
} ,
)
. map_err ( | _ | {
CasError ::EncryptedEnvelope ( " private blob AEAD encryption failed " . to_owned ( ) )
} ) ? ;
2026-05-21 01:35:00 +02:00
let envelope = EncryptedBlobEnvelope {
version : ENCRYPTED_BLOB_ENVELOPE_VERSION ,
2026-07-05 23:02:59 +02:00
algorithm : PRIVATE_BLOB_AEAD_ALGORITHM . to_owned ( ) ,
2026-05-21 01:35:00 +02:00
resource : resource . to_owned ( ) ,
epoch ,
2026-07-05 23:02:59 +02:00
plaintext_hash ,
nonce_hex : hex ::encode ( nonce ) ,
2026-05-21 01:35:00 +02:00
ciphertext ,
2026-07-05 23:02:59 +02:00
tag_hex : " included-in-aes-gcm-ciphertext " . to_owned ( ) ,
note : " private blob envelope uses AES-256-GCM with a random 96-bit nonce and resource-bound associated data; it does not claim forward secrecy or post-compromise security " . to_owned ( ) ,
2026-05-21 01:35:00 +02:00
} ;
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 {
2026-07-05 23:02:59 +02:00
if envelope . version = = 1 | | envelope . algorithm = = PRIVATE_BLOB_PROTOTYPE_ALGORITHM {
return Err ( CasError ::EncryptedEnvelope (
" prototype private blob envelopes are no longer accepted; recreate the private blob with this pre-release " . to_owned ( ) ,
) ) ;
}
2026-05-21 01:35:00 +02:00
return Err ( CasError ::EncryptedEnvelope ( format! (
" unsupported envelope version {} " ,
envelope . version
) ) ) ;
}
2026-07-05 23:02:59 +02:00
if envelope . algorithm ! = PRIVATE_BLOB_AEAD_ALGORITHM {
return Err ( CasError ::EncryptedEnvelope ( format! (
" unsupported encrypted blob algorithm {} " ,
envelope . algorithm
) ) ) ;
}
2026-05-21 01:35:00 +02:00
if envelope . resource ! = resource {
return Err ( CasError ::EncryptedEnvelope ( format! (
" envelope resource {} does not match requested resource {resource} " ,
envelope . resource
) ) ) ;
}
2026-07-05 23:02:59 +02:00
if envelope . tag_hex ! = " included-in-aes-gcm-ciphertext " {
2026-05-21 01:35:00 +02:00
return Err ( CasError ::EncryptedEnvelope (
2026-07-05 23:02:59 +02:00
" invalid AES-GCM envelope tag marker " . to_owned ( ) ,
2026-05-21 01:35:00 +02:00
) ) ;
}
2026-07-05 23:02:59 +02:00
use aes_gcm ::{
Aes256Gcm , Nonce ,
aead ::{ Aead , KeyInit , Payload } ,
} ;
let key = private_blob_key ( resource , secret_id , envelope . epoch ) ;
let nonce = decode_hex_exact ::< 12 > ( & envelope . nonce_hex ) ? ;
let cipher = Aes256Gcm ::new_from_slice ( & key )
. map_err ( | error | CasError ::EncryptedEnvelope ( error . to_string ( ) ) ) ? ;
let aad = private_blob_aad ( resource , envelope . epoch , & envelope . plaintext_hash ) ;
let plaintext = cipher
. decrypt (
Nonce ::from_slice ( & nonce ) ,
Payload {
msg : envelope . ciphertext . as_ref ( ) ,
aad : aad . as_bytes ( ) ,
} ,
)
. map_err ( | _ | {
CasError ::EncryptedEnvelope ( " private blob AEAD verification failed " . to_owned ( ) )
} ) ? ;
2026-05-21 01:35:00 +02:00
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 ] {
2026-07-05 23:02:59 +02:00
let material = format! ( " geth.private-blob.aes-256-gcm.v1 \0 {resource} \0 {secret_id} \0 {epoch} " ) ;
2026-05-21 01:35:00 +02:00
* blake3 ::hash ( material . as_bytes ( ) ) . as_bytes ( )
}
2026-07-05 23:02:59 +02:00
fn private_blob_aad ( resource : & str , epoch : u64 , plaintext_hash : & BlobHash ) -> String {
format! (
" {} \0 {} \0 {} \0 {} " ,
PRIVATE_BLOB_AEAD_ALGORITHM , resource , epoch , plaintext_hash
)
2026-05-21 01:35:00 +02:00
}
2026-07-05 23:02:59 +02:00
fn decode_hex_exact < const N : usize > ( hex : & str ) -> Result < [ u8 ; N ] , CasError > {
if hex . len ( ) ! = N * 2 {
return Err ( CasError ::EncryptedEnvelope ( format! (
" nonce must be {N} bytes encoded as lowercase hex "
) ) ) ;
2026-05-21 01:35:00 +02:00
}
2026-07-05 23:02:59 +02:00
let mut bytes = [ 0_ u8 ; N ] ;
for index in 0 .. N {
2026-05-21 01:35:00 +02:00
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 ]
2026-07-05 23:02:59 +02:00
fn private_blob_envelope_uses_aead_and_checks_bound_context ( ) {
2026-05-21 01:35:00 +02:00
let plaintext = b " private geth bytes " ;
2026-07-05 23:02:59 +02:00
let envelope = encrypt_private_blob_with_nonce (
" resource:cas:private " ,
" secret:test " ,
1 ,
[ 7 ; 12 ] ,
plaintext ,
)
. expect ( " encrypt " ) ;
2026-05-21 01:35:00 +02:00
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-07-05 23:02:59 +02:00
let decoded : EncryptedBlobEnvelope =
geth_codec ::decode_canonical ( & envelope ) . expect ( " decode envelope " ) ;
assert_eq! ( decoded . version , ENCRYPTED_BLOB_ENVELOPE_VERSION ) ;
assert_eq! ( decoded . algorithm , PRIVATE_BLOB_AEAD_ALGORITHM ) ;
assert_eq! ( decoded . nonce_hex , " 070707070707070707070707 " ) ;
assert_eq! ( decoded . tag_hex , " included-in-aes-gcm-ciphertext " ) ;
let mut tampered_ciphertext = decoded . clone ( ) ;
tampered_ciphertext . ciphertext [ 0 ] ^ = 0x01 ;
let tampered_ciphertext =
geth_codec ::encode_canonical ( & tampered_ciphertext ) . expect ( " encode tampered " ) ;
assert! (
decrypt_private_blob ( " resource:cas:private " , " secret:test " , & tampered_ciphertext )
. is_err ( )
) ;
let mut tampered_hash = decoded . clone ( ) ;
tampered_hash . plaintext_hash = hash_bytes ( b " other plaintext " ) ;
let tampered_hash = geth_codec ::encode_canonical ( & tampered_hash ) . expect ( " encode tampered " ) ;
assert! (
decrypt_private_blob ( " resource:cas:private " , " secret:test " , & tampered_hash ) . is_err ( )
) ;
}
#[ test ]
fn private_blob_envelope_generates_unique_nonces ( ) {
let first = encrypt_private_blob ( " resource:cas:private " , " secret:test " , 1 , b " same " )
. expect ( " first encrypt " ) ;
let second = encrypt_private_blob ( " resource:cas:private " , " secret:test " , 1 , b " same " )
. expect ( " second encrypt " ) ;
let first : EncryptedBlobEnvelope =
geth_codec ::decode_canonical ( & first ) . expect ( " decode first " ) ;
let second : EncryptedBlobEnvelope =
geth_codec ::decode_canonical ( & second ) . expect ( " decode second " ) ;
assert_ne! ( first . nonce_hex , second . nonce_hex ) ;
assert_ne! ( first . ciphertext , second . ciphertext ) ;
}
#[ test ]
fn prototype_private_blob_envelopes_are_rejected ( ) {
let envelope = EncryptedBlobEnvelope {
version : 1 ,
algorithm : PRIVATE_BLOB_PROTOTYPE_ALGORITHM . to_owned ( ) ,
resource : " resource:cas:private " . to_owned ( ) ,
epoch : 1 ,
plaintext_hash : hash_bytes ( b " private geth bytes " ) ,
nonce_hex : " 00 " . repeat ( 32 ) ,
ciphertext : vec ! [ 1 , 2 , 3 ] ,
tag_hex : " prototype-tag " . to_owned ( ) ,
note : " old prototype " . to_owned ( ) ,
} ;
let encoded = geth_codec ::encode_canonical ( & envelope ) . expect ( " encode prototype " ) ;
let error = decrypt_private_blob ( " resource:cas:private " , " secret:test " , & encoded )
. expect_err ( " reject prototype " ) ;
assert! (
error
. to_string ( )
. contains ( " prototype private blob envelopes " )
) ;
2026-05-21 01:35:00 +02:00
}
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
}