use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
const FNV_OFFSET_BASIS_64: u64 = 14_695_981_039_346_656_037;
const FNV_PRIME_64: u64 = 1_099_511_628_211;
fn fnv1a_64(data: &[u8]) -> u64 {
let mut hash = FNV_OFFSET_BASIS_64;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME_64);
}
hash
}
fn fnv1a_hex(data: &[u8]) -> String {
format!("{:016x}", fnv1a_64(data))
}
fn combine_hashes(left: &str, right: &str) -> String {
let mut combined = Vec::with_capacity(left.len() + right.len());
combined.extend_from_slice(left.as_bytes());
combined.extend_from_slice(right.as_bytes());
fnv1a_hex(&combined)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEntry {
pub path: String,
pub cid: String,
pub size_bytes: u64,
pub chunk_index: u32,
pub is_final_chunk: bool,
}
impl ManifestEntry {
pub fn new(
path: impl Into<String>,
cid: impl Into<String>,
size_bytes: u64,
chunk_index: u32,
is_final_chunk: bool,
) -> Self {
Self {
path: path.into(),
cid: cid.into(),
size_bytes,
chunk_index,
is_final_chunk,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MerkleTree {
pub leaves: Vec<String>,
pub nodes: Vec<String>,
}
impl MerkleTree {
pub fn build(cids: &[String]) -> Self {
if cids.is_empty() {
return Self {
leaves: Vec::new(),
nodes: Vec::new(),
};
}
let leaf_hashes: Vec<String> = cids.iter().map(|c| fnv1a_hex(c.as_bytes())).collect();
let mut all_nodes: Vec<String> = leaf_hashes.clone();
let mut current_level = leaf_hashes;
while current_level.len() > 1 {
let mut next_level: Vec<String> = Vec::new();
let mut i = 0;
while i < current_level.len() {
let left = ¤t_level[i];
let right = if i + 1 < current_level.len() {
¤t_level[i + 1]
} else {
¤t_level[i]
};
next_level.push(combine_hashes(left, right));
i += 2;
}
all_nodes.extend(next_level.iter().cloned());
current_level = next_level;
}
Self {
leaves: cids.to_vec(),
nodes: all_nodes,
}
}
pub fn root(&self) -> Option<&str> {
self.nodes.last().map(|s| s.as_str())
}
pub fn proof_for(&self, index: usize) -> Option<Vec<String>> {
let n = self.leaves.len();
if n == 0 || index >= n {
return None;
}
let mut proof = Vec::new();
let mut current_index = index;
let mut level_size = n;
let mut level_start = 0usize;
while level_size > 1 {
let sibling_index = if current_index.is_multiple_of(2) {
(current_index + 1).min(level_size - 1)
} else {
current_index - 1
};
proof.push(self.nodes[level_start + sibling_index].clone());
level_start += level_size;
level_size = level_size.div_ceil(2);
current_index /= 2;
}
Some(proof)
}
pub fn verify_proof(leaf: &str, index: usize, proof: &[String], root: &str) -> bool {
let mut current_hash = fnv1a_hex(leaf.as_bytes());
let mut current_index = index;
for sibling in proof {
current_hash = if current_index.is_multiple_of(2) {
combine_hashes(¤t_hash, sibling)
} else {
combine_hashes(sibling, ¤t_hash)
};
current_index /= 2;
}
current_hash == root
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentManifest {
pub manifest_id: String,
pub entries: Vec<ManifestEntry>,
pub root_cid: String,
pub total_size_bytes: u64,
pub created_at_ms: u64,
}
impl ContentManifest {
pub fn new(mut entries: Vec<ManifestEntry>) -> Self {
entries.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.chunk_index.cmp(&b.chunk_index))
});
let total_size_bytes: u64 = entries.iter().map(|e| e.size_bytes).sum();
let mut sorted_cids: Vec<&str> = entries.iter().map(|e| e.cid.as_str()).collect();
sorted_cids.sort_unstable();
let id_input: String = sorted_cids.join("");
let manifest_id = fnv1a_hex(id_input.as_bytes());
let ordered_cids: Vec<String> = entries.iter().map(|e| e.cid.clone()).collect();
let tree = MerkleTree::build(&ordered_cids);
let root_cid = tree.root().unwrap_or("").to_string();
let created_at_ms = created_at_ms_now();
Self {
manifest_id,
entries,
root_cid,
total_size_bytes,
created_at_ms,
}
}
pub fn get_entries_for_path<'a>(&'a self, path: &str) -> Vec<&'a ManifestEntry> {
self.entries.iter().filter(|e| e.path == path).collect()
}
pub fn total_chunks_for_path(&self, path: &str) -> usize {
self.entries.iter().filter(|e| e.path == path).count()
}
pub fn is_complete_for_path(&self, path: &str) -> bool {
let path_entries: Vec<&ManifestEntry> =
self.entries.iter().filter(|e| e.path == path).collect();
if path_entries.is_empty() {
return false;
}
let final_entries: Vec<&&ManifestEntry> =
path_entries.iter().filter(|e| e.is_final_chunk).collect();
if final_entries.len() != 1 {
return false;
}
let max_index = final_entries[0].chunk_index;
let expected_count = (max_index as usize) + 1;
if path_entries.len() != expected_count {
return false;
}
let indices: BTreeSet<u32> = path_entries.iter().map(|e| e.chunk_index).collect();
for idx in 0..=max_index {
if !indices.contains(&idx) {
return false;
}
}
true
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn paths(&self) -> Vec<String> {
let mut seen: BTreeSet<&str> = BTreeSet::new();
for entry in &self.entries {
seen.insert(entry.path.as_str());
}
seen.into_iter().map(|s| s.to_string()).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestDiff {
pub added: Vec<ManifestEntry>,
pub removed: Vec<ManifestEntry>,
}
impl ManifestDiff {
pub fn diff(old: &ContentManifest, new: &ContentManifest) -> Self {
let old_cids: BTreeMap<&str, &ManifestEntry> =
old.entries.iter().map(|e| (e.cid.as_str(), e)).collect();
let new_cids: BTreeMap<&str, &ManifestEntry> =
new.entries.iter().map(|e| (e.cid.as_str(), e)).collect();
let added: Vec<ManifestEntry> = new_cids
.iter()
.filter(|(cid, _)| !old_cids.contains_key(*cid))
.map(|(_, e)| (*e).clone())
.collect();
let removed: Vec<ManifestEntry> = old_cids
.iter()
.filter(|(cid, _)| !new_cids.contains_key(*cid))
.map(|(_, e)| (*e).clone())
.collect();
Self { added, removed }
}
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
fn created_at_ms_now() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, cid: &str, size: u64, idx: u32, final_chunk: bool) -> ManifestEntry {
ManifestEntry::new(path, cid, size, idx, final_chunk)
}
fn sample_entries() -> Vec<ManifestEntry> {
vec![
entry("a/b.txt", "cid-ab-0", 100, 0, false),
entry("a/b.txt", "cid-ab-1", 200, 1, true),
entry("c/d.bin", "cid-cd-0", 50, 0, true),
]
}
#[test]
fn test_manifest_new_total_size() {
let m = ContentManifest::new(sample_entries());
assert_eq!(m.total_size_bytes, 350);
}
#[test]
fn test_manifest_new_entry_count() {
let m = ContentManifest::new(sample_entries());
assert_eq!(m.entry_count(), 3);
}
#[test]
fn test_manifest_new_sorted_entries() {
let entries = vec![
entry("z/last.txt", "cid-z", 10, 0, true),
entry("a/first.txt", "cid-a", 20, 0, true),
];
let m = ContentManifest::new(entries);
assert_eq!(m.entries[0].path, "a/first.txt");
assert_eq!(m.entries[1].path, "z/last.txt");
}
#[test]
fn test_get_entries_for_path_found() {
let m = ContentManifest::new(sample_entries());
let entries = m.get_entries_for_path("a/b.txt");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].chunk_index, 0);
assert_eq!(entries[1].chunk_index, 1);
}
#[test]
fn test_get_entries_for_path_not_found() {
let m = ContentManifest::new(sample_entries());
let entries = m.get_entries_for_path("nonexistent.txt");
assert!(entries.is_empty());
}
#[test]
fn test_paths_unique_sorted() {
let m = ContentManifest::new(sample_entries());
let paths = m.paths();
assert_eq!(paths, vec!["a/b.txt", "c/d.bin"]);
}
#[test]
fn test_paths_empty_manifest() {
let m = ContentManifest::new(vec![]);
assert!(m.paths().is_empty());
}
#[test]
fn test_is_complete_for_path_single_chunk() {
let m = ContentManifest::new(sample_entries());
assert!(m.is_complete_for_path("c/d.bin"));
}
#[test]
fn test_is_complete_for_path_multi_chunk_complete() {
let m = ContentManifest::new(sample_entries());
assert!(m.is_complete_for_path("a/b.txt"));
}
#[test]
fn test_is_complete_for_path_missing_final() {
let entries = vec![
entry("f.txt", "cid-f-0", 10, 0, false),
entry("f.txt", "cid-f-1", 10, 1, false),
];
let m = ContentManifest::new(entries);
assert!(!m.is_complete_for_path("f.txt"));
}
#[test]
fn test_is_complete_for_path_gap() {
let entries = vec![
entry("g.bin", "cid-g-0", 10, 0, false),
entry("g.bin", "cid-g-2", 10, 2, true),
];
let m = ContentManifest::new(entries);
assert!(!m.is_complete_for_path("g.bin"));
}
#[test]
fn test_is_complete_for_path_nonexistent() {
let m = ContentManifest::new(sample_entries());
assert!(!m.is_complete_for_path("does_not_exist.txt"));
}
#[test]
fn test_manifest_id_deterministic() {
let m1 = ContentManifest::new(sample_entries());
let m2 = ContentManifest::new(sample_entries());
assert_eq!(m1.manifest_id, m2.manifest_id);
}
#[test]
fn test_manifest_id_order_independent() {
let e1 = vec![
entry("a.txt", "cid-X", 10, 0, true),
entry("b.txt", "cid-Y", 20, 0, true),
];
let e2 = vec![
entry("b.txt", "cid-Y", 20, 0, true),
entry("a.txt", "cid-X", 10, 0, true),
];
let m1 = ContentManifest::new(e1);
let m2 = ContentManifest::new(e2);
assert_eq!(m1.manifest_id, m2.manifest_id);
}
#[test]
fn test_merkle_tree_single_leaf() {
let cids = vec!["cid-1".to_string()];
let tree = MerkleTree::build(&cids);
assert_eq!(tree.leaves.len(), 1);
let leaf_hash = fnv1a_hex(b"cid-1");
assert_eq!(tree.root(), Some(leaf_hash.as_str()));
}
#[test]
fn test_merkle_tree_two_leaves() {
let cids = vec!["cid-A".to_string(), "cid-B".to_string()];
let tree = MerkleTree::build(&cids);
assert_eq!(tree.leaves.len(), 2);
assert_eq!(tree.nodes.len(), 3);
let root = tree.root().expect("root must exist");
assert_eq!(root.len(), 16); }
#[test]
fn test_merkle_tree_three_leaves() {
let cids: Vec<String> = ["c1", "c2", "c3"].iter().map(|s| s.to_string()).collect();
let tree = MerkleTree::build(&cids);
assert_eq!(tree.nodes.len(), 6);
assert!(tree.root().is_some());
}
#[test]
fn test_merkle_tree_four_leaves() {
let cids: Vec<String> = ["c1", "c2", "c3", "c4"]
.iter()
.map(|s| s.to_string())
.collect();
let tree = MerkleTree::build(&cids);
assert_eq!(tree.nodes.len(), 7);
}
#[test]
fn test_merkle_tree_empty() {
let tree = MerkleTree::build(&[]);
assert!(tree.root().is_none());
assert!(tree.nodes.is_empty());
}
#[test]
fn test_merkle_root_deterministic() {
let cids: Vec<String> = ["x", "y", "z"].iter().map(|s| s.to_string()).collect();
let t1 = MerkleTree::build(&cids);
let t2 = MerkleTree::build(&cids);
assert_eq!(t1.root(), t2.root());
}
#[test]
fn test_proof_for_correct_length_two_leaves() {
let cids = vec!["cid-L".to_string(), "cid-R".to_string()];
let tree = MerkleTree::build(&cids);
let proof = tree.proof_for(0).expect("proof must exist");
assert_eq!(proof.len(), 1);
}
#[test]
fn test_proof_for_correct_length_four_leaves() {
let cids: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
let tree = MerkleTree::build(&cids);
for idx in 0..4usize {
let proof = tree.proof_for(idx).expect("proof must exist");
assert_eq!(proof.len(), 2, "leaf {idx} proof length mismatch");
}
}
#[test]
fn test_verify_proof_valid() {
let cids: Vec<String> = ["alpha", "beta", "gamma", "delta"]
.iter()
.map(|s| s.to_string())
.collect();
let tree = MerkleTree::build(&cids);
let root = tree.root().expect("root").to_string();
for (idx, cid) in cids.iter().enumerate() {
let proof = tree.proof_for(idx).expect("proof");
assert!(
MerkleTree::verify_proof(cid, idx, &proof, &root),
"valid proof failed for index {idx}"
);
}
}
#[test]
fn test_verify_proof_tampered_leaf() {
let cids: Vec<String> = ["one", "two", "three", "four"]
.iter()
.map(|s| s.to_string())
.collect();
let tree = MerkleTree::build(&cids);
let root = tree.root().expect("root").to_string();
let proof = tree.proof_for(0).expect("proof");
assert!(
!MerkleTree::verify_proof("tampered", 0, &proof, &root),
"tampered proof must fail"
);
}
#[test]
fn test_proof_for_out_of_range() {
let cids = vec!["only-one".to_string()];
let tree = MerkleTree::build(&cids);
assert!(tree.proof_for(5).is_none());
}
#[test]
fn test_manifest_diff_added_removed() {
let old_entries = vec![
entry("shared.txt", "cid-shared", 100, 0, true),
entry("old-only.txt", "cid-old", 50, 0, true),
];
let new_entries = vec![
entry("shared.txt", "cid-shared", 100, 0, true),
entry("new-only.txt", "cid-new", 75, 0, true),
];
let old = ContentManifest::new(old_entries);
let new = ContentManifest::new(new_entries);
let diff = ManifestDiff::diff(&old, &new);
assert_eq!(diff.added.len(), 1);
assert_eq!(diff.added[0].cid, "cid-new");
assert_eq!(diff.removed.len(), 1);
assert_eq!(diff.removed[0].cid, "cid-old");
}
#[test]
fn test_manifest_diff_empty_when_identical() {
let m1 = ContentManifest::new(sample_entries());
let m2 = ContentManifest::new(sample_entries());
let diff = ManifestDiff::diff(&m1, &m2);
assert!(diff.is_empty());
}
#[test]
fn test_manifest_diff_all_added() {
let empty = ContentManifest::new(vec![]);
let full = ContentManifest::new(sample_entries());
let diff = ManifestDiff::diff(&empty, &full);
assert_eq!(diff.added.len(), 3);
assert!(diff.removed.is_empty());
}
#[test]
fn test_manifest_diff_all_removed() {
let full = ContentManifest::new(sample_entries());
let empty = ContentManifest::new(vec![]);
let diff = ManifestDiff::diff(&full, &empty);
assert!(diff.added.is_empty());
assert_eq!(diff.removed.len(), 3);
}
}