diff --git a/Cargo.toml b/Cargo.toml index 0e188a4..a161afd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ members = [ "ch02-error-handling/exercises", "ch03-traits-and-generics", "ch03-traits-and-generics/exercises", + "ch04-content-addressable", + "ch04-content-addressable/exercises", ] [workspace.package] diff --git a/ch04-content-addressable/Cargo.toml b/ch04-content-addressable/Cargo.toml new file mode 100644 index 0000000..b0d4186 --- /dev/null +++ b/ch04-content-addressable/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch04-content-addressable" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 4: Content-Addressable Data — data that carries its own proof" + +[dependencies] +blake3 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch04-content-addressable/README.md b/ch04-content-addressable/README.md new file mode 100644 index 0000000..9fbbb2e --- /dev/null +++ b/ch04-content-addressable/README.md @@ -0,0 +1,178 @@ +# Chapter 4: Content-Addressable Data + +## The Big Idea + +Most data is **location-addressed**: you find it by *where* it lives. +A file path, a URL, a database row ID — these all say "go to this place +and get whatever's there." The problem: the content at that location can +change without the address changing. You got a file from `/data/config.json` +— but is it the *same* file you got yesterday? + +**Content-addressed** data flips this: the address *is derived from the +content*. You hash the data, and the hash becomes the name. If the content +changes, the name changes. If two files have the same name, they have the +same content. The data carries its own proof of integrity. + +This isn't a Rust-specific idea — Git uses it (commits, trees, and blobs +are all content-addressed). Docker image layers use it. IPFS uses it. +But Rust's type system makes it particularly clean to express, because you +can use traits to make content-addressability a *property of the type +itself*, not something bolted on after the fact. + +## Python Analogies + +### Hashing = `hashlib` + +```python +import hashlib + +data = b"hello, world" +digest = hashlib.sha256(data).hexdigest() +print(digest) # deterministic: same input always gives same output +``` + +```rust +let data = b"hello, world"; +let hash = blake3::hash(data); +println!("{}", hash.to_hex()); // same idea, but BLAKE3 is faster +``` + +**Why BLAKE3?** SHA-256 is fine for most things, but BLAKE3 is: +- Faster (designed for modern CPUs, parallelizable) +- Just as secure for integrity checking +- Simpler API (no finalization step) + +### Deterministic serialization = `json.dumps(sort_keys=True)` + +```python +import json + +# Problem: dict ordering affects the hash +data1 = {"b": 2, "a": 1} +data2 = {"a": 1, "b": 2} + +# Python 3.7+ preserves insertion order, so these differ: +json.dumps(data1) # '{"b": 2, "a": 1}' +json.dumps(data2) # '{"a": 1, "b": 2}' + +# Fix: sort keys for deterministic output +json.dumps(data1, sort_keys=True) # '{"a": 1, "b": 2}' +json.dumps(data2, sort_keys=True) # '{"a": 1, "b": 2}' — same! +``` + +```rust +use serde::Serialize; +use serde_json; + +#[derive(Serialize)] +struct Data { + a: i32, + b: i32, +} + +// Rust structs have a fixed field order — no sorting needed. +// serde_json always serializes fields in declaration order. +let data = Data { a: 1, b: 2 }; +let json = serde_json::to_string(&data).unwrap(); +// Always: {"a":1,"b":2} — deterministic by construction +``` + +**Key insight:** Python dicts are inherently unordered (conceptually), +so you need `sort_keys=True` to get deterministic output. Rust structs +have a fixed field order defined at compile time. Determinism comes from +the type system, not from runtime flags. + +### Content identifiers = "the hash IS the name" + +```python +import hashlib, json + +def content_id(obj): + """Generate a content-based identifier for any JSON-serializable object.""" + canonical = json.dumps(obj, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode()).hexdigest() + +doc1 = {"title": "Hello", "body": "World"} +doc2 = {"title": "Hello", "body": "World"} +doc3 = {"title": "Hello", "body": "Changed"} + +assert content_id(doc1) == content_id(doc2) # same content = same id +assert content_id(doc1) != content_id(doc3) # different content = different id +``` + +In Rust, we can make this a *trait* — a property of the type: + +```rust +trait ContentAddressable: Serialize { + fn content_id(&self) -> String { + let bytes = serde_json::to_vec(self).unwrap(); + blake3::hash(&bytes).to_hex().to_string() + } +} +``` + +Any type that implements `Serialize` can become `ContentAddressable` with +a single line. The trait carries the behavior, and the type system ensures +you can't call `content_id()` on something that can't be serialized. + +### Immutability = "frozen dataclass" + +```python +from dataclasses import dataclass + +@dataclass(frozen=True) +class Document: + title: str + body: str + # Can't modify after creation — mutations return new instances +``` + +```rust +// Rust values are immutable by default. No frozen flag needed. +#[derive(Debug, Clone, Serialize)] +struct Document { + title: String, + body: String, +} +// You'd need `mut` to modify — and the type system tracks it +``` + +## Why Content-Addressability Matters + +Content-addressed data gives you powerful properties for free: + +1. **Integrity**: If the hash matches, the data is uncorrupted. No trust + required — the data proves itself. + +2. **Deduplication**: Same content = same hash. Store it once, reference + it by hash from anywhere. + +3. **Caching**: Hash hasn't changed? Don't recompute. The hash is a + perfect cache key because it captures *exactly* what the content is. + +4. **Provenance**: Chain of hashes = audit trail. You can prove that data + at point B came from data at point A, because the hashes link them. + +5. **Concurrency safety**: Content-addressed data is inherently immutable. + You never update in place — you create a new version with a new hash. + No locks needed. + +These aren't theoretical benefits. Git uses content-addressing for its +entire object store. Docker uses it for image layers. Package managers +use it for dependency resolution. Any system that needs to answer "is +this the same data I saw before?" benefits from content-addressing. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| `hashlib.sha256()` | `blake3::hash()` | Faster, simpler API | +| `json.dumps(sort_keys=True)` | `serde_json::to_vec()` | Deterministic by struct definition | +| Manual hash functions | `ContentAddressable` trait | Hash behavior attached to the type | +| `@dataclass(frozen=True)` | Default immutability | No runtime flag needed | +| Content ID as a string | Content ID as a type | Type system tracks what's addressable | + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch04-content-addressable/exercises/Cargo.toml b/ch04-content-addressable/exercises/Cargo.toml new file mode 100644 index 0000000..f4ab142 --- /dev/null +++ b/ch04-content-addressable/exercises/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch04-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 4: Content-Addressable Data" + +[dependencies] +blake3 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch04-content-addressable/exercises/src/lib.rs b/ch04-content-addressable/exercises/src/lib.rs new file mode 100644 index 0000000..7c95b30 --- /dev/null +++ b/ch04-content-addressable/exercises/src/lib.rs @@ -0,0 +1,345 @@ +//! # Chapter 4 Exercises: Content-Addressable Data +//! +//! These exercises build a mini content-addressable system from scratch. +//! Each exercise builds on the previous one. +//! +//! Run tests: `cargo test -p ch04-exercises` + +#![allow(unused_variables, dead_code)] + +use serde::Serialize; + +// ============================================================ +// Exercise 1: Hash Function +// ============================================================ +// +// Python version: +// ```python +// import hashlib +// def hash_content(data: bytes) -> str: +// return hashlib.blake2b(data, digest_size=32).hexdigest() +// ``` +// +// Use blake3 to hash bytes and return a hex string. + +pub fn hash_content(data: &[u8]) -> String { + todo!("Hash the data with blake3 and return the hex string") +} + +// ============================================================ +// Exercise 2: A ContentAddressable Trait +// ============================================================ +// +// Python version: +// ```python +// class ContentAddressable: +// def canonical_bytes(self) -> bytes: +// raise NotImplementedError +// +// def content_id(self) -> str: +// return hash_content(self.canonical_bytes()) +// ``` +// +// Define a trait with: +// - A required method `canonical_bytes(&self) -> Vec` +// - A default method `content_id(&self) -> String` that hashes the bytes + +pub trait ContentAddressable { + /// Required: return the canonical byte representation of this value. + fn canonical_bytes(&self) -> Vec; + + /// Default: hash the canonical bytes to produce a content identifier. + fn content_id(&self) -> String { + todo!("Call canonical_bytes, pass to hash_content") + } +} + +// ============================================================ +// Exercise 3: Implement ContentAddressable for a Config Struct +// ============================================================ +// +// Python version: +// ```python +// @dataclass(frozen=True) +// class Config: +// name: str +// version: int +// debug: bool +// +// def canonical_bytes(self): +// return json.dumps( +// {"name": self.name, "version": self.version, "debug": self.debug}, +// sort_keys=True, separators=(',', ':') +// ).encode() +// ``` +// +// Implement ContentAddressable for Config using serde_json serialization. + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Config { + pub name: String, + pub version: u32, + pub debug: bool, +} + +impl Config { + pub fn new(name: &str, version: u32, debug: bool) -> Self { + Self { + name: name.to_string(), + version, + debug, + } + } +} + +impl ContentAddressable for Config { + fn canonical_bytes(&self) -> Vec { + todo!("Serialize self to JSON bytes using serde_json::to_vec") + } +} + +// ============================================================ +// Exercise 4: Content-Addressed Cache +// ============================================================ +// +// Python version: +// ```python +// class ContentCache: +// def __init__(self): +// self._cache = {} +// +// def get_or_compute(self, key_obj, compute_fn): +// cid = key_obj.content_id() +// if cid not in self._cache: +// self._cache[cid] = compute_fn(key_obj) +// return self._cache[cid] +// ``` +// +// Build a cache that uses content IDs as keys. If two different Config +// objects have the same content, the computation should only run once. + +pub struct ContentCache { + cache: std::collections::HashMap, +} + +impl Default for ContentCache { + fn default() -> Self { + Self::new() + } +} + +impl ContentCache { + pub fn new() -> Self { + Self { + cache: std::collections::HashMap::new(), + } + } + + /// Get a cached result or compute it. + /// + /// If an entry with the same content_id already exists, return it. + /// Otherwise, call `compute` with the value, store the result, and return it. + pub fn get_or_compute( + &mut self, + value: &T, + compute: impl FnOnce(&T) -> String, + ) -> String { + todo!("Check if content_id is in cache; if not, compute and store") + } + + pub fn len(&self) -> usize { + self.cache.len() + } + + pub fn is_empty(&self) -> bool { + self.cache.is_empty() + } +} + +// ============================================================ +// Exercise 5: Merkle-Style Chaining +// ============================================================ +// +// Python version: +// ```python +// class Snapshot: +// def __init__(self, data, parent_cid=None): +// self.data = data +// self.parent_cid = parent_cid +// +// def canonical_bytes(self): +// obj = {"data": self.data, "parent": self.parent_cid} +// return json.dumps(obj, sort_keys=True, separators=(',', ':')).encode() +// +// def content_id(self): +// return hash_content(self.canonical_bytes()) +// ``` +// +// A Snapshot includes a reference to its parent's content ID, creating +// a chain. Changing any snapshot changes all descendant CIDs. +// This is the same principle behind Git commits and blockchain blocks. +// +// Implement ContentAddressable for Snapshot. + +#[derive(Debug, Clone, Serialize)] +pub struct Snapshot { + pub data: String, + pub parent_cid: Option, +} + +impl Snapshot { + pub fn root(data: &str) -> Self { + Self { + data: data.to_string(), + parent_cid: None, + } + } + + pub fn child(data: &str, parent: &impl ContentAddressable) -> Self { + Self { + data: data.to_string(), + parent_cid: Some(parent.content_id()), + } + } +} + +impl ContentAddressable for Snapshot { + fn canonical_bytes(&self) -> Vec { + todo!("Serialize self to JSON bytes") + } +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_hash_deterministic() { + assert_eq!(hash_content(b"hello"), hash_content(b"hello")); + } + + #[test] + fn ex1_hash_different_input() { + assert_ne!(hash_content(b"hello"), hash_content(b"world")); + } + + #[test] + fn ex1_hash_is_hex() { + let h = hash_content(b"test"); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(h.len(), 64); // BLAKE3 produces 256-bit = 64 hex chars + } + + // Exercise 2 + 3 + #[test] + fn ex3_config_content_id_deterministic() { + let cfg = Config::new("app", 1, false); + assert_eq!(cfg.content_id(), cfg.content_id()); + } + + #[test] + fn ex3_same_config_same_id() { + let cfg1 = Config::new("app", 1, false); + let cfg2 = Config::new("app", 1, false); + assert_eq!(cfg1.content_id(), cfg2.content_id()); + } + + #[test] + fn ex3_different_config_different_id() { + let cfg1 = Config::new("app", 1, false); + let cfg2 = Config::new("app", 2, false); + assert_ne!(cfg1.content_id(), cfg2.content_id()); + } + + #[test] + fn ex3_debug_flag_changes_id() { + let cfg1 = Config::new("app", 1, false); + let cfg2 = Config::new("app", 1, true); + assert_ne!(cfg1.content_id(), cfg2.content_id()); + } + + // Exercise 4 + #[test] + fn ex4_cache_computes_once() { + let mut cache = ContentCache::new(); + let cfg = Config::new("app", 1, false); + + let mut call_count = 0; + + let result1 = cache.get_or_compute(&cfg, |c| { + call_count += 1; + format!("computed-{}", c.name) + }); + + // Same content, different object — should hit cache + let cfg2 = Config::new("app", 1, false); + let result2 = cache.get_or_compute(&cfg2, |c| { + call_count += 1; + format!("computed-{}", c.name) + }); + + assert_eq!(result1, "computed-app"); + assert_eq!(result1, result2); + assert_eq!(call_count, 1); // computed only once! + assert_eq!(cache.len(), 1); + } + + #[test] + fn ex4_cache_different_content() { + let mut cache = ContentCache::new(); + + cache.get_or_compute(&Config::new("a", 1, false), |c| c.name.clone()); + cache.get_or_compute(&Config::new("b", 1, false), |c| c.name.clone()); + + assert_eq!(cache.len(), 2); + } + + // Exercise 5 + #[test] + fn ex5_root_snapshot() { + let root = Snapshot::root("initial"); + assert!(root.parent_cid.is_none()); + let cid = root.content_id(); + assert_eq!(cid.len(), 64); + } + + #[test] + fn ex5_child_includes_parent_cid() { + let root = Snapshot::root("initial"); + let child = Snapshot::child("update", &root); + assert_eq!(child.parent_cid, Some(root.content_id())); + } + + #[test] + fn ex5_chain_integrity() { + let root = Snapshot::root("v1"); + let child = Snapshot::child("v2", &root); + let grandchild = Snapshot::child("v3", &child); + + // Changing the root changes the entire chain + let alt_root = Snapshot::root("v1-tampered"); + let alt_child = Snapshot::child("v2", &alt_root); + let alt_grandchild = Snapshot::child("v3", &alt_child); + + assert_ne!(root.content_id(), alt_root.content_id()); + assert_ne!(child.content_id(), alt_child.content_id()); + assert_ne!(grandchild.content_id(), alt_grandchild.content_id()); + } + + #[test] + fn ex5_same_data_different_parent_different_cid() { + let root1 = Snapshot::root("a"); + let root2 = Snapshot::root("b"); + + let child1 = Snapshot::child("same-data", &root1); + let child2 = Snapshot::child("same-data", &root2); + + // Same child data but different parents = different CIDs + assert_ne!(child1.content_id(), child2.content_id()); + } +} diff --git a/ch04-content-addressable/src/lib.rs b/ch04-content-addressable/src/lib.rs new file mode 100644 index 0000000..4a796a3 --- /dev/null +++ b/ch04-content-addressable/src/lib.rs @@ -0,0 +1,384 @@ +//! # Chapter 4: Content-Addressable Data +//! +//! This module builds the concept of content-addressable data from first +//! principles: hashing, deterministic serialization, and a trait that +//! makes "data that carries its own proof of integrity" a property of +//! the type system. +//! +//! Run the tests: `cargo test -p ch04-content-addressable` + +use serde::Serialize; + +// --------------------------------------------------------------------------- +// 1. Basic hashing with BLAKE3 +// --------------------------------------------------------------------------- + +/// Hash raw bytes and return the hex string. +/// +/// Python equivalent: +/// ```python +/// import hashlib +/// def hash_bytes(data: bytes) -> str: +/// return hashlib.sha256(data).hexdigest() +/// ``` +/// +/// We use BLAKE3 instead of SHA-256: faster, parallelizable, same security +/// guarantees for integrity checking. +pub fn hash_bytes(data: &[u8]) -> String { + blake3::hash(data).to_hex().to_string() +} + +// --------------------------------------------------------------------------- +// 2. Deterministic serialization with serde +// --------------------------------------------------------------------------- + +/// A document with a title and body. +/// +/// The `#[derive(Serialize)]` makes this serializable with serde. +/// Unlike Python dicts, struct field order is fixed at compile time, +/// so serialization is deterministic without sort_keys=True. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Document { + pub title: String, + pub body: String, +} + +impl Document { + pub fn new(title: &str, body: &str) -> Self { + Self { + title: title.to_string(), + body: body.to_string(), + } + } +} + +/// Serialize to canonical JSON bytes (compact, no trailing newline). +/// +/// Python equivalent: +/// ```python +/// def canonical_json(obj) -> bytes: +/// return json.dumps(obj, sort_keys=True, separators=(',', ':')).encode() +/// ``` +pub fn canonical_json(value: &T) -> Vec { + serde_json::to_vec(value).expect("serialization should not fail for valid types") +} + +// --------------------------------------------------------------------------- +// 3. The ContentAddressable trait +// --------------------------------------------------------------------------- + +/// A trait for data that can identify itself by its content. +/// +/// Any type that implements `Serialize` can become content-addressable. +/// The content ID is a BLAKE3 hash of the canonical JSON representation. +/// +/// This is a default-method trait (like Chapter 3): you get `content_id()` +/// for free just by implementing Serialize and opting in. +/// +/// Python equivalent: +/// ```python +/// class ContentAddressable: +/// def content_id(self) -> str: +/// canonical = json.dumps(self.__dict__, sort_keys=True, separators=(',', ':')) +/// return hashlib.blake3(canonical.encode()).hexdigest() +/// ``` +pub trait ContentAddressable: Serialize { + /// Return the content-based identifier for this value. + /// + /// Same content always produces the same ID. Different content + /// (with overwhelming probability) produces a different ID. + fn content_id(&self) -> String { + let bytes = canonical_json(self); + hash_bytes(&bytes) + } + + /// Check whether two values have the same content, by comparing + /// their content IDs. + fn same_content(&self, other: &impl ContentAddressable) -> bool { + self.content_id() == other.content_id() + } +} + +// Implement ContentAddressable for Document — one line! +impl ContentAddressable for Document {} + +// --------------------------------------------------------------------------- +// 4. Content-addressed storage (a simple in-memory store) +// --------------------------------------------------------------------------- + +/// A simple content-addressed store: values are stored by their content ID. +/// +/// Python equivalent: +/// ```python +/// class ContentStore: +/// def __init__(self): +/// self._store = {} +/// +/// def put(self, data: bytes) -> str: +/// cid = hash_bytes(data) +/// self._store[cid] = data +/// return cid +/// +/// def get(self, cid: str) -> bytes | None: +/// return self._store.get(cid) +/// ``` +/// +/// The Rust version uses generics + trait bounds: the store only accepts +/// types that are ContentAddressable. The type system enforces this. +pub struct ContentStore { + store: std::collections::HashMap>, +} + +impl ContentStore { + pub fn new() -> Self { + Self { + store: std::collections::HashMap::new(), + } + } + + /// Store a value and return its content ID. + /// If the same content already exists, this is a no-op (deduplication!). + pub fn put(&mut self, value: &T) -> String { + let cid = value.content_id(); + let bytes = canonical_json(value); + self.store.entry(cid.clone()).or_insert(bytes); + cid + } + + /// Retrieve raw bytes by content ID. + pub fn get(&self, cid: &str) -> Option<&[u8]> { + self.store.get(cid).map(|v| v.as_slice()) + } + + /// Check if content exists without retrieving it. + pub fn contains(&self, cid: &str) -> bool { + self.store.contains_key(cid) + } + + /// Number of unique items stored. + pub fn len(&self) -> usize { + self.store.len() + } + + /// Whether the store is empty. + pub fn is_empty(&self) -> bool { + self.store.is_empty() + } +} + +impl Default for ContentStore { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// 5. Verified retrieval — data that proves itself +// --------------------------------------------------------------------------- + +/// Retrieve and verify: fetch by CID, re-hash, confirm integrity. +/// +/// This is the key insight of content-addressed systems: the address +/// IS the checksum. If someone gives you a CID and data, you can verify +/// independently that the data matches — no trust required. +pub fn verify_content(claimed_cid: &str, data: &[u8]) -> bool { + let actual_cid = hash_bytes(data); + actual_cid == claimed_cid +} + +// --------------------------------------------------------------------------- +// 6. Composable content addressing — hashing a tree +// --------------------------------------------------------------------------- + +/// A collection of documents, itself content-addressable. +/// +/// When a collection is content-addressable, its CID depends on ALL +/// of its children. Change one document and the collection's CID changes. +/// This is how Git works: a tree hash includes all its blob hashes. +#[derive(Debug, Clone, Serialize)] +pub struct DocumentCollection { + pub name: String, + pub documents: Vec, +} + +impl DocumentCollection { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + documents: Vec::new(), + } + } + + pub fn add(&mut self, doc: Document) { + self.documents.push(doc); + } +} + +impl ContentAddressable for DocumentCollection {} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Basic hashing + + #[test] + fn hash_is_deterministic() { + let data = b"hello, world"; + assert_eq!(hash_bytes(data), hash_bytes(data)); + } + + #[test] + fn different_data_different_hash() { + assert_ne!(hash_bytes(b"hello"), hash_bytes(b"world")); + } + + // Deterministic serialization + + #[test] + fn serialization_is_deterministic() { + let doc = Document::new("Hello", "World"); + assert_eq!(canonical_json(&doc), canonical_json(&doc)); + } + + #[test] + fn same_content_same_bytes() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert_eq!(canonical_json(&doc1), canonical_json(&doc2)); + } + + // ContentAddressable trait + + #[test] + fn content_id_is_deterministic() { + let doc = Document::new("Test", "Content"); + assert_eq!(doc.content_id(), doc.content_id()); + } + + #[test] + fn same_content_same_id() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert_eq!(doc1.content_id(), doc2.content_id()); + } + + #[test] + fn different_content_different_id() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "Changed"); + assert_ne!(doc1.content_id(), doc2.content_id()); + } + + #[test] + fn same_content_check() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert!(doc1.same_content(&doc2)); + } + + #[test] + fn different_content_check() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Goodbye", "World"); + assert!(!doc1.same_content(&doc2)); + } + + // Content store + + #[test] + fn store_and_retrieve() { + let mut store = ContentStore::new(); + let doc = Document::new("Test", "Data"); + let cid = store.put(&doc); + + assert!(store.contains(&cid)); + assert!(store.get(&cid).is_some()); + } + + #[test] + fn store_deduplicates() { + let mut store = ContentStore::new(); + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + + let cid1 = store.put(&doc1); + let cid2 = store.put(&doc2); + + assert_eq!(cid1, cid2); + assert_eq!(store.len(), 1); // only stored once! + } + + #[test] + fn store_different_content_separately() { + let mut store = ContentStore::new(); + store.put(&Document::new("A", "1")); + store.put(&Document::new("B", "2")); + assert_eq!(store.len(), 2); + } + + // Verified retrieval + + #[test] + fn verify_valid_content() { + let data = canonical_json(&Document::new("Test", "Data")); + let cid = hash_bytes(&data); + assert!(verify_content(&cid, &data)); + } + + #[test] + fn verify_tampered_content() { + let data = canonical_json(&Document::new("Test", "Data")); + let cid = hash_bytes(&data); + + let mut tampered = data.clone(); + tampered[0] = b'X'; + assert!(!verify_content(&cid, &tampered)); + } + + // Composable content addressing + + #[test] + fn collection_cid_includes_all_documents() { + let mut col1 = DocumentCollection::new("docs"); + col1.add(Document::new("A", "1")); + col1.add(Document::new("B", "2")); + + let mut col2 = DocumentCollection::new("docs"); + col2.add(Document::new("A", "1")); + col2.add(Document::new("B", "2")); + + assert_eq!(col1.content_id(), col2.content_id()); + } + + #[test] + fn collection_cid_changes_with_any_document() { + let mut col1 = DocumentCollection::new("docs"); + col1.add(Document::new("A", "1")); + col1.add(Document::new("B", "2")); + + let mut col2 = DocumentCollection::new("docs"); + col2.add(Document::new("A", "1")); + col2.add(Document::new("B", "CHANGED")); + + assert_ne!(col1.content_id(), col2.content_id()); + } + + #[test] + fn collection_cid_sensitive_to_order() { + let mut col1 = DocumentCollection::new("docs"); + col1.add(Document::new("A", "1")); + col1.add(Document::new("B", "2")); + + let mut col2 = DocumentCollection::new("docs"); + col2.add(Document::new("B", "2")); + col2.add(Document::new("A", "1")); + + // Order matters! Different order = different CID + assert_ne!(col1.content_id(), col2.content_id()); + } +}