A better Rust ATProto crate

swapped repo firehose types to use generated api

+244 -377
+1
Cargo.lock
··· 2628 2628 "ed25519-dalek", 2629 2629 "hex", 2630 2630 "iroh-car", 2631 + "jacquard-api", 2631 2632 "jacquard-common", 2632 2633 "jacquard-derive", 2633 2634 "k256",
+1
crates/jacquard-repo/Cargo.toml
··· 18 18 # Internal 19 19 jacquard-common = { path = "../jacquard-common", version = "0.9", features = ["crypto-ed25519", "crypto-k256", "crypto-p256"] } 20 20 jacquard-derive = { path = "../jacquard-derive", version = "0.9" } 21 + jacquard-api = { path = "../jacquard-api", version = "0.9", features = ["streaming"] } 21 22 22 23 # Serialization 23 24 serde.workspace = true
+215 -360
crates/jacquard-repo/src/commit/firehose.rs
··· 4 4 //! to avoid a dependency on the full API crate. They represent firehose protocol messages, 5 5 //! which are DISTINCT from repository commit objects. 6 6 7 - use bytes::Bytes; 8 - use jacquard_common::types::cid::CidLink; 7 + pub use jacquard_api::com_atproto::sync::subscribe_repos::Commit as FirehoseCommit; 8 + pub use jacquard_api::com_atproto::sync::subscribe_repos::RepoOp; 9 + use jacquard_api::com_atproto::sync::subscribe_repos::{Commit, RepoOpAction}; 9 10 use jacquard_common::types::crypto::PublicKey; 10 - use jacquard_common::types::string::{Datetime, Did, Tid}; 11 - use jacquard_common::{CowStr, IntoStatic}; 12 11 use smol_str::ToSmolStr; 13 12 14 - /// Firehose commit message (sync v1.0 and v1.1) 15 - /// 16 - /// Represents an update of repository state in the firehose stream. 17 - /// This is the message format sent over `com.atproto.sync.subscribeRepos`. 13 + /// Convert to VerifiedWriteOp for v1.1 validation 18 14 /// 19 - /// **Sync v1.0 vs v1.1:** 20 - /// - v1.0: `prev_data` is None/skipped, consumers must have sufficient previous repository state to validate 21 - /// - v1.1: `prev_data` includes previous MST root for inductive validation 22 - #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub struct FirehoseCommit<'a> { 25 - /// The repo this event comes from 26 - #[serde(borrow)] 27 - pub repo: Did<'a>, 28 - 29 - /// The rev of the emitted commit 30 - pub rev: Tid, 31 - 32 - /// The stream sequence number of this message 33 - pub seq: i64, 34 - 35 - /// The rev of the last emitted commit from this repo (if any) 36 - pub since: Tid, 37 - 38 - /// Timestamp of when this message was originally broadcast 39 - pub time: Datetime, 40 - 41 - /// Repo commit object CID 42 - /// 43 - /// This CID points to the repository commit block (with did, version, data, rev, prev, sig). 44 - /// It must be the first entry in the CAR header 'roots' list. 45 - #[serde(borrow)] 46 - pub commit: CidLink<'a>, 47 - 48 - /// CAR file containing relevant blocks 49 - /// 50 - /// Contains blocks as a diff since the previous repo state. The commit block 51 - /// must be included, and its CID must be the first root in the CAR header. 52 - /// 53 - /// For sync v1.1, may include additional MST node blocks needed for operation inversion. 54 - #[serde(with = "super::serde_bytes_helper")] 55 - pub blocks: Bytes, 56 - 57 - /// Operations in this commit 58 - #[serde(borrow)] 59 - pub ops: Vec<RepoOp<'a>>, 60 - 61 - /// Previous MST root CID (sync v1.1 only) 62 - /// 63 - /// The root CID of the MST tree for the previous commit (indicated by the 'since' field). 64 - /// Corresponds to the 'data' field in the previous repo commit object. 65 - /// 66 - /// **Sync v1.1 inductive validation:** 67 - /// - Enables validation without local MST state 68 - /// - Operations can be inverted (creates→deletes, deletes→creates with prev values) 69 - /// - Required for "inductive firehose" consumption 70 - /// 71 - /// **Sync v1.0:** 72 - /// - This field is None 73 - /// - Consumers must have previous repository state 74 - #[serde(skip_serializing_if = "Option::is_none")] 75 - #[serde(borrow)] 76 - pub prev_data: Option<CidLink<'a>>, 77 - 78 - /// Blob CIDs referenced in this commit 79 - #[serde(borrow)] 80 - pub blobs: Vec<CidLink<'a>>, 81 - 82 - /// DEPRECATED: Replaced by #sync event and data limits 83 - /// 84 - /// Indicates that this commit contained too many ops, or data size was too large. 85 - /// Consumers will need to make a separate request to get missing data. 86 - pub too_big: bool, 87 - 88 - /// DEPRECATED: Unused 89 - pub rebase: bool, 90 - } 91 - 92 - /// A repository operation (mutation of a single record) 93 - #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 94 - #[serde(rename_all = "camelCase")] 95 - pub struct RepoOp<'a> { 96 - /// Operation type: "create", "update", or "delete" 97 - #[serde(borrow)] 98 - pub action: CowStr<'a>, 99 - 100 - /// Collection/rkey path (e.g., "app.bsky.feed.post/abc123") 101 - #[serde(borrow)] 102 - pub path: CowStr<'a>, 103 - 104 - /// For creates and updates, the new record CID. For deletions, None (null). 105 - #[serde(skip_serializing_if = "Option::is_none")] 106 - #[serde(borrow)] 107 - pub cid: Option<CidLink<'a>>, 108 - 109 - /// For updates and deletes, the previous record CID 110 - /// 111 - /// Required for sync v1.1 inductive firehose validation. 112 - /// For creates, this field should not be defined. 113 - #[serde(skip_serializing_if = "Option::is_none")] 114 - #[serde(borrow)] 115 - pub prev: Option<CidLink<'a>>, 116 - } 117 - 118 - impl<'a> RepoOp<'a> { 119 - /// Convert to VerifiedWriteOp for v1.1 validation 120 - /// 121 - /// Validates that all required fields are present for inversion. 122 - pub fn to_invertible_op(&self) -> Result<VerifiedWriteOp> { 123 - let key = self.path.to_smolstr(); 124 - 125 - match self.action.as_ref() { 126 - "create" => { 127 - let cid = self 128 - .cid 129 - .as_ref() 130 - .ok_or_else(|| RepoError::invalid_commit("create operation missing cid field"))? 131 - .to_ipld() 132 - .map_err(|e| RepoError::invalid_cid_conversion(e, "create cid"))?; 133 - 134 - Ok(VerifiedWriteOp::Create { key, cid }) 135 - } 136 - "update" => { 137 - let cid = self 138 - .cid 139 - .as_ref() 140 - .ok_or_else(|| RepoError::invalid_commit("update operation missing cid field"))? 141 - .to_ipld() 142 - .map_err(|e| RepoError::invalid_cid_conversion(e, "update cid"))?; 143 - 144 - let prev = self 145 - .prev 146 - .as_ref() 147 - .ok_or_else(|| { 148 - RepoError::invalid_commit( 149 - "update operation missing prev field for v1.1 validation", 150 - ) 151 - })? 152 - .to_ipld() 153 - .map_err(|e| RepoError::invalid_cid_conversion(e, "update prev"))?; 154 - 155 - Ok(VerifiedWriteOp::Update { key, cid, prev }) 156 - } 157 - "delete" => { 158 - let prev = self 159 - .prev 160 - .as_ref() 161 - .ok_or_else(|| { 162 - RepoError::invalid_commit( 163 - "delete operation missing prev field for v1.1 validation", 164 - ) 165 - })? 166 - .to_ipld() 167 - .map_err(|e| RepoError::invalid_cid_conversion(e, "delete prev"))?; 15 + /// Validates that all required fields are present for inversion. 16 + pub fn to_invertible_op(op: &RepoOp<'_>) -> Result<VerifiedWriteOp> { 17 + let key = op.path.to_smolstr(); 18 + match op.action { 19 + RepoOpAction::Create => { 20 + let cid = op 21 + .cid 22 + .as_ref() 23 + .ok_or_else(|| RepoError::invalid_commit("create operation missing cid field"))? 24 + .to_ipld() 25 + .map_err(|e| RepoError::invalid_cid_conversion(e, "create cid"))?; 168 26 169 - Ok(VerifiedWriteOp::Delete { key, prev }) 170 - } 171 - action => Err(RepoError::invalid_commit(format!( 172 - "unknown action type: {}", 173 - action 174 - ))), 27 + Ok(VerifiedWriteOp::Create { key, cid }) 175 28 } 176 - } 177 - } 29 + RepoOpAction::Update => { 30 + let cid = op 31 + .cid 32 + .as_ref() 33 + .ok_or_else(|| RepoError::invalid_commit("update operation missing cid field"))? 34 + .to_ipld() 35 + .map_err(|e| RepoError::invalid_cid_conversion(e, "update cid"))?; 178 36 179 - impl IntoStatic for FirehoseCommit<'_> { 180 - type Output = FirehoseCommit<'static>; 37 + let prev = op 38 + .prev 39 + .as_ref() 40 + .ok_or_else(|| { 41 + RepoError::invalid_commit( 42 + "update operation missing prev field for v1.1 validation", 43 + ) 44 + })? 45 + .to_ipld() 46 + .map_err(|e| RepoError::invalid_cid_conversion(e, "update prev"))?; 181 47 182 - fn into_static(self) -> Self::Output { 183 - FirehoseCommit { 184 - repo: self.repo.into_static(), 185 - rev: self.rev, 186 - seq: self.seq, 187 - since: self.since, 188 - time: self.time, 189 - commit: self.commit.into_static(), 190 - blocks: self.blocks, 191 - ops: self.ops.into_iter().map(|op| op.into_static()).collect(), 192 - prev_data: self.prev_data.map(|pd| pd.into_static()), 193 - blobs: self.blobs.into_iter().map(|b| b.into_static()).collect(), 194 - too_big: self.too_big, 195 - rebase: self.rebase, 48 + Ok(VerifiedWriteOp::Update { key, cid, prev }) 196 49 } 197 - } 198 - } 199 - 200 - impl IntoStatic for RepoOp<'_> { 201 - type Output = RepoOp<'static>; 50 + RepoOpAction::Delete => { 51 + let prev = op 52 + .prev 53 + .as_ref() 54 + .ok_or_else(|| { 55 + RepoError::invalid_commit( 56 + "delete operation missing prev field for v1.1 validation", 57 + ) 58 + })? 59 + .to_ipld() 60 + .map_err(|e| RepoError::invalid_cid_conversion(e, "delete prev"))?; 202 61 203 - fn into_static(self) -> Self::Output { 204 - RepoOp { 205 - action: self.action.into_static(), 206 - path: self.path.into_static(), 207 - cid: self.cid.into_static(), 208 - prev: self.prev.map(|p| p.into_static()), 62 + Ok(VerifiedWriteOp::Delete { key, prev }) 209 63 } 64 + RepoOpAction::Other(ref action) => Err(RepoError::invalid_commit(format!( 65 + "unknown action type: {}", 66 + action 67 + ))), 210 68 } 211 69 } 212 70 ··· 220 78 use cid::Cid as IpldCid; 221 79 use std::sync::Arc; 222 80 223 - impl<'a> FirehoseCommit<'a> { 224 - /// Validate a sync v1.0 commit 225 - /// 226 - /// **Requirements:** 227 - /// - Must have previous MST state (potentially full repository) 228 - /// - All blocks needed for validation must be in `self.blocks` 229 - /// 230 - /// **Validation steps:** 231 - /// 1. Parse CAR blocks from `self.blocks` into temporary storage 232 - /// 2. Load commit object and verify signature 233 - /// 3. Apply operations to previous MST (using temporary storage for new blocks) 234 - /// 4. Verify result matches commit.data (new MST root) 235 - /// 236 - /// Returns the new MST root CID on success. 237 - pub async fn validate_v1_0<S: BlockStore + Sync + 'static>( 238 - &self, 239 - prev_mst_root: Option<IpldCid>, 240 - prev_storage: Arc<S>, 241 - pubkey: &PublicKey<'_>, 242 - ) -> Result<IpldCid> { 243 - // 1. Parse CAR blocks from the firehose message into temporary storage 244 - let parsed = parse_car_bytes(&self.blocks).await?; 245 - let temp_storage = MemoryBlockStore::new_from_blocks(parsed.blocks); 81 + /// Validate a sync v1.0 commit 82 + /// 83 + /// **Requirements:** 84 + /// - Must have previous MST state (potentially full repository) 85 + /// - All blocks needed for validation must be in `self.blocks` 86 + /// 87 + /// **Validation steps:** 88 + /// 1. Parse CAR blocks from `self.blocks` into temporary storage 89 + /// 2. Load commit object and verify signature 90 + /// 3. Apply operations to previous MST (using temporary storage for new blocks) 91 + /// 4. Verify result matches commit.data (new MST root) 92 + /// 93 + /// Returns the new MST root CID on success. 94 + pub async fn validate_v1_0<S: BlockStore + Sync + 'static>( 95 + fh_commit: &Commit<'_>, 96 + prev_mst_root: Option<IpldCid>, 97 + prev_storage: Arc<S>, 98 + pubkey: &PublicKey<'_>, 99 + ) -> Result<IpldCid> { 100 + // 1. Parse CAR blocks from the firehose message into temporary storage 101 + let parsed = parse_car_bytes(&fh_commit.blocks).await?; 102 + let temp_storage = MemoryBlockStore::new_from_blocks(parsed.blocks); 246 103 247 - // 2. Create layered storage: reads from temp first, then prev; writes to temp only 248 - // This avoids copying all previous MST blocks 249 - let layered_storage = LayeredBlockStore::new(temp_storage.clone(), prev_storage); 104 + // 2. Create layered storage: reads from temp first, then prev; writes to temp only 105 + // This avoids copying all previous MST blocks 106 + let layered_storage = LayeredBlockStore::new(temp_storage.clone(), prev_storage); 250 107 251 - // 3. Extract and verify commit object from temporary storage 252 - let commit_cid: IpldCid = self 253 - .commit 254 - .to_ipld() 255 - .map_err(|e| RepoError::invalid_cid_conversion(e, "commit CID"))?; 256 - let commit_bytes = temp_storage 257 - .get(&commit_cid) 258 - .await? 259 - .ok_or_else(|| RepoError::not_found("commit block", &commit_cid))?; 108 + // 3. Extract and verify commit object from temporary storage 109 + let commit_cid: IpldCid = fh_commit 110 + .commit 111 + .to_ipld() 112 + .map_err(|e| RepoError::invalid_cid_conversion(e, "commit CID"))?; 113 + let commit_bytes = temp_storage 114 + .get(&commit_cid) 115 + .await? 116 + .ok_or_else(|| RepoError::not_found("commit block", &commit_cid))?; 260 117 261 - let commit = super::Commit::from_cbor(&commit_bytes)?; 118 + let commit = super::Commit::from_cbor(&commit_bytes)?; 262 119 263 - // Verify DID matches 264 - if commit.did().as_ref() != self.repo.as_ref() { 265 - return Err(RepoError::invalid_commit(format!( 120 + // Verify DID matches 121 + if commit.did().as_ref() != fh_commit.repo.as_ref() { 122 + return Err(RepoError::invalid_commit(format!( 266 123 "DID mismatch: commit has {}, message has {}", 267 124 commit.did(), 268 - self.repo 125 + fh_commit.repo 269 126 )) 270 127 .with_help("DID mismatch indicates the commit was signed by a different identity - verify the commit is from the expected repository")); 271 - } 272 - 273 - // Verify signature 274 - commit.verify(pubkey)?; 128 + } 275 129 276 - let layered_arc = Arc::new(layered_storage); 130 + // Verify signature 131 + commit.verify(pubkey)?; 277 132 278 - // 4. Load previous MST state from layered storage (or start empty) 279 - let prev_mst = if let Some(prev_root) = prev_mst_root { 280 - Mst::load(layered_arc.clone(), prev_root, None) 281 - } else { 282 - Mst::new(layered_arc.clone()) 283 - }; 133 + let layered_arc = Arc::new(layered_storage); 284 134 285 - // 5. Load new MST from commit.data (claimed result) 286 - let expected_root = *commit.data(); 287 - let new_mst = Mst::load(layered_arc, expected_root, None); 135 + // 4. Load previous MST state from layered storage (or start empty) 136 + let prev_mst = if let Some(prev_root) = prev_mst_root { 137 + Mst::load(layered_arc.clone(), prev_root, None) 138 + } else { 139 + Mst::new(layered_arc.clone()) 140 + }; 288 141 289 - // 6. Compute diff to get verified write ops (with actual prev values from tree state) 290 - let diff = prev_mst.diff(&new_mst).await?; 291 - let verified_ops = diff.to_verified_ops(); 142 + // 5. Load new MST from commit.data (claimed result) 143 + let expected_root = *commit.data(); 144 + let new_mst = Mst::load(layered_arc, expected_root, None); 292 145 293 - // 7. Apply verified ops to prev MST 294 - let computed_mst = prev_mst.batch(&verified_ops).await?; 146 + // 6. Compute diff to get verified write ops (with actual prev values from tree state) 147 + let diff = prev_mst.diff(&new_mst).await?; 148 + let verified_ops = diff.to_verified_ops(); 295 149 296 - // 8. Verify computed result matches claimed result 297 - let computed_root = computed_mst.get_pointer().await?; 150 + // 7. Apply verified ops to prev MST 151 + let computed_mst = prev_mst.batch(&verified_ops).await?; 298 152 299 - if computed_root != expected_root { 300 - return Err(RepoError::cid_mismatch(format!( 301 - "MST root mismatch: expected {}, got {}", 302 - expected_root, computed_root 303 - ))); 304 - } 153 + // 8. Verify computed result matches claimed result 154 + let computed_root = computed_mst.get_pointer().await?; 305 155 306 - Ok(expected_root) 156 + if computed_root != expected_root { 157 + return Err(RepoError::cid_mismatch(format!( 158 + "MST root mismatch: expected {}, got {}", 159 + expected_root, computed_root 160 + ))); 307 161 } 308 162 309 - /// Validate a sync v1.1 commit (inductive validation) 310 - /// 311 - /// **Requirements:** 312 - /// - `self.prev_data` must be Some (contains previous MST root) 313 - /// - All blocks needed for validation must be in `self.blocks` 314 - /// 315 - /// **Validation steps:** 316 - /// 1. Parse CAR blocks from `self.blocks` into temporary storage 317 - /// 2. Load commit object and verify signature 318 - /// 3. Start from `prev_data` MST root (loaded from temp storage) 319 - /// 4. Apply operations (with prev CID validation for updates/deletes) 320 - /// 5. Verify result matches commit.data (new MST root) 321 - /// 322 - /// Returns the new MST root CID on success. 323 - /// 324 - /// **Inductive property:** Can validate without any external state besides the blocks 325 - /// in this message. The `prev_data` field provides the starting MST root, and operations 326 - /// include `prev` CIDs for validation. All necessary blocks must be in the CAR bytes. 327 - /// 328 - /// Note: Because this uses the same merkle search tree struct as the repository itself, 329 - /// this is far from the most efficient possible validation function possible. The repo 330 - /// tree struct carries extra information. However, 331 - /// it has the virtue of making everything self-validating. 332 - pub async fn validate_v1_1(&self, pubkey: &PublicKey<'_>) -> Result<IpldCid> { 333 - // 1. Require prev_data for v1.1 334 - let prev_data_cid: IpldCid = self 335 - .prev_data 336 - .as_ref() 337 - .ok_or_else(|| { 338 - RepoError::invalid_commit("Sync v1.1 validation requires prev_data field") 339 - })? 340 - .to_ipld() 341 - .map_err(|e| RepoError::invalid_cid_conversion(e, "prev_data CID"))?; 163 + Ok(expected_root) 164 + } 165 + 166 + /// Validate a sync v1.1 commit (inductive validation) 167 + /// 168 + /// **Requirements:** 169 + /// - `self.prev_data` must be Some (contains previous MST root) 170 + /// - All blocks needed for validation must be in `self.blocks` 171 + /// 172 + /// **Validation steps:** 173 + /// 1. Parse CAR blocks from `self.blocks` into temporary storage 174 + /// 2. Load commit object and verify signature 175 + /// 3. Start from `prev_data` MST root (loaded from temp storage) 176 + /// 4. Apply operations (with prev CID validation for updates/deletes) 177 + /// 5. Verify result matches commit.data (new MST root) 178 + /// 179 + /// Returns the new MST root CID on success. 180 + /// 181 + /// **Inductive property:** Can validate without any external state besides the blocks 182 + /// in this message. The `prev_data` field provides the starting MST root, and operations 183 + /// include `prev` CIDs for validation. All necessary blocks must be in the CAR bytes. 184 + /// 185 + /// Note: Because this uses the same merkle search tree struct as the repository itself, 186 + /// this is far from the most efficient possible validation function possible. The repo 187 + /// tree struct carries extra information. However, 188 + /// it has the virtue of making everything self-validating. 189 + pub async fn validate_v1_1(fh_commit: &Commit<'_>, pubkey: &PublicKey<'_>) -> Result<IpldCid> { 190 + // 1. Require prev_data for v1.1 191 + let prev_data_cid: IpldCid = fh_commit 192 + .prev_data 193 + .as_ref() 194 + .ok_or_else(|| RepoError::invalid_commit("Sync v1.1 validation requires prev_data field"))? 195 + .to_ipld() 196 + .map_err(|e| RepoError::invalid_cid_conversion(e, "prev_data CID"))?; 342 197 343 - // 2. Parse CAR blocks from the firehose message into temporary storage 344 - let parsed = parse_car_bytes(&self.blocks).await?; 198 + // 2. Parse CAR blocks from the firehose message into temporary storage 199 + let parsed = parse_car_bytes(&fh_commit.blocks).await?; 345 200 346 - let temp_storage = Arc::new(MemoryBlockStore::new_from_blocks(parsed.blocks)); 201 + let temp_storage = Arc::new(MemoryBlockStore::new_from_blocks(parsed.blocks)); 347 202 348 - // 3. Extract and verify commit object from temporary storage 349 - let commit_cid: IpldCid = self 350 - .commit 351 - .to_ipld() 352 - .map_err(|e| RepoError::invalid_cid_conversion(e, "commit CID"))?; 353 - let commit_bytes = temp_storage 354 - .get(&commit_cid) 355 - .await? 356 - .ok_or_else(|| RepoError::not_found("commit block", &commit_cid))?; 203 + // 3. Extract and verify commit object from temporary storage 204 + let commit_cid: IpldCid = fh_commit 205 + .commit 206 + .to_ipld() 207 + .map_err(|e| RepoError::invalid_cid_conversion(e, "commit CID"))?; 208 + let commit_bytes = temp_storage 209 + .get(&commit_cid) 210 + .await? 211 + .ok_or_else(|| RepoError::not_found("commit block", &commit_cid))?; 357 212 358 - let commit = super::Commit::from_cbor(&commit_bytes)?; 213 + let commit = super::Commit::from_cbor(&commit_bytes)?; 359 214 360 - // Verify DID matches 361 - if commit.did().as_ref() != self.repo.as_ref() { 362 - return Err(RepoError::invalid_commit(format!( 215 + // Verify DID matches 216 + if commit.did().as_ref() != fh_commit.repo.as_ref() { 217 + return Err(RepoError::invalid_commit(format!( 363 218 "DID mismatch: commit has {}, message has {}", 364 219 commit.did(), 365 - self.repo 220 + fh_commit.repo 366 221 )) 367 222 .with_help("DID mismatch indicates the commit was signed by a different identity - verify the commit is from the expected repository")); 368 - } 223 + } 369 224 370 - // Verify signature 371 - commit.verify(pubkey)?; 225 + // Verify signature 226 + commit.verify(pubkey)?; 372 227 373 - // 5. Load new MST from commit.data (claimed result) 374 - let expected_root = *commit.data(); 228 + // 5. Load new MST from commit.data (claimed result) 229 + let expected_root = *commit.data(); 375 230 376 - let mut new_mst = Mst::load(temp_storage, expected_root, None); 231 + let mut new_mst = Mst::load(temp_storage, expected_root, None); 377 232 378 - let verified_ops = self 379 - .ops 380 - .iter() 381 - .filter_map(|op| op.to_invertible_op().ok()) 382 - .collect::<Vec<_>>(); 383 - if verified_ops.len() != self.ops.len() { 384 - return Err(RepoError::invalid_commit(format!( 385 - "Invalid commit: expected {} ops, got {}", 386 - self.ops.len(), 387 - verified_ops.len() 388 - ))); 389 - } 233 + let verified_ops = fh_commit 234 + .ops 235 + .iter() 236 + .filter_map(|op| to_invertible_op(op).ok()) 237 + .collect::<Vec<_>>(); 238 + if verified_ops.len() != fh_commit.ops.len() { 239 + return Err(RepoError::invalid_commit(format!( 240 + "Invalid commit: expected {} ops, got {}", 241 + fh_commit.ops.len(), 242 + verified_ops.len() 243 + ))); 244 + } 390 245 391 - for op in verified_ops { 392 - if let Ok(inverted) = new_mst.invert_op(op.clone()).await { 393 - if !inverted { 394 - return Err(RepoError::invalid_commit(format!( 395 - "Invalid commit: op {:?} is not invertible", 396 - op 397 - ))); 398 - } 246 + for op in verified_ops { 247 + if let Ok(inverted) = new_mst.invert_op(op.clone()).await { 248 + if !inverted { 249 + return Err(RepoError::invalid_commit(format!( 250 + "Invalid commit: op {:?} is not invertible", 251 + op 252 + ))); 399 253 } 400 254 } 401 - // 8. Verify computed previous state matches claimed previous state 402 - let computed_root = new_mst.get_pointer().await?; 255 + } 256 + // 8. Verify computed previous state matches claimed previous state 257 + let computed_root = new_mst.get_pointer().await?; 403 258 404 - if computed_root != prev_data_cid { 405 - return Err(RepoError::cid_mismatch(format!( 406 - "MST root mismatch: expected {}, got {}", 407 - prev_data_cid, computed_root 408 - ))); 409 - } 259 + if computed_root != prev_data_cid { 260 + return Err(RepoError::cid_mismatch(format!( 261 + "MST root mismatch: expected {}, got {}", 262 + prev_data_cid, computed_root 263 + ))); 264 + } 410 265 411 - Ok(expected_root) 412 - } 266 + Ok(expected_root) 413 267 } 414 268 415 269 #[cfg(test)] ··· 419 273 use crate::commit::Commit; 420 274 use crate::mst::{Mst, RecordWriteOp}; 421 275 use crate::storage::MemoryBlockStore; 276 + use jacquard_common::IntoStatic; 422 277 use jacquard_common::types::crypto::{KeyCodec, PublicKey}; 278 + use jacquard_common::types::did::Did; 423 279 use jacquard_common::types::recordkey::Rkey; 424 - use jacquard_common::types::string::{Nsid, RecordKey}; 280 + use jacquard_common::types::string::{Datetime, Nsid, RecordKey}; 425 281 use jacquard_common::types::tid::Ticker; 426 282 use jacquard_common::types::value::RawData; 427 283 use smol_str::SmolStr; ··· 507 363 .unwrap(); 508 364 509 365 // Validate using v1.1 validation 510 - let result = firehose_commit.validate_v1_1(&pubkey).await; 366 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 511 367 if let Err(ref e) = result { 512 368 eprintln!("Validation error: {}", e); 513 369 } ··· 560 416 firehose_commit.prev_data = None; 561 417 562 418 // Validate using v1.0 validation with previous storage 563 - let result = firehose_commit 564 - .validate_v1_0(Some(prev_root), storage.clone(), &pubkey) 565 - .await; 419 + let result = 420 + validate_v1_0(&firehose_commit, Some(prev_root), storage.clone(), &pubkey).await; 566 421 567 422 assert!(result.is_ok(), "Valid v1.0 commit should pass validation"); 568 423 ··· 612 467 .await 613 468 .unwrap(); 614 469 615 - let result = firehose_commit.validate_v1_1(&pubkey).await; 470 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 616 471 assert!(result.is_ok(), "Multiple creates should validate"); 617 472 } 618 473 ··· 685 540 .await 686 541 .unwrap(); 687 542 688 - let result = firehose_commit.validate_v1_1(&pubkey).await; 543 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 689 544 assert!( 690 545 result.is_ok(), 691 546 "Update and delete operations should validate" ··· 740 595 741 596 firehose_commit.blocks = bad_car.into(); 742 597 743 - let result = firehose_commit.validate_v1_1(&pubkey).await; 598 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 744 599 assert!( 745 600 result.is_err(), 746 601 "Validation should fail when commit block is missing" ··· 802 657 803 658 firehose_commit.blocks = bad_car.into(); 804 659 805 - let result = firehose_commit.validate_v1_1(&pubkey).await; 660 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 806 661 assert!( 807 662 result.is_err(), 808 663 "Validation should fail when MST blocks are missing" ··· 863 718 .await 864 719 .unwrap(); 865 720 866 - let result = firehose_commit.validate_v1_1(&pubkey).await; 721 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 867 722 assert!( 868 723 result.is_err(), 869 724 "Validation should fail when commit has wrong MST root" ··· 905 760 906 761 firehose_commit.repo = wrong_did; 907 762 908 - let result = firehose_commit.validate_v1_1(&pubkey).await; 763 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 909 764 assert!( 910 765 result.is_err(), 911 766 "Validation should fail with mismatched DID" ··· 952 807 .await 953 808 .unwrap(); 954 809 955 - let result = firehose_commit.validate_v1_1(&wrong_pubkey).await; 810 + let result = validate_v1_1(&firehose_commit, &wrong_pubkey).await; 956 811 assert!( 957 812 result.is_err(), 958 813 "Validation should fail with wrong public key" ··· 993 848 // Strip prev_data to make it invalid for v1.1 994 849 firehose_commit.prev_data = None; 995 850 996 - let result = firehose_commit.validate_v1_1(&pubkey).await; 851 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 997 852 assert!( 998 853 result.is_err(), 999 854 "v1.1 validation should fail without prev_data" ··· 1040 895 // Use wrong prev_data CID (point to commit instead of MST root) 1041 896 firehose_commit.prev_data = Some(firehose_commit.commit.clone()); 1042 897 1043 - let result = firehose_commit.validate_v1_1(&pubkey).await; 898 + let result = validate_v1_1(&firehose_commit, &pubkey).await; 1044 899 assert!( 1045 900 result.is_err(), 1046 901 "Validation should fail with wrong prev_data CID"
+19 -7
crates/jacquard-repo/src/mst/diff.rs
··· 170 170 path: key.as_str().into(), 171 171 cid: Some(CidLink::from(*cid)), 172 172 prev: None, 173 + extra_data: None, 173 174 }); 174 175 } 175 176 ··· 180 181 path: key.as_str().into(), 181 182 cid: Some(CidLink::from(*new_cid)), 182 183 prev: Some(CidLink::from(*old_cid)), 184 + extra_data: None, 183 185 }); 184 186 } 185 187 ··· 190 192 path: key.as_str().into(), 191 193 cid: None, // null for deletes 192 194 prev: Some(CidLink::from(*old_cid)), 195 + extra_data: None, 193 196 }); 194 197 } 195 198 ··· 220 223 // Remove duplicate blocks: nodes that appear in both new_mst_blocks and removed_mst_blocks 221 224 // are unchanged nodes that were traversed during the diff but shouldn't be counted as created/deleted. 222 225 // This happens when we step into subtrees with different parent CIDs but encounter identical child nodes. 223 - let created_set: std::collections::HashSet<_> = diff.new_mst_blocks.keys().copied().collect(); 224 - let removed_set: std::collections::HashSet<_> = diff.removed_mst_blocks.iter().copied().collect(); 225 - let duplicates: std::collections::HashSet<_> = created_set.intersection(&removed_set).copied().collect(); 226 + let created_set: std::collections::HashSet<_> = 227 + diff.new_mst_blocks.keys().copied().collect(); 228 + let removed_set: std::collections::HashSet<_> = 229 + diff.removed_mst_blocks.iter().copied().collect(); 230 + let duplicates: std::collections::HashSet<_> = 231 + created_set.intersection(&removed_set).copied().collect(); 226 232 227 - diff.new_mst_blocks.retain(|cid, _| !duplicates.contains(cid)); 228 - diff.removed_mst_blocks.retain(|cid| !duplicates.contains(cid)); 233 + diff.new_mst_blocks 234 + .retain(|cid, _| !duplicates.contains(cid)); 235 + diff.removed_mst_blocks 236 + .retain(|cid| !duplicates.contains(cid)); 229 237 230 238 Ok(diff) 231 239 } ··· 420 428 // Serialize the MST node 421 429 let entries = tree.get_entries().await?; 422 430 let node_data = serialize_node_data(&entries).await?; 423 - let cbor = serde_ipld_dagcbor::to_vec(&node_data) 424 - .map_err(|e| RepoError::serialization(e).with_context(format!("serializing MST node for diff tracking: {}", tree_cid)))?; 431 + let cbor = serde_ipld_dagcbor::to_vec(&node_data).map_err(|e| { 432 + RepoError::serialization(e).with_context(format!( 433 + "serializing MST node for diff tracking: {}", 434 + tree_cid 435 + )) 436 + })?; 425 437 426 438 // Track the serialized block 427 439 diff.new_mst_blocks.insert(tree_cid, Bytes::from(cbor));
+2 -1
crates/jacquard-repo/src/repo.rs
··· 82 82 repo: repo.clone().into_static(), 83 83 rev: self.rev.clone(), 84 84 seq, 85 - since: self.since.clone().unwrap_or_else(|| self.rev.clone()), 85 + since: Some(self.since.clone().unwrap_or_else(|| self.rev.clone())), 86 86 time, 87 87 commit: CidLink::from(self.cid), 88 88 blocks: blocks_car.into(), ··· 91 91 blobs, 92 92 too_big: false, 93 93 rebase: false, 94 + extra_data: None, 94 95 }) 95 96 } 96 97 }
+6 -9
crates/jacquard-repo/tests/large_proof_tests.rs
··· 10 10 use jacquard_common::types::value::RawData; 11 11 use jacquard_repo::Repository; 12 12 use jacquard_repo::car::read_car_header; 13 + use jacquard_repo::commit::firehose::validate_v1_1; 13 14 use jacquard_repo::mst::RecordWriteOp; 14 15 use jacquard_repo::storage::{BlockStore, MemoryBlockStore}; 15 16 use rand::Rng; ··· 224 225 .await 225 226 .unwrap(); 226 227 227 - firehose_commit 228 - .validate_v1_1(&pubkey) 228 + validate_v1_1(&firehose_commit, &pubkey) 229 229 .await 230 230 .expect("Initial batch should validate"); 231 231 ··· 266 266 .await 267 267 .unwrap(); 268 268 269 - firehose_commit 270 - .validate_v1_1(&pubkey) 269 + validate_v1_1(&firehose_commit, &pubkey) 271 270 .await 272 271 .unwrap_or_else(|e| { 273 272 eprintln!( ··· 336 335 .await 337 336 .unwrap(); 338 337 339 - firehose_commit.validate_v1_1(&pubkey).await.unwrap(); 338 + validate_v1_1(&firehose_commit, &pubkey).await.unwrap(); 340 339 341 340 for batch_num in 1..=5000 { 342 341 let batch_size = rng.gen_range(1..=20); ··· 355 354 .await 356 355 .unwrap(); 357 356 358 - firehose_commit 359 - .validate_v1_1(&pubkey) 357 + validate_v1_1(&firehose_commit, &pubkey) 360 358 .await 361 359 .unwrap_or_else(|e| { 362 360 panic!( ··· 441 439 .await 442 440 .unwrap(); 443 441 444 - firehose_commit 445 - .validate_v1_1(&pubkey) 442 + validate_v1_1(&firehose_commit, &pubkey) 446 443 .await 447 444 .unwrap_or_else(|e| panic!("Fixture validation failed at batch {}: {}", batch_num, e)); 448 445 }