1use std::collections::{BTreeMap, HashMap};
8
9pub fn compute_cid(data: &[u8]) -> String {
11 let mut h: u64 = 14_695_981_039_346_656_037_u64;
12 for &b in data {
13 h ^= b as u64;
14 h = h.wrapping_mul(1_099_511_628_211_u64);
15 }
16 format!("{:016x}", h)
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum ArchiveError {
24 #[error("duplicate CID: {0}")]
26 DuplicateCid(String),
27
28 #[error("CID not found: {0}")]
30 CidNotFound(String),
31
32 #[error("archive is full")]
34 ArchiveFull,
35
36 #[error("corrupted entry: {0}")]
38 CorruptedEntry(String),
39
40 #[error("tombstoned entry: {0}")]
42 TombstonedEntry(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ArchiveEntry {
50 pub cid: String,
52 pub offset: u64,
54 pub length: u64,
56 pub compressed: bool,
58 pub metadata: HashMap<String, String>,
61 pub inserted_at: u64,
63}
64
65impl ArchiveEntry {
66 pub fn is_tombstoned(&self) -> bool {
68 self.metadata
69 .get("_tombstone")
70 .map(|v| v == "true")
71 .unwrap_or(false)
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct ArchiveBlock {
78 pub data: Vec<u8>,
80 pub entry: ArchiveEntry,
82}
83
84#[derive(Debug, Clone, Default)]
86pub struct ArchiveIndex {
87 pub entries: BTreeMap<String, ArchiveEntry>,
89 pub next_offset: u64,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ArchiveConfig {
98 pub max_size_bytes: u64,
100 pub max_entries: usize,
102 pub allow_overwrites: bool,
105}
106
107impl Default for ArchiveConfig {
108 fn default() -> Self {
109 Self {
110 max_size_bytes: 1_073_741_824, max_entries: 1_000_000,
112 allow_overwrites: false,
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
121pub struct ArchiveStats {
122 pub live_entries: usize,
124 pub tombstoned_entries: usize,
126 pub total_bytes: u64,
128 pub avg_entry_size_bytes: f64,
130 pub utilization_fraction: f64,
132}
133
134#[derive(Debug, Clone)]
143pub struct ContentAddressedArchive {
144 pub config: ArchiveConfig,
146 pub index: ArchiveIndex,
148 pub data: Vec<u8>,
150}
151
152impl ContentAddressedArchive {
153 pub fn new(config: ArchiveConfig) -> Self {
157 Self {
158 config,
159 index: ArchiveIndex::default(),
160 data: Vec::new(),
161 }
162 }
163
164 pub fn is_full(&self) -> bool {
168 self.total_data_bytes() >= self.config.max_size_bytes
169 || self.entry_count() >= self.config.max_entries
170 }
171
172 pub fn put(
181 &mut self,
182 data: Vec<u8>,
183 metadata: HashMap<String, String>,
184 now: u64,
185 ) -> Result<String, ArchiveError> {
186 let cid = compute_cid(&data);
187
188 if let Some(existing) = self.index.entries.get(&cid) {
190 if existing.is_tombstoned() {
191 } else if !self.config.allow_overwrites {
194 return Err(ArchiveError::DuplicateCid(cid));
195 }
196 }
198
199 if self.is_full() {
201 let is_live_overwrite = self
204 .index
205 .entries
206 .get(&cid)
207 .map(|e| !e.is_tombstoned())
208 .unwrap_or(false);
209 if !is_live_overwrite {
210 return Err(ArchiveError::ArchiveFull);
211 }
212 }
213
214 let offset = self.index.next_offset;
215 let length = data.len() as u64;
216
217 self.data.extend_from_slice(&data);
218 self.index.next_offset += length;
219
220 let entry = ArchiveEntry {
221 cid: cid.clone(),
222 offset,
223 length,
224 compressed: false,
225 metadata,
226 inserted_at: now,
227 };
228 self.index.entries.insert(cid.clone(), entry);
229
230 Ok(cid)
231 }
232
233 pub fn get(&self, cid: &str) -> Result<&[u8], ArchiveError> {
235 match self.index.entries.get(cid) {
236 None => Err(ArchiveError::CidNotFound(cid.to_owned())),
237 Some(entry) if entry.is_tombstoned() => {
238 Err(ArchiveError::TombstonedEntry(cid.to_owned()))
239 }
240 Some(entry) => {
241 let start = entry.offset as usize;
242 let end = start + entry.length as usize;
243 Ok(&self.data[start..end])
244 }
245 }
246 }
247
248 pub fn get_entry(&self, cid: &str) -> Result<&ArchiveEntry, ArchiveError> {
250 match self.index.entries.get(cid) {
251 None => Err(ArchiveError::CidNotFound(cid.to_owned())),
252 Some(entry) if entry.is_tombstoned() => {
253 Err(ArchiveError::TombstonedEntry(cid.to_owned()))
254 }
255 Some(entry) => Ok(entry),
256 }
257 }
258
259 pub fn contains(&self, cid: &str) -> bool {
261 self.index
262 .entries
263 .get(cid)
264 .map(|e| !e.is_tombstoned())
265 .unwrap_or(false)
266 }
267
268 pub fn remove(&mut self, cid: &str) -> Result<Vec<u8>, ArchiveError> {
273 match self.index.entries.get_mut(cid) {
274 None => Err(ArchiveError::CidNotFound(cid.to_owned())),
275 Some(entry) if entry.is_tombstoned() => {
276 Err(ArchiveError::TombstonedEntry(cid.to_owned()))
277 }
278 Some(entry) => {
279 let start = entry.offset as usize;
280 let end = start + entry.length as usize;
281 let blob = self.data[start..end].to_vec();
282 entry
283 .metadata
284 .insert("_tombstone".to_owned(), "true".to_owned());
285 Ok(blob)
286 }
287 }
288 }
289
290 pub fn list_cids(&self) -> Vec<&str> {
294 self.index
295 .entries
296 .iter()
297 .filter(|(_, e)| !e.is_tombstoned())
298 .map(|(k, _)| k.as_str())
299 .collect()
300 }
301
302 pub fn list_entries(&self) -> Vec<&ArchiveEntry> {
304 self.index
305 .entries
306 .values()
307 .filter(|e| !e.is_tombstoned())
308 .collect()
309 }
310
311 pub fn total_data_bytes(&self) -> u64 {
313 self.index
314 .entries
315 .values()
316 .filter(|e| !e.is_tombstoned())
317 .map(|e| e.length)
318 .sum()
319 }
320
321 pub fn entry_count(&self) -> usize {
323 self.index
324 .entries
325 .values()
326 .filter(|e| !e.is_tombstoned())
327 .count()
328 }
329
330 pub fn verify_integrity(&self) -> Vec<String> {
337 let mut corrupt = Vec::new();
338 for (cid, entry) in &self.index.entries {
339 if entry.is_tombstoned() {
340 continue;
341 }
342 let start = entry.offset as usize;
343 let end = start + entry.length as usize;
344 let recomputed = compute_cid(&self.data[start..end]);
345 if &recomputed != cid {
346 corrupt.push(cid.clone());
347 }
348 }
349 corrupt
350 }
351
352 pub fn export_entries(&self) -> Vec<ArchiveBlock> {
356 self.index
357 .entries
358 .values()
359 .filter(|e| !e.is_tombstoned())
360 .map(|entry| {
361 let start = entry.offset as usize;
362 let end = start + entry.length as usize;
363 ArchiveBlock {
364 data: self.data[start..end].to_vec(),
365 entry: entry.clone(),
366 }
367 })
368 .collect()
369 }
370
371 pub fn merge(
376 &mut self,
377 other: &ContentAddressedArchive,
378 now: u64,
379 ) -> Result<usize, ArchiveError> {
380 let mut added = 0usize;
381
382 let blocks: Vec<ArchiveBlock> = other.export_entries();
384
385 for block in blocks {
386 if self.index.entries.contains_key(&block.entry.cid) {
388 continue;
389 }
390 if self.is_full() {
392 return Err(ArchiveError::ArchiveFull);
393 }
394 let mut meta = block.entry.metadata.clone();
396 meta.remove("_tombstone");
397 self.put(block.data, meta, now)?;
398 added += 1;
399 }
400
401 Ok(added)
402 }
403
404 pub fn stats(&self) -> ArchiveStats {
408 let live_entries = self.entry_count();
409 let tombstoned_entries = self
410 .index
411 .entries
412 .values()
413 .filter(|e| e.is_tombstoned())
414 .count();
415 let total_bytes = self.total_data_bytes();
416 let avg_entry_size_bytes = if live_entries == 0 {
417 0.0
418 } else {
419 total_bytes as f64 / live_entries as f64
420 };
421 let utilization_fraction = if self.config.max_size_bytes == 0 {
422 0.0
423 } else {
424 total_bytes as f64 / self.config.max_size_bytes as f64
425 };
426 ArchiveStats {
427 live_entries,
428 tombstoned_entries,
429 total_bytes,
430 avg_entry_size_bytes,
431 utilization_fraction,
432 }
433 }
434}
435
436#[cfg(test)]
441mod tests {
442 use std::collections::HashMap;
443
444 use crate::content_addressed_archive::{
445 compute_cid, ArchiveConfig, ArchiveError, ContentAddressedArchive,
446 };
447
448 fn empty_archive() -> ContentAddressedArchive {
449 ContentAddressedArchive::new(ArchiveConfig::default())
450 }
451
452 fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
453 pairs
454 .iter()
455 .map(|(k, v)| (k.to_string(), v.to_string()))
456 .collect()
457 }
458
459 #[test]
462 fn test_compute_cid_empty() {
463 let cid = compute_cid(&[]);
465 assert_eq!(cid.len(), 16);
466 assert_eq!(cid, format!("{:016x}", 14_695_981_039_346_656_037_u64));
467 }
468
469 #[test]
470 fn test_compute_cid_single_byte() {
471 let cid = compute_cid(&[0x41]);
472 assert_eq!(cid.len(), 16);
473 assert_ne!(cid, compute_cid(&[0x42]));
475 }
476
477 #[test]
478 fn test_compute_cid_deterministic() {
479 let data = b"hello world";
480 assert_eq!(compute_cid(data), compute_cid(data));
481 }
482
483 #[test]
484 fn test_compute_cid_different_data() {
485 assert_ne!(compute_cid(b"foo"), compute_cid(b"bar"));
486 }
487
488 #[test]
489 fn test_compute_cid_length() {
490 for data in [b"".as_slice(), b"a", b"hello", b"hello world!"] {
491 assert_eq!(compute_cid(data).len(), 16);
492 }
493 }
494
495 #[test]
498 fn test_put_returns_cid() {
499 let mut archive = empty_archive();
500 let data = b"test blob".to_vec();
501 let expected_cid = compute_cid(&data);
502 let cid = archive.put(data, HashMap::new(), 1).unwrap();
503 assert_eq!(cid, expected_cid);
504 }
505
506 #[test]
507 fn test_get_returns_correct_data() {
508 let mut archive = empty_archive();
509 let data = b"hello".to_vec();
510 let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
511 assert_eq!(archive.get(&cid).unwrap(), data.as_slice());
512 }
513
514 #[test]
515 fn test_get_unknown_cid() {
516 let archive = empty_archive();
517 assert_eq!(
518 archive.get("0000000000000000"),
519 Err(ArchiveError::CidNotFound("0000000000000000".to_owned()))
520 );
521 }
522
523 #[test]
524 fn test_multiple_blobs_independent() {
525 let mut archive = empty_archive();
526 let cid1 = archive.put(b"alpha".to_vec(), HashMap::new(), 0).unwrap();
527 let cid2 = archive.put(b"beta".to_vec(), HashMap::new(), 1).unwrap();
528 assert_eq!(archive.get(&cid1).unwrap(), b"alpha");
529 assert_eq!(archive.get(&cid2).unwrap(), b"beta");
530 }
531
532 #[test]
533 fn test_put_preserves_metadata() {
534 let mut archive = empty_archive();
535 let m = meta(&[("key", "value"), ("type", "test")]);
536 let cid = archive.put(b"data".to_vec(), m.clone(), 42).unwrap();
537 let entry = archive.get_entry(&cid).unwrap();
538 assert_eq!(entry.metadata.get("key"), Some(&"value".to_owned()));
539 assert_eq!(entry.metadata.get("type"), Some(&"test".to_owned()));
540 assert_eq!(entry.inserted_at, 42);
541 }
542
543 #[test]
546 fn test_duplicate_cid_rejected_by_default() {
547 let mut archive = empty_archive();
548 let data = b"same data".to_vec();
549 archive.put(data.clone(), HashMap::new(), 0).unwrap();
550 let result = archive.put(data, HashMap::new(), 1);
551 assert!(matches!(result, Err(ArchiveError::DuplicateCid(_))));
552 }
553
554 #[test]
555 fn test_duplicate_allowed_with_overwrites() {
556 let mut archive = ContentAddressedArchive::new(ArchiveConfig {
557 allow_overwrites: true,
558 ..ArchiveConfig::default()
559 });
560 let data = b"same data".to_vec();
561 let cid1 = archive.put(data.clone(), HashMap::new(), 0).unwrap();
562 let cid2 = archive.put(data, meta(&[("v", "2")]), 1).unwrap();
563 assert_eq!(cid1, cid2);
564 assert_eq!(
566 archive.get_entry(&cid1).unwrap().metadata.get("v"),
567 Some(&"2".to_owned())
568 );
569 }
570
571 #[test]
574 fn test_contains_live_entry() {
575 let mut archive = empty_archive();
576 let cid = archive.put(b"exist".to_vec(), HashMap::new(), 0).unwrap();
577 assert!(archive.contains(&cid));
578 }
579
580 #[test]
581 fn test_contains_absent_entry() {
582 let archive = empty_archive();
583 assert!(!archive.contains("deadbeefdeadbeef"));
584 }
585
586 #[test]
589 fn test_remove_returns_data() {
590 let mut archive = empty_archive();
591 let data = b"to be removed".to_vec();
592 let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
593 let removed = archive.remove(&cid).unwrap();
594 assert_eq!(removed, data);
595 }
596
597 #[test]
598 fn test_remove_tombstones_entry() {
599 let mut archive = empty_archive();
600 let cid = archive.put(b"bye".to_vec(), HashMap::new(), 0).unwrap();
601 archive.remove(&cid).unwrap();
602 assert!(!archive.contains(&cid));
603 assert_eq!(
604 archive.get(&cid),
605 Err(ArchiveError::TombstonedEntry(cid.clone()))
606 );
607 }
608
609 #[test]
610 fn test_remove_unknown_cid() {
611 let mut archive = empty_archive();
612 assert_eq!(
613 archive.remove("0000000000000000"),
614 Err(ArchiveError::CidNotFound("0000000000000000".to_owned()))
615 );
616 }
617
618 #[test]
619 fn test_remove_already_tombstoned() {
620 let mut archive = empty_archive();
621 let cid = archive.put(b"once".to_vec(), HashMap::new(), 0).unwrap();
622 archive.remove(&cid).unwrap();
623 assert_eq!(
624 archive.remove(&cid),
625 Err(ArchiveError::TombstonedEntry(cid))
626 );
627 }
628
629 #[test]
632 fn test_list_cids_sorted() {
633 let mut archive = empty_archive();
634 archive.put(b"aaa".to_vec(), HashMap::new(), 0).unwrap();
635 archive.put(b"bbb".to_vec(), HashMap::new(), 1).unwrap();
636 archive.put(b"ccc".to_vec(), HashMap::new(), 2).unwrap();
637 let cids = archive.list_cids();
638 let mut sorted = cids.clone();
639 sorted.sort_unstable();
640 assert_eq!(cids, sorted);
641 }
642
643 #[test]
644 fn test_list_cids_excludes_tombstones() {
645 let mut archive = empty_archive();
646 let cid1 = archive.put(b"keep".to_vec(), HashMap::new(), 0).unwrap();
647 let cid2 = archive.put(b"remove".to_vec(), HashMap::new(), 1).unwrap();
648 archive.remove(&cid2).unwrap();
649 let cids = archive.list_cids();
650 assert!(cids.contains(&cid1.as_str()));
651 assert!(!cids.contains(&cid2.as_str()));
652 }
653
654 #[test]
655 fn test_list_entries_excludes_tombstones() {
656 let mut archive = empty_archive();
657 archive.put(b"alive".to_vec(), HashMap::new(), 0).unwrap();
658 let cid = archive.put(b"dead".to_vec(), HashMap::new(), 1).unwrap();
659 archive.remove(&cid).unwrap();
660 assert_eq!(archive.list_entries().len(), 1);
661 }
662
663 #[test]
666 fn test_entry_count() {
667 let mut archive = empty_archive();
668 assert_eq!(archive.entry_count(), 0);
669 archive.put(b"one".to_vec(), HashMap::new(), 0).unwrap();
670 assert_eq!(archive.entry_count(), 1);
671 let cid = archive.put(b"two".to_vec(), HashMap::new(), 1).unwrap();
672 assert_eq!(archive.entry_count(), 2);
673 archive.remove(&cid).unwrap();
674 assert_eq!(archive.entry_count(), 1);
675 }
676
677 #[test]
678 fn test_total_data_bytes() {
679 let mut archive = empty_archive();
680 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);
683 }
684
685 #[test]
686 fn test_total_data_bytes_excludes_tombstones() {
687 let mut archive = empty_archive();
688 archive.put(b"12345".to_vec(), HashMap::new(), 0).unwrap();
689 let cid = archive.put(b"abcde".to_vec(), HashMap::new(), 1).unwrap();
690 archive.remove(&cid).unwrap();
691 assert_eq!(archive.total_data_bytes(), 5);
692 }
693
694 #[test]
697 fn test_is_full_by_entry_count() {
698 let mut archive = ContentAddressedArchive::new(ArchiveConfig {
699 max_entries: 2,
700 max_size_bytes: 1_073_741_824,
701 allow_overwrites: false,
702 });
703 archive.put(b"one".to_vec(), HashMap::new(), 0).unwrap();
704 archive.put(b"two".to_vec(), HashMap::new(), 1).unwrap();
705 assert!(archive.is_full());
706 let result = archive.put(b"three".to_vec(), HashMap::new(), 2);
707 assert_eq!(result, Err(ArchiveError::ArchiveFull));
708 }
709
710 #[test]
711 fn test_is_full_by_size() {
712 let mut archive = ContentAddressedArchive::new(ArchiveConfig {
713 max_size_bytes: 10,
714 max_entries: 1_000_000,
715 allow_overwrites: false,
716 });
717 archive
718 .put(b"12345678901".to_vec(), HashMap::new(), 0)
719 .unwrap(); assert!(archive.is_full());
721 let result = archive.put(b"extra".to_vec(), HashMap::new(), 1);
722 assert_eq!(result, Err(ArchiveError::ArchiveFull));
723 }
724
725 #[test]
728 fn test_verify_integrity_clean() {
729 let mut archive = empty_archive();
730 archive.put(b"good".to_vec(), HashMap::new(), 0).unwrap();
731 archive.put(b"data".to_vec(), HashMap::new(), 1).unwrap();
732 assert!(archive.verify_integrity().is_empty());
733 }
734
735 #[test]
736 fn test_verify_integrity_detects_corruption() {
737 let mut archive = empty_archive();
738 let cid = archive
739 .put(b"original".to_vec(), HashMap::new(), 0)
740 .unwrap();
741
742 let offset = archive.index.entries[&cid].offset as usize;
744 archive.data[offset] ^= 0xFF;
745
746 let corrupt = archive.verify_integrity();
747 assert_eq!(corrupt.len(), 1);
748 assert_eq!(corrupt[0], cid);
749 }
750
751 #[test]
752 fn test_verify_integrity_skips_tombstones() {
753 let mut archive = empty_archive();
754 let cid = archive.put(b"deleted".to_vec(), HashMap::new(), 0).unwrap();
755 let offset = archive.index.entries[&cid].offset as usize;
757 archive.data[offset] ^= 0xFF;
758 archive
760 .index
761 .entries
762 .get_mut(&cid)
763 .unwrap()
764 .metadata
765 .insert("_tombstone".to_owned(), "true".to_owned());
766 assert!(archive.verify_integrity().is_empty());
767 }
768
769 #[test]
772 fn test_export_entries_count() {
773 let mut archive = empty_archive();
774 archive.put(b"a".to_vec(), HashMap::new(), 0).unwrap();
775 archive.put(b"b".to_vec(), HashMap::new(), 1).unwrap();
776 let cid = archive.put(b"c".to_vec(), HashMap::new(), 2).unwrap();
777 archive.remove(&cid).unwrap();
778 let blocks = archive.export_entries();
779 assert_eq!(blocks.len(), 2);
780 }
781
782 #[test]
783 fn test_export_entries_data_correct() {
784 let mut archive = empty_archive();
785 let data = b"export me".to_vec();
786 let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
787 let blocks = archive.export_entries();
788 let block = blocks.iter().find(|b| b.entry.cid == cid).unwrap();
789 assert_eq!(block.data, data);
790 }
791
792 #[test]
795 fn test_merge_adds_missing_entries() {
796 let mut src = empty_archive();
797 src.put(b"src1".to_vec(), HashMap::new(), 0).unwrap();
798 src.put(b"src2".to_vec(), HashMap::new(), 1).unwrap();
799
800 let mut dst = empty_archive();
801 let added = dst.merge(&src, 100).unwrap();
802 assert_eq!(added, 2);
803 assert_eq!(dst.entry_count(), 2);
804 }
805
806 #[test]
807 fn test_merge_skips_existing_entries() {
808 let mut src = empty_archive();
809 let cid = src.put(b"shared".to_vec(), HashMap::new(), 0).unwrap();
810
811 let mut dst = empty_archive();
812 dst.put(b"shared".to_vec(), HashMap::new(), 1).unwrap();
813
814 let added = dst.merge(&src, 100).unwrap();
815 assert_eq!(added, 0);
816 assert_eq!(dst.entry_count(), 1);
818 assert_eq!(dst.get(&cid).unwrap(), b"shared");
819 }
820
821 #[test]
822 fn test_merge_skips_tombstoned_in_src() {
823 let mut src = empty_archive();
824 let cid = src.put(b"bye".to_vec(), HashMap::new(), 0).unwrap();
825 src.remove(&cid).unwrap();
826
827 let mut dst = empty_archive();
828 let added = dst.merge(&src, 100).unwrap();
829 assert_eq!(added, 0);
830 assert_eq!(dst.entry_count(), 0);
831 }
832
833 #[test]
834 fn test_merge_respects_capacity() {
835 let mut src = empty_archive();
836 src.put(b"item1".to_vec(), HashMap::new(), 0).unwrap();
837 src.put(b"item2".to_vec(), HashMap::new(), 1).unwrap();
838
839 let mut dst = ContentAddressedArchive::new(ArchiveConfig {
840 max_entries: 1,
841 ..ArchiveConfig::default()
842 });
843 dst.put(b"pre-existing".to_vec(), HashMap::new(), 0)
844 .unwrap();
845
846 let result = dst.merge(&src, 100);
847 assert_eq!(result, Err(ArchiveError::ArchiveFull));
848 }
849
850 #[test]
853 fn test_stats_empty() {
854 let archive = empty_archive();
855 let s = archive.stats();
856 assert_eq!(s.live_entries, 0);
857 assert_eq!(s.tombstoned_entries, 0);
858 assert_eq!(s.total_bytes, 0);
859 assert_eq!(s.avg_entry_size_bytes, 0.0);
860 assert_eq!(s.utilization_fraction, 0.0);
861 }
862
863 #[test]
864 fn test_stats_live_and_tombstoned() {
865 let mut archive = empty_archive();
866 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();
869
870 let s = archive.stats();
871 assert_eq!(s.live_entries, 1);
872 assert_eq!(s.tombstoned_entries, 1);
873 assert_eq!(s.total_bytes, 5);
874 assert_eq!(s.avg_entry_size_bytes, 5.0);
875 }
876
877 #[test]
878 fn test_stats_utilization() {
879 let mut archive = ContentAddressedArchive::new(ArchiveConfig {
880 max_size_bytes: 100,
881 ..ArchiveConfig::default()
882 });
883 archive
884 .put(b"1234567890".to_vec(), HashMap::new(), 0)
885 .unwrap(); let s = archive.stats();
887 assert!((s.utilization_fraction - 0.1_f64).abs() < f64::EPSILON);
888 }
889
890 #[test]
893 fn test_get_entry_fields() {
894 let mut archive = empty_archive();
895 let m = meta(&[("owner", "alice")]);
896 let cid = archive.put(b"payload".to_vec(), m, 999).unwrap();
897 let entry = archive.get_entry(&cid).unwrap();
898 assert_eq!(entry.cid, cid);
899 assert_eq!(entry.length, 7);
900 assert!(!entry.compressed);
901 assert_eq!(entry.inserted_at, 999);
902 assert_eq!(entry.metadata.get("owner"), Some(&"alice".to_owned()));
903 }
904
905 #[test]
906 fn test_get_entry_not_found() {
907 let archive = empty_archive();
908 assert!(matches!(
909 archive.get_entry("0000000000000000"),
910 Err(ArchiveError::CidNotFound(_))
911 ));
912 }
913
914 #[test]
915 fn test_get_entry_tombstoned() {
916 let mut archive = empty_archive();
917 let cid = archive.put(b"gone".to_vec(), HashMap::new(), 0).unwrap();
918 archive.remove(&cid).unwrap();
919 assert!(matches!(
920 archive.get_entry(&cid),
921 Err(ArchiveError::TombstonedEntry(_))
922 ));
923 }
924
925 #[test]
928 fn test_offsets_are_contiguous() {
929 let mut archive = empty_archive();
930 let blobs: &[&[u8]] = &[b"one", b"two", b"three"];
931 let mut cids = Vec::new();
932 for blob in blobs {
933 cids.push(archive.put(blob.to_vec(), HashMap::new(), 0).unwrap());
934 }
935 let mut expected_offset = 0u64;
936 for (blob, cid) in blobs.iter().zip(&cids) {
937 let entry = archive.index.entries.get(cid).unwrap();
938 assert_eq!(entry.offset, expected_offset);
939 assert_eq!(entry.length, blob.len() as u64);
940 expected_offset += blob.len() as u64;
941 }
942 }
943
944 #[test]
947 fn test_put_empty_blob() {
948 let mut archive = empty_archive();
949 let cid = archive.put(vec![], HashMap::new(), 0).unwrap();
950 assert_eq!(archive.get(&cid).unwrap(), b"");
951 assert_eq!(archive.entry_count(), 1);
952 assert_eq!(archive.total_data_bytes(), 0);
953 }
954
955 #[test]
958 fn test_large_blob_round_trip() {
959 let mut archive = empty_archive();
960 let data: Vec<u8> = (0..65_536).map(|i| (i % 256) as u8).collect();
961 let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
962 assert_eq!(archive.get(&cid).unwrap(), data.as_slice());
963 }
964
965 #[test]
968 fn test_reinsert_after_tombstone() {
969 let mut archive = empty_archive();
970 let data = b"reborn".to_vec();
971 let cid = archive.put(data.clone(), HashMap::new(), 0).unwrap();
972 archive.remove(&cid).unwrap();
973 let cid2 = archive.put(data.clone(), meta(&[("v", "2")]), 1).unwrap();
975 assert_eq!(cid, cid2);
976 assert!(archive.contains(&cid));
977 assert_eq!(archive.get(&cid).unwrap(), data.as_slice());
978 }
979}