use std::collections::{BTreeMap, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::atp::object::ContentId;
pub type ChunkId = ContentId;
const MERKLE_LEAF_DOMAIN: &[u8] = b"asupersync.atp.cas.merkle.leaf.v1\0";
const MERKLE_NODE_DOMAIN: &[u8] = b"asupersync.atp.cas.merkle.node.v1\0";
const MERKLE_EMPTY_DOMAIN: &[u8] = b"asupersync.atp.cas.merkle.empty.v1\0";
const TREE_ROOT_DOMAIN: &[u8] = b"asupersync.atp.cas.tree.v1\0";
#[must_use]
pub fn chunk_id(bytes: &[u8]) -> ChunkId {
ContentId::from_bytes(bytes)
}
#[must_use]
pub fn chunk_merkle_root(ids: &[ChunkId]) -> ContentId {
if ids.is_empty() {
let mut h = Sha256::new();
h.update(MERKLE_EMPTY_DOMAIN);
return ContentId::new(h.finalize().into());
}
let mut level: Vec<[u8; 32]> = ids
.iter()
.map(|id| {
let mut h = Sha256::new();
h.update(MERKLE_LEAF_DOMAIN);
h.update(id.hash());
h.finalize().into()
})
.collect();
while level.len() > 1 {
let mut next: Vec<[u8; 32]> = Vec::with_capacity(level.len().div_ceil(2));
for pair in level.chunks(2) {
let left = &pair[0];
let right = if pair.len() == 2 { &pair[1] } else { &pair[0] };
let mut h = Sha256::new();
h.update(MERKLE_NODE_DOMAIN);
h.update(left);
h.update(right);
next.push(h.finalize().into());
}
level = next;
}
ContentId::new(level[0])
}
#[derive(Debug, Default, Clone)]
pub struct ContentAddressedStore {
chunks: HashMap<ChunkId, Vec<u8>>,
dedup_hits: u64,
bytes_stored: u64,
bytes_deduped: u64,
}
impl ContentAddressedStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn put(&mut self, bytes: &[u8]) -> (ChunkId, bool) {
let id = chunk_id(bytes);
let len = bytes.len() as u64;
if self.chunks.contains_key(&id) {
self.dedup_hits = self.dedup_hits.saturating_add(1);
self.bytes_deduped = self.bytes_deduped.saturating_add(len);
(id, false)
} else {
self.chunks.insert(id.clone(), bytes.to_vec());
self.bytes_stored = self.bytes_stored.saturating_add(len);
(id, true)
}
}
#[must_use]
pub fn get(&self, id: &ChunkId) -> Option<&[u8]> {
self.chunks.get(id).map(Vec::as_slice)
}
#[must_use]
pub fn contains(&self, id: &ChunkId) -> bool {
self.chunks.contains_key(id)
}
#[must_use]
pub fn len(&self) -> usize {
self.chunks.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
#[must_use]
pub fn dedup_hits(&self) -> u64 {
self.dedup_hits
}
#[must_use]
pub fn bytes_stored(&self) -> u64 {
self.bytes_stored
}
#[must_use]
pub fn bytes_deduped(&self) -> u64 {
self.bytes_deduped
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkRef {
pub id: ChunkId,
pub offset: u64,
pub len: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileManifest {
pub rel_path: String,
pub size: u64,
pub chunks: Vec<ChunkRef>,
pub merkle_root: ContentId,
}
impl FileManifest {
#[must_use]
pub fn from_chunks(rel_path: impl Into<String>, chunks: Vec<ChunkRef>) -> Self {
let size = chunks.iter().map(|c| u64::from(c.len)).sum();
let ids: Vec<ChunkId> = chunks.iter().map(|c| c.id.clone()).collect();
let merkle_root = chunk_merkle_root(&ids);
Self {
rel_path: rel_path.into(),
size,
chunks,
merkle_root,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeManifest {
entries: BTreeMap<String, FileManifest>,
root: ContentId,
}
impl Default for TreeManifest {
fn default() -> Self {
Self::new()
}
}
impl TreeManifest {
#[must_use]
pub fn new() -> Self {
let entries = BTreeMap::new();
let root = tree_root(&entries);
Self { entries, root }
}
#[must_use]
pub fn from_files(files: impl IntoIterator<Item = FileManifest>) -> Self {
let mut entries = BTreeMap::new();
for file in files {
entries.insert(file.rel_path.clone(), file);
}
let root = tree_root(&entries);
Self { entries, root }
}
pub fn insert(&mut self, file: FileManifest) {
self.entries.insert(file.rel_path.clone(), file);
self.root = tree_root(&self.entries);
}
#[must_use]
pub fn root(&self) -> &ContentId {
&self.root
}
#[must_use]
pub fn file(&self, rel_path: &str) -> Option<&FileManifest> {
self.entries.get(rel_path)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn tree_root(entries: &BTreeMap<String, FileManifest>) -> ContentId {
let mut h = Sha256::new();
h.update(TREE_ROOT_DOMAIN);
h.update((entries.len() as u64).to_be_bytes());
for (path, file) in entries {
h.update((path.len() as u64).to_be_bytes());
h.update(path.as_bytes());
h.update(file.merkle_root.hash());
}
ContentId::new(h.finalize().into())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TreeDelta {
pub changed_files: Vec<String>,
pub removed_files: Vec<String>,
pub new_chunks: Vec<ChunkId>,
}
impl TreeDelta {
#[must_use]
pub fn is_empty(&self) -> bool {
self.changed_files.is_empty() && self.removed_files.is_empty() && self.new_chunks.is_empty()
}
}
#[must_use]
pub fn diff_trees(
prior: &TreeManifest,
current: &TreeManifest,
have: &ContentAddressedStore,
) -> TreeDelta {
let mut delta = TreeDelta::default();
if prior.root == current.root {
return delta; }
let mut seen: HashSet<ChunkId> = HashSet::new();
for (path, file) in ¤t.entries {
let changed = match prior.entries.get(path) {
Some(prev) => prev.merkle_root != file.merkle_root,
None => true,
};
if !changed {
continue; }
delta.changed_files.push(path.clone());
for chunk in &file.chunks {
if have.contains(&chunk.id) {
continue; }
if seen.insert(chunk.id.clone()) {
delta.new_chunks.push(chunk.id.clone());
}
}
}
for path in prior.entries.keys() {
if !current.entries.contains_key(path) {
delta.removed_files.push(path.clone());
}
}
delta
}
#[cfg(test)]
mod tests {
use super::*;
fn refs_from(byte_chunks: &[&[u8]]) -> (Vec<ChunkRef>, Vec<Vec<u8>>) {
let mut refs = Vec::new();
let mut bytes = Vec::new();
let mut offset = 0u64;
for c in byte_chunks {
refs.push(ChunkRef {
id: chunk_id(c),
offset,
len: c.len() as u32,
});
offset += c.len() as u64;
bytes.push(c.to_vec());
}
(refs, bytes)
}
#[test]
fn cas_round_trip_and_dedup() {
let mut cas = ContentAddressedStore::new();
let (id1, new1) = cas.put(b"hello world");
assert!(new1, "first insert is new");
assert_eq!(cas.get(&id1), Some(b"hello world".as_slice()));
assert_eq!(cas.len(), 1);
let (id2, new2) = cas.put(b"hello world");
assert_eq!(id1, id2, "identical bytes share one content id");
assert!(!new2, "second insert is a dedup hit");
assert_eq!(cas.len(), 1, "dedup hit must not grow the store");
assert_eq!(cas.dedup_hits(), 1);
assert_eq!(cas.bytes_deduped(), b"hello world".len() as u64);
let (id3, new3) = cas.put(b"goodbye");
assert!(new3);
assert_ne!(id1, id3);
assert_eq!(cas.len(), 2);
assert!(cas.contains(&id3));
}
#[test]
fn merkle_root_is_stable_order_sensitive_and_distinct() {
let a = chunk_id(b"a");
let b = chunk_id(b"b");
let c = chunk_id(b"c");
assert_eq!(
chunk_merkle_root(&[a.clone(), b.clone(), c.clone()]),
chunk_merkle_root(&[a.clone(), b.clone(), c.clone()])
);
assert_ne!(
chunk_merkle_root(&[a.clone(), b.clone()]),
chunk_merkle_root(&[b.clone(), a.clone()])
);
assert_ne!(
chunk_merkle_root(&[a.clone(), b.clone()]),
chunk_merkle_root(&[a.clone(), c.clone()])
);
assert_eq!(chunk_merkle_root(&[]), chunk_merkle_root(&[]));
assert_ne!(chunk_merkle_root(&[]), chunk_merkle_root(&[a]));
}
#[test]
fn tree_diff_skips_unchanged_localizes_change_and_dedups() {
let (f1_refs, f1_bytes) = refs_from(&[b"alpha-0", b"alpha-1"]);
let (f2_refs, f2_bytes) = refs_from(&[b"beta-0", b"beta-1"]);
let prior = TreeManifest::from_files([
FileManifest::from_chunks("dir/a.bin", f1_refs.clone()),
FileManifest::from_chunks("dir/b.bin", f2_refs),
]);
let mut have = ContentAddressedStore::new();
for b in f1_bytes.iter().chain(f2_bytes.iter()) {
have.put(b);
}
let same = diff_trees(&prior, &prior, &have);
assert!(same.is_empty(), "identical trees transmit nothing");
let (mut f1b_refs, _) = refs_from(&[b"alpha-0", b"alpha-1"]);
let new_chunk: &[u8] = b"alpha-2-NEW";
f1b_refs.push(ChunkRef {
id: chunk_id(new_chunk),
offset: 14,
len: new_chunk.len() as u32,
});
let current = TreeManifest::from_files([
FileManifest::from_chunks("dir/a.bin", f1b_refs),
FileManifest::from_chunks("dir/b.bin", f2_refs_clone()),
]);
let delta = diff_trees(&prior, ¤t, &have);
assert_eq!(
delta.changed_files,
vec!["dir/a.bin".to_string()],
"only a.bin changed"
);
assert!(delta.removed_files.is_empty());
assert_eq!(delta.new_chunks, vec![chunk_id(new_chunk)]);
}
#[test]
fn tree_diff_dedups_missing_chunks_across_changed_files() {
let prior = TreeManifest::from_files([
FileManifest::from_chunks("a.bin", refs_from(&[b"a-old"]).0),
FileManifest::from_chunks("b.bin", refs_from(&[b"b-old"]).0),
FileManifest::from_chunks("unchanged.bin", refs_from(&[b"already-held"]).0),
]);
let mut have = ContentAddressedStore::new();
have.put(b"already-held");
let shared_new = b"shared-new-content";
let a_only = b"a-only-new";
let b_only = b"b-only-new";
let current = TreeManifest::from_files([
FileManifest::from_chunks("a.bin", refs_from(&[b"already-held", shared_new, a_only]).0),
FileManifest::from_chunks("b.bin", refs_from(&[shared_new, b_only]).0),
FileManifest::from_chunks("unchanged.bin", refs_from(&[b"already-held"]).0),
]);
let delta = diff_trees(&prior, ¤t, &have);
assert_eq!(
delta.changed_files,
vec!["a.bin".to_string(), "b.bin".to_string()]
);
assert!(delta.removed_files.is_empty());
assert_eq!(
delta.new_chunks,
vec![chunk_id(shared_new), chunk_id(a_only), chunk_id(b_only)],
"shared missing content must be transmitted once in first-seen order"
);
}
fn f2_refs_clone() -> Vec<ChunkRef> {
refs_from(&[b"beta-0", b"beta-1"]).0
}
#[test]
fn diff_reports_removed_files_and_new_file_chunks() {
let prior = TreeManifest::from_files([
FileManifest::from_chunks("keep.bin", refs_from(&[b"k0"]).0),
FileManifest::from_chunks("gone.bin", refs_from(&[b"g0"]).0),
]);
let have = ContentAddressedStore::new();
let current = TreeManifest::from_files([
FileManifest::from_chunks("keep.bin", refs_from(&[b"k0"]).0),
FileManifest::from_chunks("fresh.bin", refs_from(&[b"new!"]).0),
]);
let delta = diff_trees(&prior, ¤t, &have);
assert!(delta.changed_files.contains(&"fresh.bin".to_string()));
assert!(delta.removed_files.contains(&"gone.bin".to_string()));
assert_eq!(delta.new_chunks, vec![chunk_id(b"new!")]);
}
}