Skip to main content

ipfrs_core/
manifest.rs

1//! Content manifests for tracking multi-file add operations
2//!
3//! This module provides:
4//! - [`ManifestEntry`] - metadata for a single content-addressed block in a manifest
5//! - [`ContentManifest`] - tracks all blocks for a multi-file operation with Merkle integrity
6//! - [`MerkleTree`] - binary Merkle tree over CID strings using FNV-1a hashing
7//! - [`ManifestDiff`] - diff two manifests to find added/removed entries
8//!
9//! ## Design
10//!
11//! `manifest_id` and `root_cid` are both derived deterministically from the entry CIDs,
12//! so the same set of entries always produces the same manifest identity and root.
13//!
14//! FNV-1a (64-bit) is used throughout as a fast, dependency-free hash primitive.
15
16use serde::{Deserialize, Serialize};
17use std::collections::{BTreeMap, BTreeSet};
18
19// ---------------------------------------------------------------------------
20// FNV-1a helpers (no external dependency)
21// ---------------------------------------------------------------------------
22
23const FNV_OFFSET_BASIS_64: u64 = 14_695_981_039_346_656_037;
24const FNV_PRIME_64: u64 = 1_099_511_628_211;
25
26/// Compute a 64-bit FNV-1a hash of a byte slice.
27fn fnv1a_64(data: &[u8]) -> u64 {
28    let mut hash = FNV_OFFSET_BASIS_64;
29    for &byte in data {
30        hash ^= u64::from(byte);
31        hash = hash.wrapping_mul(FNV_PRIME_64);
32    }
33    hash
34}
35
36/// Format a u64 FNV-1a hash as a 16-character lowercase hex string.
37fn fnv1a_hex(data: &[u8]) -> String {
38    format!("{:016x}", fnv1a_64(data))
39}
40
41/// Combine two hex-encoded hash strings into one FNV-1a hash.
42///
43/// This is the *node hash* function used by [`MerkleTree`]:
44/// `hash(a, b) = FNV-1a(bytes(a) ++ bytes(b))`.
45fn combine_hashes(left: &str, right: &str) -> String {
46    let mut combined = Vec::with_capacity(left.len() + right.len());
47    combined.extend_from_slice(left.as_bytes());
48    combined.extend_from_slice(right.as_bytes());
49    fnv1a_hex(&combined)
50}
51
52// ---------------------------------------------------------------------------
53// ManifestEntry
54// ---------------------------------------------------------------------------
55
56/// A single entry inside a [`ContentManifest`].
57///
58/// An entry corresponds to one content-addressed block within a (possibly
59/// chunked) file that was added as part of a multi-file operation.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ManifestEntry {
62    /// Relative path of the file within the manifest (e.g. `"images/photo.jpg"`).
63    pub path: String,
64
65    /// Content Identifier for this block.
66    pub cid: String,
67
68    /// Size of this block's raw data in bytes.
69    pub size_bytes: u64,
70
71    /// Zero-based index of this chunk within its file.
72    /// Always `0` for single-chunk files.
73    pub chunk_index: u32,
74
75    /// `true` when this entry is the last chunk of the file at `path`.
76    pub is_final_chunk: bool,
77}
78
79impl ManifestEntry {
80    /// Create a new manifest entry.
81    pub fn new(
82        path: impl Into<String>,
83        cid: impl Into<String>,
84        size_bytes: u64,
85        chunk_index: u32,
86        is_final_chunk: bool,
87    ) -> Self {
88        Self {
89            path: path.into(),
90            cid: cid.into(),
91            size_bytes,
92            chunk_index,
93            is_final_chunk,
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// MerkleTree
100// ---------------------------------------------------------------------------
101
102/// Binary Merkle tree whose leaves are CID strings.
103///
104/// ### Hash function
105///
106/// Leaf hashes are `FNV-1a(cid.as_bytes())`.
107/// Internal nodes use `FNV-1a(left_hex ++ right_hex)`.
108/// When a level has an odd number of nodes the last node is *duplicated*
109/// (standard Bitcoin/IPFS convention).
110///
111/// All hash values are stored and returned as 16-character lowercase hex strings.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct MerkleTree {
114    /// Original CID strings used as leaves (in the order they were supplied).
115    pub leaves: Vec<String>,
116
117    /// All tree nodes stored level-by-level, leaves first.
118    ///
119    /// Layout: `[ leaf_hashes … | level-1 hashes … | … | root ]`
120    pub nodes: Vec<String>,
121}
122
123impl MerkleTree {
124    /// Build a Merkle tree from a slice of CID strings.
125    ///
126    /// Returns an empty tree (no nodes, no root) when `cids` is empty.
127    pub fn build(cids: &[String]) -> Self {
128        if cids.is_empty() {
129            return Self {
130                leaves: Vec::new(),
131                nodes: Vec::new(),
132            };
133        }
134
135        // Hash each leaf CID.
136        let leaf_hashes: Vec<String> = cids.iter().map(|c| fnv1a_hex(c.as_bytes())).collect();
137
138        // We accumulate all levels into `nodes` (leaves first).
139        let mut all_nodes: Vec<String> = leaf_hashes.clone();
140        let mut current_level = leaf_hashes;
141
142        while current_level.len() > 1 {
143            let mut next_level: Vec<String> = Vec::new();
144
145            let mut i = 0;
146            while i < current_level.len() {
147                let left = &current_level[i];
148                // Duplicate the last node if the level has an odd count.
149                let right = if i + 1 < current_level.len() {
150                    &current_level[i + 1]
151                } else {
152                    &current_level[i]
153                };
154                next_level.push(combine_hashes(left, right));
155                i += 2;
156            }
157
158            all_nodes.extend(next_level.iter().cloned());
159            current_level = next_level;
160        }
161
162        Self {
163            leaves: cids.to_vec(),
164            nodes: all_nodes,
165        }
166    }
167
168    /// Return the Merkle root as a hex string, or `None` for an empty tree.
169    pub fn root(&self) -> Option<&str> {
170        self.nodes.last().map(|s| s.as_str())
171    }
172
173    /// Return the Merkle proof for the leaf at `index`.
174    ///
175    /// The proof is a list of sibling hashes from leaf level up to (but not
176    /// including) the root.  Returns `None` if `index` is out of range or
177    /// the tree is empty.
178    pub fn proof_for(&self, index: usize) -> Option<Vec<String>> {
179        let n = self.leaves.len();
180        if n == 0 || index >= n {
181            return None;
182        }
183
184        let mut proof = Vec::new();
185        let mut current_index = index;
186        let mut level_size = n;
187        let mut level_start = 0usize;
188
189        while level_size > 1 {
190            // Determine sibling index.
191            let sibling_index = if current_index.is_multiple_of(2) {
192                // We are a left child; sibling is to the right (or self if last).
193                (current_index + 1).min(level_size - 1)
194            } else {
195                // We are a right child; sibling is to the left.
196                current_index - 1
197            };
198
199            proof.push(self.nodes[level_start + sibling_index].clone());
200
201            level_start += level_size;
202            level_size = level_size.div_ceil(2);
203            current_index /= 2;
204        }
205
206        Some(proof)
207    }
208
209    /// Verify a Merkle proof.
210    ///
211    /// - `leaf` — the raw CID string whose membership is being verified
212    /// - `index` — zero-based position of the leaf in the original list
213    /// - `proof` — sibling hashes returned by [`MerkleTree::proof_for`]
214    /// - `root` — expected Merkle root hex string
215    ///
216    /// Returns `true` iff the recomputed root matches `root`.
217    pub fn verify_proof(leaf: &str, index: usize, proof: &[String], root: &str) -> bool {
218        let mut current_hash = fnv1a_hex(leaf.as_bytes());
219        let mut current_index = index;
220
221        for sibling in proof {
222            current_hash = if current_index.is_multiple_of(2) {
223                combine_hashes(&current_hash, sibling)
224            } else {
225                combine_hashes(sibling, &current_hash)
226            };
227            current_index /= 2;
228        }
229
230        current_hash == root
231    }
232}
233
234// ---------------------------------------------------------------------------
235// ContentManifest
236// ---------------------------------------------------------------------------
237
238/// Tracks all blocks belonging to a multi-file add operation.
239///
240/// The manifest provides:
241/// - Ordered entry lookup by path and chunk index
242/// - Completeness checks for individual paths
243/// - A Merkle root for efficient integrity verification and partial retrieval
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ContentManifest {
246    /// Unique manifest identifier derived as the FNV-1a hash of all entry CIDs
247    /// sorted lexicographically and concatenated.
248    pub manifest_id: String,
249
250    /// All entries, sorted by `(path, chunk_index)`.
251    pub entries: Vec<ManifestEntry>,
252
253    /// Hex string of the Merkle root over all entry CIDs (sorted by path then
254    /// chunk_index), or an empty string when there are no entries.
255    pub root_cid: String,
256
257    /// Sum of `size_bytes` across all entries.
258    pub total_size_bytes: u64,
259
260    /// Unix timestamp in milliseconds at manifest creation time.
261    pub created_at_ms: u64,
262}
263
264impl ContentManifest {
265    /// Create a new manifest from a list of entries.
266    ///
267    /// Entries are sorted by `(path, chunk_index)` in place.
268    /// `manifest_id`, `root_cid`, and `total_size_bytes` are computed
269    /// deterministically from the supplied entries.
270    pub fn new(mut entries: Vec<ManifestEntry>) -> Self {
271        // Canonical sort: path first, then chunk_index.
272        entries.sort_by(|a, b| {
273            a.path
274                .cmp(&b.path)
275                .then_with(|| a.chunk_index.cmp(&b.chunk_index))
276        });
277
278        let total_size_bytes: u64 = entries.iter().map(|e| e.size_bytes).sum();
279
280        // manifest_id = FNV-1a of all CIDs sorted lexicographically.
281        let mut sorted_cids: Vec<&str> = entries.iter().map(|e| e.cid.as_str()).collect();
282        sorted_cids.sort_unstable();
283        let id_input: String = sorted_cids.join("");
284        let manifest_id = fnv1a_hex(id_input.as_bytes());
285
286        // root_cid from Merkle tree over entry CIDs in canonical (path, chunk_index) order.
287        let ordered_cids: Vec<String> = entries.iter().map(|e| e.cid.clone()).collect();
288        let tree = MerkleTree::build(&ordered_cids);
289        let root_cid = tree.root().unwrap_or("").to_string();
290
291        // Timestamp using wasm-compatible platform time.
292        let created_at_ms = created_at_ms_now();
293
294        Self {
295            manifest_id,
296            entries,
297            root_cid,
298            total_size_bytes,
299            created_at_ms,
300        }
301    }
302
303    /// Return all entries whose `path` equals `path`, in chunk order.
304    pub fn get_entries_for_path<'a>(&'a self, path: &str) -> Vec<&'a ManifestEntry> {
305        self.entries.iter().filter(|e| e.path == path).collect()
306    }
307
308    /// Return the total number of entries (chunks) for a given path.
309    pub fn total_chunks_for_path(&self, path: &str) -> usize {
310        self.entries.iter().filter(|e| e.path == path).count()
311    }
312
313    /// Return `true` if the path is fully represented in this manifest.
314    ///
315    /// A path is *complete* when:
316    /// 1. There is at least one entry for the path.
317    /// 2. The entries with the highest `chunk_index` has `is_final_chunk = true`.
318    /// 3. All chunk indices from `0` to `max_chunk_index` are present (no gaps).
319    pub fn is_complete_for_path(&self, path: &str) -> bool {
320        let path_entries: Vec<&ManifestEntry> =
321            self.entries.iter().filter(|e| e.path == path).collect();
322
323        if path_entries.is_empty() {
324            return false;
325        }
326
327        // There must be exactly one entry marked as final.
328        let final_entries: Vec<&&ManifestEntry> =
329            path_entries.iter().filter(|e| e.is_final_chunk).collect();
330
331        if final_entries.len() != 1 {
332            return false;
333        }
334
335        let max_index = final_entries[0].chunk_index;
336        let expected_count = (max_index as usize) + 1;
337
338        if path_entries.len() != expected_count {
339            return false;
340        }
341
342        // Verify no gaps: collect all indices and check 0..=max_index are present.
343        let indices: BTreeSet<u32> = path_entries.iter().map(|e| e.chunk_index).collect();
344        for idx in 0..=max_index {
345            if !indices.contains(&idx) {
346                return false;
347            }
348        }
349
350        true
351    }
352
353    /// Return the total number of entries in this manifest.
354    pub fn entry_count(&self) -> usize {
355        self.entries.len()
356    }
357
358    /// Return a sorted, deduplicated list of all paths in this manifest.
359    pub fn paths(&self) -> Vec<String> {
360        let mut seen: BTreeSet<&str> = BTreeSet::new();
361        for entry in &self.entries {
362            seen.insert(entry.path.as_str());
363        }
364        seen.into_iter().map(|s| s.to_string()).collect()
365    }
366}
367
368// ---------------------------------------------------------------------------
369// ManifestDiff
370// ---------------------------------------------------------------------------
371
372/// The result of comparing two [`ContentManifest`] instances.
373///
374/// Comparison is done by CID: an entry present in `new` but absent in `old`
375/// (by CID) is *added*; an entry present in `old` but absent in `new` is
376/// *removed*.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct ManifestDiff {
379    /// Entries that exist in the new manifest but not the old one.
380    pub added: Vec<ManifestEntry>,
381
382    /// Entries that exist in the old manifest but not the new one.
383    pub removed: Vec<ManifestEntry>,
384}
385
386impl ManifestDiff {
387    /// Compute the diff between `old` and `new` manifests.
388    pub fn diff(old: &ContentManifest, new: &ContentManifest) -> Self {
389        let old_cids: BTreeMap<&str, &ManifestEntry> =
390            old.entries.iter().map(|e| (e.cid.as_str(), e)).collect();
391        let new_cids: BTreeMap<&str, &ManifestEntry> =
392            new.entries.iter().map(|e| (e.cid.as_str(), e)).collect();
393
394        let added: Vec<ManifestEntry> = new_cids
395            .iter()
396            .filter(|(cid, _)| !old_cids.contains_key(*cid))
397            .map(|(_, e)| (*e).clone())
398            .collect();
399
400        let removed: Vec<ManifestEntry> = old_cids
401            .iter()
402            .filter(|(cid, _)| !new_cids.contains_key(*cid))
403            .map(|(_, e)| (*e).clone())
404            .collect();
405
406        Self { added, removed }
407    }
408
409    /// Return `true` when there are no differences.
410    pub fn is_empty(&self) -> bool {
411        self.added.is_empty() && self.removed.is_empty()
412    }
413}
414
415// ---------------------------------------------------------------------------
416// Internal helpers
417// ---------------------------------------------------------------------------
418
419/// Return the current wall-clock time in milliseconds since Unix epoch.
420///
421/// On wasm32 targets (where `std::time::SystemTime` is unavailable) this
422/// falls back to 0.  Tests that need a stable value should override
423/// `created_at_ms` directly after construction.
424fn created_at_ms_now() -> u64 {
425    #[cfg(not(target_arch = "wasm32"))]
426    {
427        use std::time::{SystemTime, UNIX_EPOCH};
428        SystemTime::now()
429            .duration_since(UNIX_EPOCH)
430            .map(|d| d.as_millis() as u64)
431            .unwrap_or(0)
432    }
433    #[cfg(target_arch = "wasm32")]
434    {
435        0
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------------------
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    // -----------------------------------------------------------------------
448    // Helpers
449    // -----------------------------------------------------------------------
450
451    fn entry(path: &str, cid: &str, size: u64, idx: u32, final_chunk: bool) -> ManifestEntry {
452        ManifestEntry::new(path, cid, size, idx, final_chunk)
453    }
454
455    fn sample_entries() -> Vec<ManifestEntry> {
456        vec![
457            entry("a/b.txt", "cid-ab-0", 100, 0, false),
458            entry("a/b.txt", "cid-ab-1", 200, 1, true),
459            entry("c/d.bin", "cid-cd-0", 50, 0, true),
460        ]
461    }
462
463    // -----------------------------------------------------------------------
464    // ContentManifest::new
465    // -----------------------------------------------------------------------
466
467    #[test]
468    fn test_manifest_new_total_size() {
469        let m = ContentManifest::new(sample_entries());
470        assert_eq!(m.total_size_bytes, 350);
471    }
472
473    #[test]
474    fn test_manifest_new_entry_count() {
475        let m = ContentManifest::new(sample_entries());
476        assert_eq!(m.entry_count(), 3);
477    }
478
479    #[test]
480    fn test_manifest_new_sorted_entries() {
481        // Supply entries in reverse order; they must come out sorted.
482        let entries = vec![
483            entry("z/last.txt", "cid-z", 10, 0, true),
484            entry("a/first.txt", "cid-a", 20, 0, true),
485        ];
486        let m = ContentManifest::new(entries);
487        assert_eq!(m.entries[0].path, "a/first.txt");
488        assert_eq!(m.entries[1].path, "z/last.txt");
489    }
490
491    // -----------------------------------------------------------------------
492    // get_entries_for_path
493    // -----------------------------------------------------------------------
494
495    #[test]
496    fn test_get_entries_for_path_found() {
497        let m = ContentManifest::new(sample_entries());
498        let entries = m.get_entries_for_path("a/b.txt");
499        assert_eq!(entries.len(), 2);
500        assert_eq!(entries[0].chunk_index, 0);
501        assert_eq!(entries[1].chunk_index, 1);
502    }
503
504    #[test]
505    fn test_get_entries_for_path_not_found() {
506        let m = ContentManifest::new(sample_entries());
507        let entries = m.get_entries_for_path("nonexistent.txt");
508        assert!(entries.is_empty());
509    }
510
511    // -----------------------------------------------------------------------
512    // paths()
513    // -----------------------------------------------------------------------
514
515    #[test]
516    fn test_paths_unique_sorted() {
517        let m = ContentManifest::new(sample_entries());
518        let paths = m.paths();
519        assert_eq!(paths, vec!["a/b.txt", "c/d.bin"]);
520    }
521
522    #[test]
523    fn test_paths_empty_manifest() {
524        let m = ContentManifest::new(vec![]);
525        assert!(m.paths().is_empty());
526    }
527
528    // -----------------------------------------------------------------------
529    // is_complete_for_path
530    // -----------------------------------------------------------------------
531
532    #[test]
533    fn test_is_complete_for_path_single_chunk() {
534        let m = ContentManifest::new(sample_entries());
535        assert!(m.is_complete_for_path("c/d.bin"));
536    }
537
538    #[test]
539    fn test_is_complete_for_path_multi_chunk_complete() {
540        let m = ContentManifest::new(sample_entries());
541        assert!(m.is_complete_for_path("a/b.txt"));
542    }
543
544    #[test]
545    fn test_is_complete_for_path_missing_final() {
546        // Two chunks, neither is marked final.
547        let entries = vec![
548            entry("f.txt", "cid-f-0", 10, 0, false),
549            entry("f.txt", "cid-f-1", 10, 1, false),
550        ];
551        let m = ContentManifest::new(entries);
552        assert!(!m.is_complete_for_path("f.txt"));
553    }
554
555    #[test]
556    fn test_is_complete_for_path_gap() {
557        // Chunk 0 and chunk 2 present, chunk 1 missing.
558        let entries = vec![
559            entry("g.bin", "cid-g-0", 10, 0, false),
560            entry("g.bin", "cid-g-2", 10, 2, true),
561        ];
562        let m = ContentManifest::new(entries);
563        assert!(!m.is_complete_for_path("g.bin"));
564    }
565
566    #[test]
567    fn test_is_complete_for_path_nonexistent() {
568        let m = ContentManifest::new(sample_entries());
569        assert!(!m.is_complete_for_path("does_not_exist.txt"));
570    }
571
572    // -----------------------------------------------------------------------
573    // manifest_id determinism
574    // -----------------------------------------------------------------------
575
576    #[test]
577    fn test_manifest_id_deterministic() {
578        let m1 = ContentManifest::new(sample_entries());
579        let m2 = ContentManifest::new(sample_entries());
580        assert_eq!(m1.manifest_id, m2.manifest_id);
581    }
582
583    #[test]
584    fn test_manifest_id_order_independent() {
585        // manifest_id is based on *sorted* CIDs, so entry order must not matter.
586        let e1 = vec![
587            entry("a.txt", "cid-X", 10, 0, true),
588            entry("b.txt", "cid-Y", 20, 0, true),
589        ];
590        let e2 = vec![
591            entry("b.txt", "cid-Y", 20, 0, true),
592            entry("a.txt", "cid-X", 10, 0, true),
593        ];
594        let m1 = ContentManifest::new(e1);
595        let m2 = ContentManifest::new(e2);
596        assert_eq!(m1.manifest_id, m2.manifest_id);
597    }
598
599    // -----------------------------------------------------------------------
600    // MerkleTree::build
601    // -----------------------------------------------------------------------
602
603    #[test]
604    fn test_merkle_tree_single_leaf() {
605        let cids = vec!["cid-1".to_string()];
606        let tree = MerkleTree::build(&cids);
607        assert_eq!(tree.leaves.len(), 1);
608        // With one leaf the root IS the leaf hash.
609        let leaf_hash = fnv1a_hex(b"cid-1");
610        assert_eq!(tree.root(), Some(leaf_hash.as_str()));
611    }
612
613    #[test]
614    fn test_merkle_tree_two_leaves() {
615        let cids = vec!["cid-A".to_string(), "cid-B".to_string()];
616        let tree = MerkleTree::build(&cids);
617        assert_eq!(tree.leaves.len(), 2);
618        // nodes = [hash(A), hash(B), hash(hash(A)+hash(B))]
619        assert_eq!(tree.nodes.len(), 3);
620        let root = tree.root().expect("root must exist");
621        assert_eq!(root.len(), 16); // 16-char hex
622    }
623
624    #[test]
625    fn test_merkle_tree_three_leaves() {
626        let cids: Vec<String> = ["c1", "c2", "c3"].iter().map(|s| s.to_string()).collect();
627        let tree = MerkleTree::build(&cids);
628        // Level 0: 3 leaves → Level 1: 2 nodes (c3 is duplicated) → Level 2: 1 root
629        // Total nodes = 3 + 2 + 1 = 6
630        assert_eq!(tree.nodes.len(), 6);
631        assert!(tree.root().is_some());
632    }
633
634    #[test]
635    fn test_merkle_tree_four_leaves() {
636        let cids: Vec<String> = ["c1", "c2", "c3", "c4"]
637            .iter()
638            .map(|s| s.to_string())
639            .collect();
640        let tree = MerkleTree::build(&cids);
641        // Level 0: 4 leaves → Level 1: 2 nodes → Level 2: 1 root
642        // Total = 4 + 2 + 1 = 7
643        assert_eq!(tree.nodes.len(), 7);
644    }
645
646    #[test]
647    fn test_merkle_tree_empty() {
648        let tree = MerkleTree::build(&[]);
649        assert!(tree.root().is_none());
650        assert!(tree.nodes.is_empty());
651    }
652
653    // -----------------------------------------------------------------------
654    // MerkleTree::root determinism
655    // -----------------------------------------------------------------------
656
657    #[test]
658    fn test_merkle_root_deterministic() {
659        let cids: Vec<String> = ["x", "y", "z"].iter().map(|s| s.to_string()).collect();
660        let t1 = MerkleTree::build(&cids);
661        let t2 = MerkleTree::build(&cids);
662        assert_eq!(t1.root(), t2.root());
663    }
664
665    // -----------------------------------------------------------------------
666    // proof_for and verify_proof
667    // -----------------------------------------------------------------------
668
669    #[test]
670    fn test_proof_for_correct_length_two_leaves() {
671        let cids = vec!["cid-L".to_string(), "cid-R".to_string()];
672        let tree = MerkleTree::build(&cids);
673        let proof = tree.proof_for(0).expect("proof must exist");
674        // A tree with 2 leaves has depth 1; proof length should be 1.
675        assert_eq!(proof.len(), 1);
676    }
677
678    #[test]
679    fn test_proof_for_correct_length_four_leaves() {
680        let cids: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
681        let tree = MerkleTree::build(&cids);
682        // Depth of a balanced 4-leaf tree is 2.
683        for idx in 0..4usize {
684            let proof = tree.proof_for(idx).expect("proof must exist");
685            assert_eq!(proof.len(), 2, "leaf {idx} proof length mismatch");
686        }
687    }
688
689    #[test]
690    fn test_verify_proof_valid() {
691        let cids: Vec<String> = ["alpha", "beta", "gamma", "delta"]
692            .iter()
693            .map(|s| s.to_string())
694            .collect();
695        let tree = MerkleTree::build(&cids);
696        let root = tree.root().expect("root").to_string();
697
698        for (idx, cid) in cids.iter().enumerate() {
699            let proof = tree.proof_for(idx).expect("proof");
700            assert!(
701                MerkleTree::verify_proof(cid, idx, &proof, &root),
702                "valid proof failed for index {idx}"
703            );
704        }
705    }
706
707    #[test]
708    fn test_verify_proof_tampered_leaf() {
709        let cids: Vec<String> = ["one", "two", "three", "four"]
710            .iter()
711            .map(|s| s.to_string())
712            .collect();
713        let tree = MerkleTree::build(&cids);
714        let root = tree.root().expect("root").to_string();
715        let proof = tree.proof_for(0).expect("proof");
716
717        // Use a different leaf than the one at index 0.
718        assert!(
719            !MerkleTree::verify_proof("tampered", 0, &proof, &root),
720            "tampered proof must fail"
721        );
722    }
723
724    #[test]
725    fn test_proof_for_out_of_range() {
726        let cids = vec!["only-one".to_string()];
727        let tree = MerkleTree::build(&cids);
728        assert!(tree.proof_for(5).is_none());
729    }
730
731    // -----------------------------------------------------------------------
732    // ManifestDiff
733    // -----------------------------------------------------------------------
734
735    #[test]
736    fn test_manifest_diff_added_removed() {
737        let old_entries = vec![
738            entry("shared.txt", "cid-shared", 100, 0, true),
739            entry("old-only.txt", "cid-old", 50, 0, true),
740        ];
741        let new_entries = vec![
742            entry("shared.txt", "cid-shared", 100, 0, true),
743            entry("new-only.txt", "cid-new", 75, 0, true),
744        ];
745        let old = ContentManifest::new(old_entries);
746        let new = ContentManifest::new(new_entries);
747        let diff = ManifestDiff::diff(&old, &new);
748
749        assert_eq!(diff.added.len(), 1);
750        assert_eq!(diff.added[0].cid, "cid-new");
751        assert_eq!(diff.removed.len(), 1);
752        assert_eq!(diff.removed[0].cid, "cid-old");
753    }
754
755    #[test]
756    fn test_manifest_diff_empty_when_identical() {
757        let m1 = ContentManifest::new(sample_entries());
758        let m2 = ContentManifest::new(sample_entries());
759        let diff = ManifestDiff::diff(&m1, &m2);
760        assert!(diff.is_empty());
761    }
762
763    #[test]
764    fn test_manifest_diff_all_added() {
765        let empty = ContentManifest::new(vec![]);
766        let full = ContentManifest::new(sample_entries());
767        let diff = ManifestDiff::diff(&empty, &full);
768        assert_eq!(diff.added.len(), 3);
769        assert!(diff.removed.is_empty());
770    }
771
772    #[test]
773    fn test_manifest_diff_all_removed() {
774        let full = ContentManifest::new(sample_entries());
775        let empty = ContentManifest::new(vec![]);
776        let diff = ManifestDiff::diff(&full, &empty);
777        assert!(diff.added.is_empty());
778        assert_eq!(diff.removed.len(), 3);
779    }
780}