use std::collections::{BTreeMap, HashMap};
pub fn compute_cid(data: &[u8]) -> String {
let mut h: u64 = 14_695_981_039_346_656_037_u64;
for &b in data {
h ^= b as u64;
h = h.wrapping_mul(1_099_511_628_211_u64);
}
format!("{:016x}", h)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ArchiveError {
#[error("duplicate CID: {0}")]
DuplicateCid(String),
#[error("CID not found: {0}")]
CidNotFound(String),
#[error("archive is full")]
ArchiveFull,
#[error("corrupted entry: {0}")]
CorruptedEntry(String),
#[error("tombstoned entry: {0}")]
TombstonedEntry(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveEntry {
pub cid: String,
pub offset: u64,
pub length: u64,
pub compressed: bool,
pub metadata: HashMap<String, String>,
pub inserted_at: u64,
}
impl ArchiveEntry {
pub fn is_tombstoned(&self) -> bool {
self.metadata
.get("_tombstone")
.map(|v| v == "true")
.unwrap_or(false)
}
}
#[derive(Debug, Clone)]
pub struct ArchiveBlock {
pub data: Vec<u8>,
pub entry: ArchiveEntry,
}
#[derive(Debug, Clone, Default)]
pub struct ArchiveIndex {
pub entries: BTreeMap<String, ArchiveEntry>,
pub next_offset: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveConfig {
pub max_size_bytes: u64,
pub max_entries: usize,
pub allow_overwrites: bool,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
max_size_bytes: 1_073_741_824, max_entries: 1_000_000,
allow_overwrites: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ArchiveStats {
pub live_entries: usize,
pub tombstoned_entries: usize,
pub total_bytes: u64,
pub avg_entry_size_bytes: f64,
pub utilization_fraction: f64,
}
#[derive(Debug, Clone)]
pub struct ContentAddressedArchive {
pub config: ArchiveConfig,
pub index: ArchiveIndex,
pub data: Vec<u8>,
}
impl ContentAddressedArchive {
pub fn new(config: ArchiveConfig) -> Self {
Self {
config,
index: ArchiveIndex::default(),
data: Vec::new(),
}
}
pub fn is_full(&self) -> bool {
self.total_data_bytes() >= self.config.max_size_bytes
|| self.entry_count() >= self.config.max_entries
}
pub fn put(
&mut self,
data: Vec<u8>,
metadata: HashMap<String, String>,
now: u64,
) -> Result<String, ArchiveError> {
let cid = compute_cid(&data);
if let Some(existing) = self.index.entries.get(&cid) {
if existing.is_tombstoned() {
} else if !self.config.allow_overwrites {
return Err(ArchiveError::DuplicateCid(cid));
}
}
if self.is_full() {
let is_live_overwrite = self
.index
.entries
.get(&cid)
.map(|e| !e.is_tombstoned())
.unwrap_or(false);
if !is_live_overwrite {
return Err(ArchiveError::ArchiveFull);
}
}
let offset = self.index.next_offset;
let length = data.len() as u64;
self.data.extend_from_slice(&data);
self.index.next_offset += length;
let entry = ArchiveEntry {
cid: cid.clone(),
offset,
length,
compressed: false,
metadata,
inserted_at: now,
};
self.index.entries.insert(cid.clone(), entry);
Ok(cid)
}
pub fn get(&self, cid: &str) -> Result<&[u8], ArchiveError> {
match self.index.entries.get(cid) {
None => Err(ArchiveError::CidNotFound(cid.to_owned())),
Some(entry) if entry.is_tombstoned() => {
Err(ArchiveError::TombstonedEntry(cid.to_owned()))
}
Some(entry) => {
let start = entry.offset as usize;
let end = start + entry.length as usize;
Ok(&self.data[start..end])
}
}
}
pub fn get_entry(&self, cid: &str) -> Result<&ArchiveEntry, ArchiveError> {
match self.index.entries.get(cid) {
None => Err(ArchiveError::CidNotFound(cid.to_owned())),
Some(entry) if entry.is_tombstoned() => {
Err(ArchiveError::TombstonedEntry(cid.to_owned()))
}
Some(entry) => Ok(entry),
}
}
pub fn contains(&self, cid: &str) -> bool {
self.index
.entries
.get(cid)
.map(|e| !e.is_tombstoned())
.unwrap_or(false)
}
pub fn remove(&mut self, cid: &str) -> Result<Vec<u8>, ArchiveError> {
match self.index.entries.get_mut(cid) {
None => Err(ArchiveError::CidNotFound(cid.to_owned())),
Some(entry) if entry.is_tombstoned() => {
Err(ArchiveError::TombstonedEntry(cid.to_owned()))
}
Some(entry) => {
let start = entry.offset as usize;
let end = start + entry.length as usize;
let blob = self.data[start..end].to_vec();
entry
.metadata
.insert("_tombstone".to_owned(), "true".to_owned());
Ok(blob)
}
}
}
pub fn list_cids(&self) -> Vec<&str> {
self.index
.entries
.iter()
.filter(|(_, e)| !e.is_tombstoned())
.map(|(k, _)| k.as_str())
.collect()
}
pub fn list_entries(&self) -> Vec<&ArchiveEntry> {
self.index
.entries
.values()
.filter(|e| !e.is_tombstoned())
.collect()
}
pub fn total_data_bytes(&self) -> u64 {
self.index
.entries
.values()
.filter(|e| !e.is_tombstoned())
.map(|e| e.length)
.sum()
}
pub fn entry_count(&self) -> usize {
self.index
.entries
.values()
.filter(|e| !e.is_tombstoned())
.count()
}
pub fn verify_integrity(&self) -> Vec<String> {
let mut corrupt = Vec::new();
for (cid, entry) in &self.index.entries {
if entry.is_tombstoned() {
continue;
}
let start = entry.offset as usize;
let end = start + entry.length as usize;
let recomputed = compute_cid(&self.data[start..end]);
if &recomputed != cid {
corrupt.push(cid.clone());
}
}
corrupt
}
pub fn export_entries(&self) -> Vec<ArchiveBlock> {
self.index
.entries
.values()
.filter(|e| !e.is_tombstoned())
.map(|entry| {
let start = entry.offset as usize;
let end = start + entry.length as usize;
ArchiveBlock {
data: self.data[start..end].to_vec(),
entry: entry.clone(),
}
})
.collect()
}
pub fn merge(
&mut self,
other: &ContentAddressedArchive,
now: u64,
) -> Result<usize, ArchiveError> {
let mut added = 0usize;
let blocks: Vec<ArchiveBlock> = other.export_entries();
for block in blocks {
if self.index.entries.contains_key(&block.entry.cid) {
continue;
}
if self.is_full() {
return Err(ArchiveError::ArchiveFull);
}
let mut meta = block.entry.metadata.clone();
meta.remove("_tombstone");
self.put(block.data, meta, now)?;
added += 1;
}
Ok(added)
}
pub fn stats(&self) -> ArchiveStats {
let live_entries = self.entry_count();
let tombstoned_entries = self
.index
.entries
.values()
.filter(|e| e.is_tombstoned())
.count();
let total_bytes = self.total_data_bytes();
let avg_entry_size_bytes = if live_entries == 0 {
0.0
} else {
total_bytes as f64 / live_entries as f64
};
let utilization_fraction = if self.config.max_size_bytes == 0 {
0.0
} else {
total_bytes as f64 / self.config.max_size_bytes as f64
};
ArchiveStats {
live_entries,
tombstoned_entries,
total_bytes,
avg_entry_size_bytes,
utilization_fraction,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::content_addressed_archive::{
compute_cid, ArchiveConfig, ArchiveError, ContentAddressedArchive,
};
fn empty_archive() -> ContentAddressedArchive {
ContentAddressedArchive::new(ArchiveConfig::default())
}
fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn test_compute_cid_empty() {
let cid = compute_cid(&[]);
assert_eq!(cid.len(), 16);
assert_eq!(cid, format!("{:016x}", 14_695_981_039_346_656_037_u64));
}
#[test]
fn test_compute_cid_single_byte() {
let cid = compute_cid(&[0x41]);
assert_eq!(cid.len(), 16);
assert_ne!(cid, compute_cid(&[0x42]));
}
#[test]
fn test_compute_cid_deterministic() {
let data = b"hello world";
assert_eq!(compute_cid(data), compute_cid(data));
}
#[test]
fn test_compute_cid_different_data() {
assert_ne!(compute_cid(b"foo"), compute_cid(b"bar"));
}
#[test]
fn test_compute_cid_length() {
for data in [b"".as_slice(), b"a", b"hello", b"hello world!"] {
assert_eq!(compute_cid(data).len(), 16);
}
}
#[test]
fn test_put_returns_cid() {
let mut archive = empty_archive();
let data = b"test blob".to_vec();
let expected_cid = compute_cid(&data);
let cid = archive.put(data, HashMap::new(), 1).unwrap();
assert_eq!(cid, expected_cid);
}
#[test]
fn test_get_returns_correct_data() {
let mut archive = empty_archive();
let data = b"hello".to_vec();
let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
assert_eq!(archive.get(&cid).unwrap(), data.as_slice());
}
#[test]
fn test_get_unknown_cid() {
let archive = empty_archive();
assert_eq!(
archive.get("0000000000000000"),
Err(ArchiveError::CidNotFound("0000000000000000".to_owned()))
);
}
#[test]
fn test_multiple_blobs_independent() {
let mut archive = empty_archive();
let cid1 = archive.put(b"alpha".to_vec(), HashMap::new(), 0).unwrap();
let cid2 = archive.put(b"beta".to_vec(), HashMap::new(), 1).unwrap();
assert_eq!(archive.get(&cid1).unwrap(), b"alpha");
assert_eq!(archive.get(&cid2).unwrap(), b"beta");
}
#[test]
fn test_put_preserves_metadata() {
let mut archive = empty_archive();
let m = meta(&[("key", "value"), ("type", "test")]);
let cid = archive.put(b"data".to_vec(), m.clone(), 42).unwrap();
let entry = archive.get_entry(&cid).unwrap();
assert_eq!(entry.metadata.get("key"), Some(&"value".to_owned()));
assert_eq!(entry.metadata.get("type"), Some(&"test".to_owned()));
assert_eq!(entry.inserted_at, 42);
}
#[test]
fn test_duplicate_cid_rejected_by_default() {
let mut archive = empty_archive();
let data = b"same data".to_vec();
archive.put(data.clone(), HashMap::new(), 0).unwrap();
let result = archive.put(data, HashMap::new(), 1);
assert!(matches!(result, Err(ArchiveError::DuplicateCid(_))));
}
#[test]
fn test_duplicate_allowed_with_overwrites() {
let mut archive = ContentAddressedArchive::new(ArchiveConfig {
allow_overwrites: true,
..ArchiveConfig::default()
});
let data = b"same data".to_vec();
let cid1 = archive.put(data.clone(), HashMap::new(), 0).unwrap();
let cid2 = archive.put(data, meta(&[("v", "2")]), 1).unwrap();
assert_eq!(cid1, cid2);
assert_eq!(
archive.get_entry(&cid1).unwrap().metadata.get("v"),
Some(&"2".to_owned())
);
}
#[test]
fn test_contains_live_entry() {
let mut archive = empty_archive();
let cid = archive.put(b"exist".to_vec(), HashMap::new(), 0).unwrap();
assert!(archive.contains(&cid));
}
#[test]
fn test_contains_absent_entry() {
let archive = empty_archive();
assert!(!archive.contains("deadbeefdeadbeef"));
}
#[test]
fn test_remove_returns_data() {
let mut archive = empty_archive();
let data = b"to be removed".to_vec();
let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
let removed = archive.remove(&cid).unwrap();
assert_eq!(removed, data);
}
#[test]
fn test_remove_tombstones_entry() {
let mut archive = empty_archive();
let cid = archive.put(b"bye".to_vec(), HashMap::new(), 0).unwrap();
archive.remove(&cid).unwrap();
assert!(!archive.contains(&cid));
assert_eq!(
archive.get(&cid),
Err(ArchiveError::TombstonedEntry(cid.clone()))
);
}
#[test]
fn test_remove_unknown_cid() {
let mut archive = empty_archive();
assert_eq!(
archive.remove("0000000000000000"),
Err(ArchiveError::CidNotFound("0000000000000000".to_owned()))
);
}
#[test]
fn test_remove_already_tombstoned() {
let mut archive = empty_archive();
let cid = archive.put(b"once".to_vec(), HashMap::new(), 0).unwrap();
archive.remove(&cid).unwrap();
assert_eq!(
archive.remove(&cid),
Err(ArchiveError::TombstonedEntry(cid))
);
}
#[test]
fn test_list_cids_sorted() {
let mut archive = empty_archive();
archive.put(b"aaa".to_vec(), HashMap::new(), 0).unwrap();
archive.put(b"bbb".to_vec(), HashMap::new(), 1).unwrap();
archive.put(b"ccc".to_vec(), HashMap::new(), 2).unwrap();
let cids = archive.list_cids();
let mut sorted = cids.clone();
sorted.sort_unstable();
assert_eq!(cids, sorted);
}
#[test]
fn test_list_cids_excludes_tombstones() {
let mut archive = empty_archive();
let cid1 = archive.put(b"keep".to_vec(), HashMap::new(), 0).unwrap();
let cid2 = archive.put(b"remove".to_vec(), HashMap::new(), 1).unwrap();
archive.remove(&cid2).unwrap();
let cids = archive.list_cids();
assert!(cids.contains(&cid1.as_str()));
assert!(!cids.contains(&cid2.as_str()));
}
#[test]
fn test_list_entries_excludes_tombstones() {
let mut archive = empty_archive();
archive.put(b"alive".to_vec(), HashMap::new(), 0).unwrap();
let cid = archive.put(b"dead".to_vec(), HashMap::new(), 1).unwrap();
archive.remove(&cid).unwrap();
assert_eq!(archive.list_entries().len(), 1);
}
#[test]
fn test_entry_count() {
let mut archive = empty_archive();
assert_eq!(archive.entry_count(), 0);
archive.put(b"one".to_vec(), HashMap::new(), 0).unwrap();
assert_eq!(archive.entry_count(), 1);
let cid = archive.put(b"two".to_vec(), HashMap::new(), 1).unwrap();
assert_eq!(archive.entry_count(), 2);
archive.remove(&cid).unwrap();
assert_eq!(archive.entry_count(), 1);
}
#[test]
fn test_total_data_bytes() {
let mut archive = empty_archive();
archive.put(b"12345".to_vec(), HashMap::new(), 0).unwrap(); archive.put(b"abcde".to_vec(), HashMap::new(), 1).unwrap(); assert_eq!(archive.total_data_bytes(), 10);
}
#[test]
fn test_total_data_bytes_excludes_tombstones() {
let mut archive = empty_archive();
archive.put(b"12345".to_vec(), HashMap::new(), 0).unwrap();
let cid = archive.put(b"abcde".to_vec(), HashMap::new(), 1).unwrap();
archive.remove(&cid).unwrap();
assert_eq!(archive.total_data_bytes(), 5);
}
#[test]
fn test_is_full_by_entry_count() {
let mut archive = ContentAddressedArchive::new(ArchiveConfig {
max_entries: 2,
max_size_bytes: 1_073_741_824,
allow_overwrites: false,
});
archive.put(b"one".to_vec(), HashMap::new(), 0).unwrap();
archive.put(b"two".to_vec(), HashMap::new(), 1).unwrap();
assert!(archive.is_full());
let result = archive.put(b"three".to_vec(), HashMap::new(), 2);
assert_eq!(result, Err(ArchiveError::ArchiveFull));
}
#[test]
fn test_is_full_by_size() {
let mut archive = ContentAddressedArchive::new(ArchiveConfig {
max_size_bytes: 10,
max_entries: 1_000_000,
allow_overwrites: false,
});
archive
.put(b"12345678901".to_vec(), HashMap::new(), 0)
.unwrap(); assert!(archive.is_full());
let result = archive.put(b"extra".to_vec(), HashMap::new(), 1);
assert_eq!(result, Err(ArchiveError::ArchiveFull));
}
#[test]
fn test_verify_integrity_clean() {
let mut archive = empty_archive();
archive.put(b"good".to_vec(), HashMap::new(), 0).unwrap();
archive.put(b"data".to_vec(), HashMap::new(), 1).unwrap();
assert!(archive.verify_integrity().is_empty());
}
#[test]
fn test_verify_integrity_detects_corruption() {
let mut archive = empty_archive();
let cid = archive
.put(b"original".to_vec(), HashMap::new(), 0)
.unwrap();
let offset = archive.index.entries[&cid].offset as usize;
archive.data[offset] ^= 0xFF;
let corrupt = archive.verify_integrity();
assert_eq!(corrupt.len(), 1);
assert_eq!(corrupt[0], cid);
}
#[test]
fn test_verify_integrity_skips_tombstones() {
let mut archive = empty_archive();
let cid = archive.put(b"deleted".to_vec(), HashMap::new(), 0).unwrap();
let offset = archive.index.entries[&cid].offset as usize;
archive.data[offset] ^= 0xFF;
archive
.index
.entries
.get_mut(&cid)
.unwrap()
.metadata
.insert("_tombstone".to_owned(), "true".to_owned());
assert!(archive.verify_integrity().is_empty());
}
#[test]
fn test_export_entries_count() {
let mut archive = empty_archive();
archive.put(b"a".to_vec(), HashMap::new(), 0).unwrap();
archive.put(b"b".to_vec(), HashMap::new(), 1).unwrap();
let cid = archive.put(b"c".to_vec(), HashMap::new(), 2).unwrap();
archive.remove(&cid).unwrap();
let blocks = archive.export_entries();
assert_eq!(blocks.len(), 2);
}
#[test]
fn test_export_entries_data_correct() {
let mut archive = empty_archive();
let data = b"export me".to_vec();
let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
let blocks = archive.export_entries();
let block = blocks.iter().find(|b| b.entry.cid == cid).unwrap();
assert_eq!(block.data, data);
}
#[test]
fn test_merge_adds_missing_entries() {
let mut src = empty_archive();
src.put(b"src1".to_vec(), HashMap::new(), 0).unwrap();
src.put(b"src2".to_vec(), HashMap::new(), 1).unwrap();
let mut dst = empty_archive();
let added = dst.merge(&src, 100).unwrap();
assert_eq!(added, 2);
assert_eq!(dst.entry_count(), 2);
}
#[test]
fn test_merge_skips_existing_entries() {
let mut src = empty_archive();
let cid = src.put(b"shared".to_vec(), HashMap::new(), 0).unwrap();
let mut dst = empty_archive();
dst.put(b"shared".to_vec(), HashMap::new(), 1).unwrap();
let added = dst.merge(&src, 100).unwrap();
assert_eq!(added, 0);
assert_eq!(dst.entry_count(), 1);
assert_eq!(dst.get(&cid).unwrap(), b"shared");
}
#[test]
fn test_merge_skips_tombstoned_in_src() {
let mut src = empty_archive();
let cid = src.put(b"bye".to_vec(), HashMap::new(), 0).unwrap();
src.remove(&cid).unwrap();
let mut dst = empty_archive();
let added = dst.merge(&src, 100).unwrap();
assert_eq!(added, 0);
assert_eq!(dst.entry_count(), 0);
}
#[test]
fn test_merge_respects_capacity() {
let mut src = empty_archive();
src.put(b"item1".to_vec(), HashMap::new(), 0).unwrap();
src.put(b"item2".to_vec(), HashMap::new(), 1).unwrap();
let mut dst = ContentAddressedArchive::new(ArchiveConfig {
max_entries: 1,
..ArchiveConfig::default()
});
dst.put(b"pre-existing".to_vec(), HashMap::new(), 0)
.unwrap();
let result = dst.merge(&src, 100);
assert_eq!(result, Err(ArchiveError::ArchiveFull));
}
#[test]
fn test_stats_empty() {
let archive = empty_archive();
let s = archive.stats();
assert_eq!(s.live_entries, 0);
assert_eq!(s.tombstoned_entries, 0);
assert_eq!(s.total_bytes, 0);
assert_eq!(s.avg_entry_size_bytes, 0.0);
assert_eq!(s.utilization_fraction, 0.0);
}
#[test]
fn test_stats_live_and_tombstoned() {
let mut archive = empty_archive();
archive.put(b"hello".to_vec(), HashMap::new(), 0).unwrap(); let cid = archive.put(b"world".to_vec(), HashMap::new(), 1).unwrap(); archive.remove(&cid).unwrap();
let s = archive.stats();
assert_eq!(s.live_entries, 1);
assert_eq!(s.tombstoned_entries, 1);
assert_eq!(s.total_bytes, 5);
assert_eq!(s.avg_entry_size_bytes, 5.0);
}
#[test]
fn test_stats_utilization() {
let mut archive = ContentAddressedArchive::new(ArchiveConfig {
max_size_bytes: 100,
..ArchiveConfig::default()
});
archive
.put(b"1234567890".to_vec(), HashMap::new(), 0)
.unwrap(); let s = archive.stats();
assert!((s.utilization_fraction - 0.1_f64).abs() < f64::EPSILON);
}
#[test]
fn test_get_entry_fields() {
let mut archive = empty_archive();
let m = meta(&[("owner", "alice")]);
let cid = archive.put(b"payload".to_vec(), m, 999).unwrap();
let entry = archive.get_entry(&cid).unwrap();
assert_eq!(entry.cid, cid);
assert_eq!(entry.length, 7);
assert!(!entry.compressed);
assert_eq!(entry.inserted_at, 999);
assert_eq!(entry.metadata.get("owner"), Some(&"alice".to_owned()));
}
#[test]
fn test_get_entry_not_found() {
let archive = empty_archive();
assert!(matches!(
archive.get_entry("0000000000000000"),
Err(ArchiveError::CidNotFound(_))
));
}
#[test]
fn test_get_entry_tombstoned() {
let mut archive = empty_archive();
let cid = archive.put(b"gone".to_vec(), HashMap::new(), 0).unwrap();
archive.remove(&cid).unwrap();
assert!(matches!(
archive.get_entry(&cid),
Err(ArchiveError::TombstonedEntry(_))
));
}
#[test]
fn test_offsets_are_contiguous() {
let mut archive = empty_archive();
let blobs: &[&[u8]] = &[b"one", b"two", b"three"];
let mut cids = Vec::new();
for blob in blobs {
cids.push(archive.put(blob.to_vec(), HashMap::new(), 0).unwrap());
}
let mut expected_offset = 0u64;
for (blob, cid) in blobs.iter().zip(&cids) {
let entry = archive.index.entries.get(cid).unwrap();
assert_eq!(entry.offset, expected_offset);
assert_eq!(entry.length, blob.len() as u64);
expected_offset += blob.len() as u64;
}
}
#[test]
fn test_put_empty_blob() {
let mut archive = empty_archive();
let cid = archive.put(vec![], HashMap::new(), 0).unwrap();
assert_eq!(archive.get(&cid).unwrap(), b"");
assert_eq!(archive.entry_count(), 1);
assert_eq!(archive.total_data_bytes(), 0);
}
#[test]
fn test_large_blob_round_trip() {
let mut archive = empty_archive();
let data: Vec<u8> = (0..65_536).map(|i| (i % 256) as u8).collect();
let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
assert_eq!(archive.get(&cid).unwrap(), data.as_slice());
}
#[test]
fn test_reinsert_after_tombstone() {
let mut archive = empty_archive();
let data = b"reborn".to_vec();
let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
archive.remove(&cid).unwrap();
let cid2 = archive.put(data.clone(), meta(&[("v", "2")]), 1).unwrap();
assert_eq!(cid, cid2);
assert!(archive.contains(&cid));
assert_eq!(archive.get(&cid).unwrap(), data.as_slice());
}
}