Skip to main content

ipfrs_storage/
content_addressed_archive.rs

1//! Content-Addressed Archive — an append-only, in-memory store for immutable data blobs.
2//!
3//! Each blob is keyed by its FNV-1a-64 content identifier (CID).  The flat byte array
4//! (`data`) grows monotonically; removed entries are tombstoned rather than reclaimed,
5//! preserving the append-only invariant while still allowing logical deletion.
6
7use std::collections::{BTreeMap, HashMap};
8
9/// Compute a 64-bit FNV-1a content identifier, returned as a 16-char hex string.
10pub 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// ── Error ─────────────────────────────────────────────────────────────────────
20
21/// Errors that can occur during archive operations.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum ArchiveError {
24    /// A blob with this CID already exists and overwrites are disabled.
25    #[error("duplicate CID: {0}")]
26    DuplicateCid(String),
27
28    /// No live entry with this CID exists.
29    #[error("CID not found: {0}")]
30    CidNotFound(String),
31
32    /// The archive has reached its configured capacity limit.
33    #[error("archive is full")]
34    ArchiveFull,
35
36    /// On-disk (or in-memory) data does not match the recorded CID.
37    #[error("corrupted entry: {0}")]
38    CorruptedEntry(String),
39
40    /// The entry exists but has been tombstoned (logically removed).
41    #[error("tombstoned entry: {0}")]
42    TombstonedEntry(String),
43}
44
45// ── Core data types ───────────────────────────────────────────────────────────
46
47/// Metadata record for a single archived blob.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ArchiveEntry {
50    /// FNV-1a-64 content identifier (16 hex chars).
51    pub cid: String,
52    /// Byte offset into the flat `data` array.
53    pub offset: u64,
54    /// Byte length of the blob.
55    pub length: u64,
56    /// Whether the blob is stored compressed (always `false` in the current implementation).
57    pub compressed: bool,
58    /// User-supplied and internal key/value metadata.
59    /// The reserved key `"_tombstone"` is set to `"true"` for removed entries.
60    pub metadata: HashMap<String, String>,
61    /// Unix timestamp (seconds or millis — caller-supplied) at insertion time.
62    pub inserted_at: u64,
63}
64
65impl ArchiveEntry {
66    /// Returns `true` if this entry has been logically removed.
67    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/// A blob together with its archive metadata, used when exporting entries.
76#[derive(Debug, Clone)]
77pub struct ArchiveBlock {
78    /// Raw blob bytes.
79    pub data: Vec<u8>,
80    /// Metadata record.
81    pub entry: ArchiveEntry,
82}
83
84/// The ordered index of all entries (live + tombstoned).
85#[derive(Debug, Clone, Default)]
86pub struct ArchiveIndex {
87    /// All entries, keyed and sorted by CID for deterministic iteration.
88    pub entries: BTreeMap<String, ArchiveEntry>,
89    /// Offset at which the next blob will be written into the flat array.
90    pub next_offset: u64,
91}
92
93// ── Configuration ─────────────────────────────────────────────────────────────
94
95/// Capacity and behaviour configuration for [`ContentAddressedArchive`].
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ArchiveConfig {
98    /// Maximum total live bytes before the archive is considered full.
99    pub max_size_bytes: u64,
100    /// Maximum number of live entries before the archive is considered full.
101    pub max_entries: usize,
102    /// When `true`, inserting a CID that already exists replaces the old entry;
103    /// when `false` (the default), it returns [`ArchiveError::DuplicateCid`].
104    pub allow_overwrites: bool,
105}
106
107impl Default for ArchiveConfig {
108    fn default() -> Self {
109        Self {
110            max_size_bytes: 1_073_741_824, // 1 GiB
111            max_entries: 1_000_000,
112            allow_overwrites: false,
113        }
114    }
115}
116
117// ── Statistics ────────────────────────────────────────────────────────────────
118
119/// Aggregate statistics about the archive.
120#[derive(Debug, Clone)]
121pub struct ArchiveStats {
122    /// Number of live (non-tombstoned) entries.
123    pub live_entries: usize,
124    /// Number of tombstoned entries.
125    pub tombstoned_entries: usize,
126    /// Total live bytes stored.
127    pub total_bytes: u64,
128    /// Mean size of live entries in bytes; `0.0` if no live entries.
129    pub avg_entry_size_bytes: f64,
130    /// `total_bytes / max_size_bytes` — fraction of capacity used.
131    pub utilization_fraction: f64,
132}
133
134// ── Archive ───────────────────────────────────────────────────────────────────
135
136/// An append-only, content-addressed archive for storing immutable data blobs.
137///
138/// Data is kept in a flat in-memory byte array (`data`).  Each entry records
139/// the offset and length of its blob so that slices can be returned without
140/// copying.  Removed entries are tombstoned in their metadata; their bytes
141/// remain in the flat array but are excluded from all live-entry computations.
142#[derive(Debug, Clone)]
143pub struct ContentAddressedArchive {
144    /// Archive capacity and behaviour settings.
145    pub config: ArchiveConfig,
146    /// CID → entry index.
147    pub index: ArchiveIndex,
148    /// Flat byte array holding all blob data (including tombstoned blobs).
149    pub data: Vec<u8>,
150}
151
152impl ContentAddressedArchive {
153    // ── Construction ──────────────────────────────────────────────────────────
154
155    /// Create a new, empty archive with the given configuration.
156    pub fn new(config: ArchiveConfig) -> Self {
157        Self {
158            config,
159            index: ArchiveIndex::default(),
160            data: Vec::new(),
161        }
162    }
163
164    // ── Capacity checks ───────────────────────────────────────────────────────
165
166    /// Returns `true` when the archive has reached either of its capacity limits.
167    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    // ── Core mutation ─────────────────────────────────────────────────────────
173
174    /// Insert a blob into the archive.
175    ///
176    /// Computes the CID from `data`, checks for duplicates, enforces capacity
177    /// limits, appends the bytes to the flat array, and records the entry.
178    ///
179    /// Returns the computed CID on success.
180    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        // Duplicate check
189        if let Some(existing) = self.index.entries.get(&cid) {
190            if existing.is_tombstoned() {
191                // A previously tombstoned entry: treat as a fresh insert
192                // (the old bytes remain in the flat array; we append new ones).
193            } else if !self.config.allow_overwrites {
194                return Err(ArchiveError::DuplicateCid(cid));
195            }
196            // allow_overwrites == true → fall through and replace the index entry.
197        }
198
199        // Capacity guard (only for genuinely new live entries)
200        if self.is_full() {
201            // Check whether this is just an overwrite of an existing live entry —
202            // in that case we don't increase the live count/size, so we can proceed.
203            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    /// Retrieve the raw bytes of the blob identified by `cid`.
234    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    /// Retrieve the metadata entry for the blob identified by `cid`.
249    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    /// Returns `true` if a **live** entry with `cid` exists.
260    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    /// Logically remove the entry for `cid`, returning the original blob bytes.
269    ///
270    /// The entry is kept in the index with `"_tombstone" = "true"` in its
271    /// metadata; the raw bytes remain in the flat `data` array.
272    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    // ── Queries ───────────────────────────────────────────────────────────────
291
292    /// Return the sorted list of all live CID strings (alphabetical order).
293    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    /// Return live entries sorted by CID.
303    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    /// Total bytes occupied by live entries.
312    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    /// Number of live entries.
322    pub fn entry_count(&self) -> usize {
323        self.index
324            .entries
325            .values()
326            .filter(|e| !e.is_tombstoned())
327            .count()
328    }
329
330    // ── Integrity ─────────────────────────────────────────────────────────────
331
332    /// Verify every live entry by recomputing its CID from stored bytes.
333    ///
334    /// Returns the CIDs of entries where the recomputed CID differs from the
335    /// recorded CID (indicating data corruption).
336    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    // ── Export / Merge ────────────────────────────────────────────────────────
353
354    /// Export all live entries as [`ArchiveBlock`] values.
355    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    /// Import all live entries from `other` that are not already present in
372    /// `self`.  Returns the number of entries added.
373    ///
374    /// Entries that already exist (even as tombstones) in `self` are skipped.
375    pub fn merge(
376        &mut self,
377        other: &ContentAddressedArchive,
378        now: u64,
379    ) -> Result<usize, ArchiveError> {
380        let mut added = 0usize;
381
382        // Collect blobs first to avoid borrow conflicts.
383        let blocks: Vec<ArchiveBlock> = other.export_entries();
384
385        for block in blocks {
386            // Skip CIDs already present (live or tombstoned) in self.
387            if self.index.entries.contains_key(&block.entry.cid) {
388                continue;
389            }
390            // Respect capacity limits.
391            if self.is_full() {
392                return Err(ArchiveError::ArchiveFull);
393            }
394            // Strip the source timestamp and use the caller-supplied `now`.
395            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    // ── Statistics ────────────────────────────────────────────────────────────
405
406    /// Compute aggregate statistics for the archive.
407    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// ═════════════════════════════════════════════════════════════════════════════
437// Tests
438// ═════════════════════════════════════════════════════════════════════════════
439
440#[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    // ── compute_cid ───────────────────────────────────────────────────────────
460
461    #[test]
462    fn test_compute_cid_empty() {
463        // FNV-1a of empty slice = FNV offset basis, formatted as hex.
464        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        // Different data must produce a different CID.
474        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    // ── Basic put / get ───────────────────────────────────────────────────────
496
497    #[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    // ── Duplicate handling ────────────────────────────────────────────────────
544
545    #[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        // Latest metadata should win.
565        assert_eq!(
566            archive.get_entry(&cid1).unwrap().metadata.get("v"),
567            Some(&"2".to_owned())
568        );
569    }
570
571    // ── contains ─────────────────────────────────────────────────────────────
572
573    #[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    // ── remove / tombstone ────────────────────────────────────────────────────
587
588    #[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    // ── list_cids / list_entries ──────────────────────────────────────────────
630
631    #[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    // ── Counters ──────────────────────────────────────────────────────────────
664
665    #[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(); // 5
681        archive.put(b"abcde".to_vec(), HashMap::new(), 1).unwrap(); // 5
682        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    // ── is_full ───────────────────────────────────────────────────────────────
695
696    #[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(); // 11 bytes
720        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    // ── verify_integrity ─────────────────────────────────────────────────────
726
727    #[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        // Corrupt the flat data array in-place.
743        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        // Corrupt the bytes first.
756        let offset = archive.index.entries[&cid].offset as usize;
757        archive.data[offset] ^= 0xFF;
758        // Now tombstone — corruption of tombstoned entries is ignored.
759        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    // ── export_entries ────────────────────────────────────────────────────────
770
771    #[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    // ── merge ─────────────────────────────────────────────────────────────────
793
794    #[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        // Only one entry: the original.
817        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    // ── stats ─────────────────────────────────────────────────────────────────
851
852    #[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(); // 5 bytes
867        let cid = archive.put(b"world".to_vec(), HashMap::new(), 1).unwrap(); // 5 bytes
868        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(); // 10 bytes
886        let s = archive.stats();
887        assert!((s.utilization_fraction - 0.1_f64).abs() < f64::EPSILON);
888    }
889
890    // ── get_entry ─────────────────────────────────────────────────────────────
891
892    #[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    // ── Offset correctness after multiple puts ────────────────────────────────
926
927    #[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    // ── Empty data blob ───────────────────────────────────────────────────────
945
946    #[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    // ── Large blob round-trip ─────────────────────────────────────────────────
956
957    #[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    // ── Tombstone re-insert ───────────────────────────────────────────────────
966
967    #[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        // Re-inserting the same data should succeed (tombstoned entry treated as new).
974        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}