//! `ClawSyncManifest` and `IbltManifest` — pre-flight exchange structs. //! //! Two negotiation modes are supported: //! 1. **Full manifest** (`ClawSyncManifest`): O(N) bytes, one round trip. //! 2. **IBLT sketch** (`IbltManifest`): O(D) bytes, O(log N) identification. //! The IBLT mode is preferred when both sides support it (checked via //! `PeerCapabilities.iblt_manifest`). use rkyv::{Archive, Deserialize, Serialize}; use clawhdf5_onion::writer::OnionFile; use crate::iblt::{DEFAULT_HASH_COUNT, IbltDecodeResult, IbltDiff, IbltSketch}; /// A compact summary of one revision — used in the manifest. #[derive(Archive, Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct RevisionSummaryPacket { pub revision: u64, pub branch_id: u32, pub parent_rev: u64, pub timestamp: f64, /// BLAKE3 hash of the pages in this revision. pub blake3: [u8; 32], pub annotation: Option, } /// Full manifest of a `.onion` sidecar, sent during sync negotiation. #[derive(Archive, Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct ClawSyncManifest { /// Logical identifier for the agent / file (typically the HDF5 file path /// or a user-supplied name). pub agent_id: String, /// BLAKE3 hash of the raw `.h5` base file (used to detect base divergence). pub file_blake3: [u8; 32], /// Global revision count. pub revision_count: u64, /// HEAD revision number on the default (main) branch. pub head_revision: u64, /// BLAKE3 of the HEAD revision's pages. pub head_blake3: [u8; 32], /// Per-revision summaries (all branches). pub revisions: Vec, /// Unix timestamp of the last committed revision. pub last_write: f64, } impl ClawSyncManifest { /// Build a manifest from an open [`OnionFile`] and the raw HDF5 base bytes. pub fn from_onion(agent_id: &str, onion: &OnionFile, h5_base: &[u8]) -> Self { use clawsync_core::checksum::blake3_hash; let summaries = onion.list_revisions(); let revision_count = summaries.len() as u64; let (head_revision, head_blake3) = summaries .last() .map(|s| { let hash = hex_to_bytes32(&s.blake3_hex).unwrap_or([0u8; 32]); (s.revision, hash) }) .unwrap_or((0, [0u8; 32])); let last_write = summaries.last().map(|s| s.timestamp).unwrap_or(0.0); let revisions = summaries .into_iter() .map(|s| RevisionSummaryPacket { revision: s.revision, branch_id: s.branch_id, parent_rev: s.parent_rev, timestamp: s.timestamp, blake3: hex_to_bytes32(&s.blake3_hex).unwrap_or([0u8; 32]), annotation: s.annotation, }) .collect(); Self { agent_id: agent_id.to_string(), file_blake3: blake3_hash(h5_base), revision_count, head_revision, head_blake3, revisions, last_write, } } /// Serialize to rkyv bytes. pub fn to_bytes(&self) -> Result, String> { rkyv::to_bytes::(self) .map(|v| v.to_vec()) .map_err(|e| e.to_string()) } /// Deserialize from rkyv bytes. /// /// Copies into an aligned buffer to satisfy rkyv's alignment requirement. pub fn from_bytes(bytes: &[u8]) -> Result { let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len()); aligned.extend_from_slice(bytes); rkyv::from_bytes::(&aligned) .map_err(|e| e.to_string()) } /// Return revision numbers present in this manifest as a set. pub fn revision_set(&self) -> std::collections::HashSet { self.revisions.iter().map(|r| r.revision).collect() } } // ───────────────────────────────────────────────────────────────────────────── // IbltManifest — compact pre-flight with IBLT sketch // ───────────────────────────────────────────────────────────────────────────── /// A compact sync manifest carrying an IBLT sketch instead of full revision /// list. /// /// Wire size: `~21 + ceil(N × 0.1) × 20` bytes. /// For N=1 000: ~3 KB vs ~60 KB for `ClawSyncManifest`. #[derive(Archive, Deserialize, Serialize, Debug, Clone, PartialEq)] pub struct IbltManifest { pub agent_id: String, pub file_blake3: [u8; 32], pub revision_count: u64, pub head_revision: u64, pub head_blake3: [u8; 32], pub last_write: f64, /// Serialised [`IbltSketch`] bytes. pub sketch: Vec, /// Number of cells in the sketch (m). pub sketch_cells: u32, } impl IbltManifest { /// Build from an open [`OnionFile`], the raw HDF5 base bytes, and a seed. pub fn from_onion(agent_id: &str, onion: &OnionFile, h5_base: &[u8], seed: u64) -> Self { use clawsync_core::checksum::blake3_hash; let summaries = onion.list_revisions(); let revision_count = summaries.len() as u64; let (head_revision, head_blake3) = summaries .last() .map(|s| { ( s.revision, hex_to_bytes32(&s.blake3_hex).unwrap_or([0u8; 32]), ) }) .unwrap_or((0, [0u8; 32])); let last_write = summaries.last().map(|s| s.timestamp).unwrap_or(0.0); let revision_numbers: Vec = summaries.iter().map(|s| s.revision).collect(); let sketch = IbltSketch::from_keys(&revision_numbers, seed); let sketch_cells = sketch.cell_count() as u32; let sketch_bytes = sketch.to_bytes(); Self { agent_id: agent_id.to_string(), file_blake3: blake3_hash(h5_base), revision_count, head_revision, head_blake3, last_write, sketch: sketch_bytes, sketch_cells, } } /// Compute which revisions `self` (remote) has that `local_keys` lacks, /// and vice-versa. /// /// The seed is extracted from the serialised remote sketch — both sides /// automatically use the same seed without an extra exchange. /// /// Returns `Ok(IbltDiff)` or `Err` if the sketch cannot be decoded (too /// small or malformed). pub fn diff_against(&self, local_keys: &[u64]) -> Result { let remote_sketch = IbltSketch::from_bytes(&self.sketch) .map_err(|_| "cannot deserialise remote IBLT sketch")?; // Use the seed embedded in the remote sketch so both sides agree. let seed = remote_sketch.seed(); let m = remote_sketch .cell_count() .max(IbltSketch::recommended_cells(local_keys.len())); let mut a = IbltSketch::new(m, DEFAULT_HASH_COUNT, seed); for &k in local_keys { a.insert(k); } // Pad remote to same m if needed. let b = if remote_sketch.cell_count() == m { remote_sketch } else { let b_bytes = remote_sketch.to_bytes(); IbltSketch::from_bytes(&b_bytes).unwrap() }; a.subtract(&b); match a.decode() { IbltDecodeResult::Complete(diff) => Ok(diff), IbltDecodeResult::NeedMoreCells => Err("IBLT sketch too small; retry with larger m"), } } /// Serialise to rkyv bytes. pub fn to_bytes(&self) -> Result, String> { rkyv::to_bytes::(self) .map(|v| v.to_vec()) .map_err(|e| e.to_string()) } /// Deserialise from rkyv bytes. pub fn from_bytes(bytes: &[u8]) -> Result { let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len()); aligned.extend_from_slice(bytes); rkyv::from_bytes::(&aligned).map_err(|e| e.to_string()) } } /// Parse a 64-char lowercase hex string into a `[u8; 32]`. fn hex_to_bytes32(hex: &str) -> Option<[u8; 32]> { if hex.len() != 64 { return None; } let mut out = [0u8; 32]; for (i, byte) in out.iter_mut().enumerate() { *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; } Some(out) } // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; fn make_onion() -> (std::path::PathBuf, OnionFile, Vec) { let f = NamedTempFile::new().unwrap(); let h5_path = f.path().with_extension("h5"); let base = b"\x89HDF\r\n\x1a\n".to_vec(); std::fs::write(&h5_path, &base).unwrap(); let mut onion = OnionFile::create(&h5_path, 4096).unwrap(); let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![0xAAu8; 4096]); onion.commit_session(s, Some("first")).unwrap(); let mut s2 = onion.begin_session(None).unwrap(); s2.record_page(0, &vec![0xBBu8; 4096]); onion.commit_session(s2, Some("second")).unwrap(); (h5_path, onion, base) } #[test] fn build_manifest_basic() { let (_h5, onion, base) = make_onion(); let m = ClawSyncManifest::from_onion("test-agent", &onion, &base); assert_eq!(m.agent_id, "test-agent"); assert_eq!(m.revision_count, 2); assert_eq!(m.revisions.len(), 2); assert_eq!(m.head_revision, 1); } #[test] fn manifest_roundtrip() { let (_h5, onion, base) = make_onion(); let m = ClawSyncManifest::from_onion("agent", &onion, &base); let bytes = m.to_bytes().unwrap(); let recovered = ClawSyncManifest::from_bytes(&bytes).unwrap(); assert_eq!(recovered.agent_id, m.agent_id); assert_eq!(recovered.revision_count, m.revision_count); assert_eq!(recovered.revisions.len(), m.revisions.len()); } #[test] fn revision_set() { let (_h5, onion, base) = make_onion(); let m = ClawSyncManifest::from_onion("a", &onion, &base); let set = m.revision_set(); assert!(set.contains(&0)); assert!(set.contains(&1)); assert!(!set.contains(&2)); } #[test] fn empty_onion_manifest() { let f = NamedTempFile::new().unwrap(); let h5_path = f.path().with_extension("h5"); let base = b"\x89HDF\r\n\x1a\n".to_vec(); std::fs::write(&h5_path, &base).unwrap(); let onion = OnionFile::create(&h5_path, 4096).unwrap(); let m = ClawSyncManifest::from_onion("empty", &onion, &base); assert_eq!(m.revision_count, 0); assert!(m.revisions.is_empty()); } #[test] fn file_blake3_reflects_base() { use clawsync_core::checksum::blake3_hash; let (_h5, onion, base) = make_onion(); let m = ClawSyncManifest::from_onion("x", &onion, &base); assert_eq!(m.file_blake3, blake3_hash(&base)); } }