High-performance implementation of plcbundle written in Rust
at main 890 lines 32 kB view raw
1//! Bundle and chain verification: fast metadata checks, compressed/content hash validation, operation count checks, and parent/cursor linkage 2// src/verification.rs 3use crate::bundle_format; 4use crate::constants; 5use crate::index::BundleMetadata; 6use crate::index::Index; 7use crate::manager::{ChainVerifyResult, ChainVerifySpec, VerifyResult, VerifySpec}; 8use anyhow::Result; 9use std::fs::File; 10use std::io::Read; 11use std::path::Path; 12 13pub fn verify_bundle( 14 directory: &Path, 15 metadata: &BundleMetadata, 16 spec: VerifySpec, 17) -> Result<VerifyResult> { 18 let mut errors = Vec::new(); 19 20 let bundle_path = constants::bundle_path(directory, metadata.bundle_number); 21 22 if !bundle_path.exists() { 23 errors.push(format!("Bundle file not found: {:?}", bundle_path)); 24 return Ok(VerifyResult { 25 valid: false, 26 errors, 27 }); 28 } 29 30 // Fast mode: only check metadata frame, skip all hash calculations 31 if spec.fast { 32 match bundle_format::extract_metadata_from_file(&bundle_path) { 33 Ok(file_metadata) => { 34 // Verify key fields match index metadata 35 if file_metadata.bundle_number != metadata.bundle_number { 36 errors.push(format!( 37 "Metadata bundle number mismatch: expected {}, got {}", 38 metadata.bundle_number, file_metadata.bundle_number 39 )); 40 } 41 42 if file_metadata.content_hash != metadata.content_hash { 43 errors.push(format!( 44 "Metadata content hash mismatch: expected {}, got {}", 45 metadata.content_hash, file_metadata.content_hash 46 )); 47 } 48 49 if file_metadata.operation_count != metadata.operation_count as usize { 50 errors.push(format!( 51 "Metadata operation count mismatch: expected {}, got {}", 52 metadata.operation_count, file_metadata.operation_count 53 )); 54 } 55 56 // Check parent hash if present 57 if let Some(ref parent_hash) = file_metadata.parent_hash { 58 if !metadata.parent.is_empty() && parent_hash != &metadata.parent { 59 errors.push(format!( 60 "Metadata parent hash mismatch: expected {}, got {}", 61 metadata.parent, parent_hash 62 )); 63 } 64 } else if !metadata.parent.is_empty() { 65 errors.push(format!( 66 "Metadata missing parent hash (expected {})", 67 metadata.parent 68 )); 69 } 70 } 71 Err(_) => { 72 // Metadata frame doesn't exist - that's okay, it's optional 73 // In fast mode, we can't verify without metadata, so we report it 74 // but don't fail (as per user request: "otherwise no change") 75 } 76 } 77 78 return Ok(VerifyResult { 79 valid: errors.is_empty(), 80 errors, 81 }); 82 } 83 84 // Optimize: do both hashes efficiently in a single pass 85 if spec.check_hash && spec.check_content_hash { 86 use sha2::{Digest, Sha256}; 87 use std::io::{BufReader, Read}; 88 89 // Read entire file once into memory (like Go does) 90 let file_data = std::fs::read(&bundle_path)?; 91 92 // Hash compressed file (entire file) 93 let mut comp_hasher = Sha256::new(); 94 comp_hasher.update(&file_data); 95 let comp_hash = format!("{:x}", comp_hasher.finalize()); 96 97 // For content hash, skip metadata if present, then decompress 98 let mut content_start = 0; 99 if file_data.len() >= 4 { 100 let magic = 101 u32::from_le_bytes([file_data[0], file_data[1], file_data[2], file_data[3]]); 102 if magic == bundle_format::SKIPPABLE_MAGIC_METADATA && file_data.len() >= 8 { 103 let frame_size = 104 u32::from_le_bytes([file_data[4], file_data[5], file_data[6], file_data[7]]) 105 as usize; 106 content_start = 8 + frame_size; // Skip magic(4) + size(4) + data 107 } 108 } 109 110 // Use streaming decompression with hashing (more memory efficient) 111 let compressed_data = &file_data[content_start..]; 112 let decoder = zstd::Decoder::new(compressed_data)?; 113 let mut reader = BufReader::with_capacity(256 * 1024, decoder); // 256KB buffer 114 115 let mut content_hasher = Sha256::new(); 116 let mut buf = vec![0u8; 64 * 1024]; // 64KB read buffer 117 loop { 118 let n = reader.read(&mut buf)?; 119 if n == 0 { 120 break; 121 } 122 content_hasher.update(&buf[..n]); 123 } 124 let content_hash = format!("{:x}", content_hasher.finalize()); 125 126 if comp_hash != metadata.compressed_hash { 127 errors.push(format!( 128 "Compressed hash mismatch: expected {}, got {}", 129 metadata.compressed_hash, comp_hash 130 )); 131 } 132 133 if content_hash != metadata.content_hash { 134 errors.push(format!( 135 "Content hash mismatch: expected {}, got {}", 136 metadata.content_hash, content_hash 137 )); 138 } 139 } else if spec.check_hash { 140 // Only compressed hash - fast path with streaming 141 use sha2::{Digest, Sha256}; 142 use std::io::BufReader; 143 let file = File::open(&bundle_path)?; 144 let mut reader = BufReader::with_capacity(64 * 1024, file); // 64KB buffer 145 let mut hasher = Sha256::new(); 146 std::io::copy(&mut reader, &mut hasher)?; 147 let hash = format!("{:x}", hasher.finalize()); 148 149 if hash != metadata.compressed_hash { 150 errors.push(format!( 151 "Compressed hash mismatch: expected {}, got {}", 152 metadata.compressed_hash, hash 153 )); 154 } 155 } else if spec.check_content_hash { 156 // Only content hash 157 use sha2::{Digest, Sha256}; 158 use std::io::{BufReader, Seek, SeekFrom}; 159 let file = File::open(&bundle_path)?; 160 let mut reader = BufReader::new(file); 161 162 // Skip metadata frame if present 163 let mut magic_buf = [0u8; 4]; 164 if reader.read_exact(&mut magic_buf).is_ok() { 165 let magic = u32::from_le_bytes(magic_buf); 166 if magic == bundle_format::SKIPPABLE_MAGIC_METADATA { 167 let mut size_buf = [0u8; 4]; 168 reader.read_exact(&mut size_buf)?; 169 let frame_size = u32::from_le_bytes(size_buf); 170 let mut skip_buf = vec![0u8; frame_size as usize]; 171 reader.read_exact(&mut skip_buf)?; 172 } else { 173 reader.seek(SeekFrom::Start(0))?; 174 } 175 } else { 176 reader.seek(SeekFrom::Start(0))?; 177 } 178 179 let mut decoder = zstd::Decoder::new(reader)?; 180 let mut content = Vec::new(); 181 decoder.read_to_end(&mut content)?; 182 183 let mut hasher = Sha256::new(); 184 hasher.update(&content); 185 let hash = format!("{:x}", hasher.finalize()); 186 187 if hash != metadata.content_hash { 188 errors.push(format!( 189 "Content hash mismatch: expected {}, got {}", 190 metadata.content_hash, hash 191 )); 192 } 193 } 194 195 if spec.check_operations { 196 // Verify operation count 197 // Skip metadata frame if present 198 let mut file = File::open(&bundle_path)?; 199 200 // Try to skip metadata frame 201 let mut magic_buf = [0u8; 4]; 202 if file.read_exact(&mut magic_buf).is_ok() { 203 let magic = u32::from_le_bytes(magic_buf); 204 if magic == bundle_format::SKIPPABLE_MAGIC_METADATA { 205 // Skip metadata frame 206 let mut size_buf = [0u8; 4]; 207 file.read_exact(&mut size_buf)?; 208 let frame_size = u32::from_le_bytes(size_buf); 209 let mut skip_buf = vec![0u8; frame_size as usize]; 210 file.read_exact(&mut skip_buf)?; 211 } else { 212 // No metadata frame, seek back to start 213 use std::io::Seek; 214 file.seek(std::io::SeekFrom::Start(0))?; 215 } 216 } else { 217 // File read error, seek back 218 use std::io::Seek; 219 file.seek(std::io::SeekFrom::Start(0))?; 220 } 221 222 let decoder = zstd::Decoder::new(file)?; 223 let reader = std::io::BufReader::new(decoder); 224 use std::io::BufRead; 225 226 let count = reader 227 .lines() 228 .map_while(Result::ok) 229 .filter(|l| !l.is_empty()) 230 .count(); 231 232 if count != metadata.operation_count as usize { 233 errors.push(format!( 234 "Operation count mismatch: expected {}, got {}", 235 metadata.operation_count, count 236 )); 237 } 238 } 239 240 // Verify metadata in skippable frame (if present) 241 // This is optional - some bundles may not have metadata frame 242 match bundle_format::extract_metadata_from_file(&bundle_path) { 243 Ok(file_metadata) => { 244 // Verify key fields match index metadata 245 if file_metadata.bundle_number != metadata.bundle_number { 246 errors.push(format!( 247 "Metadata bundle number mismatch: expected {}, got {}", 248 metadata.bundle_number, file_metadata.bundle_number 249 )); 250 } 251 252 if file_metadata.content_hash != metadata.content_hash { 253 errors.push(format!( 254 "Metadata content hash mismatch: expected {}, got {}", 255 metadata.content_hash, file_metadata.content_hash 256 )); 257 } 258 259 if file_metadata.operation_count != metadata.operation_count as usize { 260 errors.push(format!( 261 "Metadata operation count mismatch: expected {}, got {}", 262 metadata.operation_count, file_metadata.operation_count 263 )); 264 } 265 266 // Check parent hash if present 267 if let Some(ref parent_hash) = file_metadata.parent_hash { 268 if !metadata.parent.is_empty() && parent_hash != &metadata.parent { 269 errors.push(format!( 270 "Metadata parent hash mismatch: expected {}, got {}", 271 metadata.parent, parent_hash 272 )); 273 } 274 } else if !metadata.parent.is_empty() { 275 errors.push(format!( 276 "Metadata missing parent hash (expected {})", 277 metadata.parent 278 )); 279 } 280 } 281 Err(_) => { 282 // Metadata frame doesn't exist - that's okay, it's optional for legacy bundles 283 // No error is added, verification continues 284 } 285 } 286 287 Ok(VerifyResult { 288 valid: errors.is_empty(), 289 errors, 290 }) 291} 292 293pub fn verify_chain( 294 _directory: &Path, // Add underscore prefix 295 index: &Index, 296 spec: ChainVerifySpec, 297) -> Result<ChainVerifyResult> { 298 let end = spec.end_bundle.unwrap_or(index.last_bundle); 299 let mut errors = Vec::new(); 300 let mut bundles_checked = 0; 301 302 for bundle_num in spec.start_bundle..=end { 303 let metadata = match index.get_bundle(bundle_num) { 304 Some(m) => m, 305 None => { 306 errors.push((bundle_num, format!("Bundle {} not in index", bundle_num))); 307 continue; 308 } 309 }; 310 311 bundles_checked += 1; 312 313 if spec.check_parent_links && bundle_num > 1 { 314 let prev_metadata = match index.get_bundle(bundle_num - 1) { 315 Some(m) => m, 316 None => { 317 errors.push(( 318 bundle_num, 319 format!("Previous bundle {} not found", bundle_num - 1), 320 )); 321 continue; 322 } 323 }; 324 325 if metadata.parent != prev_metadata.hash { 326 errors.push(( 327 bundle_num, 328 format!( 329 "Parent hash mismatch: expected {}, got {}", 330 prev_metadata.hash, metadata.parent 331 ), 332 )); 333 } 334 335 // Validate cursor matches previous bundle's end_time per spec 336 let expected_cursor = if bundle_num == 1 { 337 String::new() // First bundle has empty cursor 338 } else { 339 prev_metadata.end_time.clone() 340 }; 341 342 if metadata.cursor != expected_cursor { 343 errors.push(( 344 bundle_num, 345 format!( 346 "Cursor mismatch: expected {} (previous bundle end_time), got {}", 347 expected_cursor, metadata.cursor 348 ), 349 )); 350 } 351 } else if bundle_num == 1 { 352 // First bundle should have empty cursor 353 if !metadata.cursor.is_empty() { 354 errors.push(( 355 bundle_num, 356 format!( 357 "Cursor should be empty for first bundle, got: {}", 358 metadata.cursor 359 ), 360 )); 361 } 362 } 363 } 364 365 Ok(ChainVerifyResult { 366 valid: errors.is_empty(), 367 bundles_checked, 368 errors, 369 }) 370} 371 372#[cfg(test)] 373mod tests { 374 use super::*; 375 use crate::manager::{ChainVerifySpec, VerifySpec}; 376 use tempfile::TempDir; 377 378 fn create_test_bundle_metadata( 379 bundle_num: u32, 380 parent: &str, 381 cursor: &str, 382 end_time: &str, 383 ) -> BundleMetadata { 384 BundleMetadata { 385 bundle_number: bundle_num, 386 start_time: "2024-01-01T00:00:00Z".to_string(), 387 end_time: end_time.to_string(), 388 operation_count: 100, 389 did_count: 50, 390 hash: format!("hash{}", bundle_num), 391 content_hash: format!("content{}", bundle_num), 392 parent: parent.to_string(), 393 compressed_hash: format!("comp{}", bundle_num), 394 compressed_size: 1000, 395 uncompressed_size: 2000, 396 cursor: cursor.to_string(), 397 created_at: "2024-01-01T00:00:00Z".to_string(), 398 } 399 } 400 401 #[test] 402 fn test_verify_bundle_file_not_found() { 403 let tmp = TempDir::new().unwrap(); 404 let metadata = create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"); 405 let spec = VerifySpec { 406 check_hash: false, 407 check_content_hash: false, 408 check_operations: false, 409 fast: false, 410 }; 411 412 let result = verify_bundle(tmp.path(), &metadata, spec).unwrap(); 413 assert!(!result.valid); 414 assert!(!result.errors.is_empty()); 415 assert!(result.errors[0].contains("Bundle file not found")); 416 } 417 418 #[test] 419 fn test_verify_bundle_fast_mode_file_not_found() { 420 let tmp = TempDir::new().unwrap(); 421 let metadata = create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"); 422 let spec = VerifySpec { 423 check_hash: false, 424 check_content_hash: false, 425 check_operations: false, 426 fast: true, 427 }; 428 429 let result = verify_bundle(tmp.path(), &metadata, spec).unwrap(); 430 assert!(!result.valid); 431 assert!(!result.errors.is_empty()); 432 assert!(result.errors[0].contains("Bundle file not found")); 433 } 434 435 #[test] 436 fn test_verify_chain_empty_index() { 437 let tmp = TempDir::new().unwrap(); 438 let index = Index { 439 version: "1.0".to_string(), 440 origin: "test".to_string(), 441 last_bundle: 0, 442 updated_at: "2024-01-01T00:00:00Z".to_string(), 443 total_size_bytes: 0, 444 total_uncompressed_size_bytes: 0, 445 bundles: Vec::new(), 446 }; 447 448 let spec = ChainVerifySpec { 449 start_bundle: 1, 450 end_bundle: None, 451 check_parent_links: true, 452 }; 453 454 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 455 assert!(result.valid); 456 assert_eq!(result.bundles_checked, 0); 457 assert!(result.errors.is_empty()); 458 } 459 460 #[test] 461 fn test_verify_chain_single_bundle_valid() { 462 let tmp = TempDir::new().unwrap(); 463 let index = Index { 464 version: "1.0".to_string(), 465 origin: "test".to_string(), 466 last_bundle: 1, 467 updated_at: "2024-01-01T00:00:00Z".to_string(), 468 total_size_bytes: 1000, 469 total_uncompressed_size_bytes: 2000, 470 bundles: vec![create_test_bundle_metadata( 471 1, 472 "", 473 "", 474 "2024-01-01T01:00:00Z", 475 )], 476 }; 477 478 let spec = ChainVerifySpec { 479 start_bundle: 1, 480 end_bundle: None, 481 check_parent_links: true, 482 }; 483 484 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 485 assert!(result.valid); 486 assert_eq!(result.bundles_checked, 1); 487 assert!(result.errors.is_empty()); 488 } 489 490 #[test] 491 fn test_verify_chain_multiple_bundles_valid() { 492 let tmp = TempDir::new().unwrap(); 493 // Bundle 1: end_time = "2024-01-01T01:00:00Z", so bundle 2's cursor should be "2024-01-01T01:00:00Z" 494 // Bundle 2: end_time = "2024-01-01T02:00:00Z", so bundle 3's cursor should be "2024-01-01T02:00:00Z" 495 let index = Index { 496 version: "1.0".to_string(), 497 origin: "test".to_string(), 498 last_bundle: 3, 499 updated_at: "2024-01-01T00:00:00Z".to_string(), 500 total_size_bytes: 3000, 501 total_uncompressed_size_bytes: 6000, 502 bundles: vec![ 503 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), // end_time = "2024-01-01T01:00:00Z" 504 create_test_bundle_metadata( 505 2, 506 "hash1", 507 "2024-01-01T01:00:00Z", 508 "2024-01-01T02:00:00Z", 509 ), // cursor = bundle 1's end_time 510 create_test_bundle_metadata( 511 3, 512 "hash2", 513 "2024-01-01T02:00:00Z", 514 "2024-01-01T03:00:00Z", 515 ), // cursor = bundle 2's end_time 516 ], 517 }; 518 519 let spec = ChainVerifySpec { 520 start_bundle: 1, 521 end_bundle: None, 522 check_parent_links: true, 523 }; 524 525 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 526 assert!(result.valid); 527 assert_eq!(result.bundles_checked, 3); 528 assert!(result.errors.is_empty()); 529 } 530 531 #[test] 532 fn test_verify_chain_parent_hash_mismatch() { 533 let tmp = TempDir::new().unwrap(); 534 let index = Index { 535 version: "1.0".to_string(), 536 origin: "test".to_string(), 537 last_bundle: 2, 538 updated_at: "2024-01-01T00:00:00Z".to_string(), 539 total_size_bytes: 2000, 540 total_uncompressed_size_bytes: 4000, 541 bundles: vec![ 542 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), 543 create_test_bundle_metadata( 544 2, 545 "wrong_hash", 546 "2024-01-01T01:00:00Z", 547 "2024-01-01T02:00:00Z", 548 ), // Wrong parent hash 549 ], 550 }; 551 552 let spec = ChainVerifySpec { 553 start_bundle: 1, 554 end_bundle: None, 555 check_parent_links: true, 556 }; 557 558 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 559 assert!(!result.valid); 560 assert_eq!(result.bundles_checked, 2); 561 assert!(!result.errors.is_empty()); 562 assert!( 563 result 564 .errors 565 .iter() 566 .any(|(_, msg)| msg.contains("Parent hash mismatch")) 567 ); 568 } 569 570 #[test] 571 fn test_verify_chain_cursor_mismatch() { 572 let tmp = TempDir::new().unwrap(); 573 let index = Index { 574 version: "1.0".to_string(), 575 origin: "test".to_string(), 576 last_bundle: 2, 577 updated_at: "2024-01-01T00:00:00Z".to_string(), 578 total_size_bytes: 2000, 579 total_uncompressed_size_bytes: 4000, 580 bundles: vec![ 581 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), 582 create_test_bundle_metadata(2, "hash1", "wrong_cursor", "2024-01-01T02:00:00Z"), // Wrong cursor 583 ], 584 }; 585 586 let spec = ChainVerifySpec { 587 start_bundle: 1, 588 end_bundle: None, 589 check_parent_links: true, 590 }; 591 592 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 593 assert!(!result.valid); 594 assert_eq!(result.bundles_checked, 2); 595 assert!(!result.errors.is_empty()); 596 assert!( 597 result 598 .errors 599 .iter() 600 .any(|(_, msg)| msg.contains("Cursor mismatch")) 601 ); 602 } 603 604 #[test] 605 fn test_verify_chain_first_bundle_non_empty_cursor() { 606 let tmp = TempDir::new().unwrap(); 607 let index = Index { 608 version: "1.0".to_string(), 609 origin: "test".to_string(), 610 last_bundle: 1, 611 updated_at: "2024-01-01T00:00:00Z".to_string(), 612 total_size_bytes: 1000, 613 total_uncompressed_size_bytes: 2000, 614 bundles: vec![create_test_bundle_metadata( 615 1, 616 "", 617 "should_be_empty", 618 "2024-01-01T01:00:00Z", 619 )], // First bundle has non-empty cursor 620 }; 621 622 let spec = ChainVerifySpec { 623 start_bundle: 1, 624 end_bundle: None, 625 check_parent_links: false, // Don't check parent links, but still check first bundle cursor 626 }; 627 628 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 629 assert!(!result.valid); 630 assert_eq!(result.bundles_checked, 1); 631 assert!(!result.errors.is_empty()); 632 assert!( 633 result 634 .errors 635 .iter() 636 .any(|(_, msg)| msg.contains("Cursor should be empty")) 637 ); 638 } 639 640 #[test] 641 fn test_verify_chain_missing_bundle() { 642 let tmp = TempDir::new().unwrap(); 643 let index = Index { 644 version: "1.0".to_string(), 645 origin: "test".to_string(), 646 last_bundle: 3, 647 updated_at: "2024-01-01T00:00:00Z".to_string(), 648 total_size_bytes: 2000, 649 total_uncompressed_size_bytes: 4000, 650 bundles: vec![ 651 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), 652 create_test_bundle_metadata( 653 3, 654 "hash2", 655 "2024-01-01T02:00:00Z", 656 "2024-01-01T03:00:00Z", 657 ), // Missing bundle 2 658 ], 659 }; 660 661 let spec = ChainVerifySpec { 662 start_bundle: 1, 663 end_bundle: None, 664 check_parent_links: true, 665 }; 666 667 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 668 assert!(!result.valid); 669 assert_eq!(result.bundles_checked, 2); // Only bundles 1 and 3 checked 670 assert!(!result.errors.is_empty()); 671 assert!(result.errors.iter().any(|(num, _)| *num == 2)); 672 assert!( 673 result 674 .errors 675 .iter() 676 .any(|(_, msg)| msg.contains("not in index")) 677 ); 678 } 679 680 #[test] 681 fn test_verify_chain_missing_previous_bundle() { 682 let tmp = TempDir::new().unwrap(); 683 let index = Index { 684 version: "1.0".to_string(), 685 origin: "test".to_string(), 686 last_bundle: 2, 687 updated_at: "2024-01-01T00:00:00Z".to_string(), 688 total_size_bytes: 1000, 689 total_uncompressed_size_bytes: 2000, 690 bundles: vec![create_test_bundle_metadata( 691 2, 692 "hash1", 693 "2024-01-01T01:00:00Z", 694 "2024-01-01T02:00:00Z", 695 )], // Missing bundle 1 696 }; 697 698 let spec = ChainVerifySpec { 699 start_bundle: 1, 700 end_bundle: None, 701 check_parent_links: true, 702 }; 703 704 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 705 assert!(!result.valid); 706 assert_eq!(result.bundles_checked, 1); // Only bundle 2 checked 707 assert!(!result.errors.is_empty()); 708 assert!( 709 result 710 .errors 711 .iter() 712 .any(|(_, msg)| msg.contains("Previous bundle 1 not found")) 713 ); 714 } 715 716 #[test] 717 fn test_verify_chain_range() { 718 let tmp = TempDir::new().unwrap(); 719 // Each bundle's cursor should equal the previous bundle's end_time 720 let index = Index { 721 version: "1.0".to_string(), 722 origin: "test".to_string(), 723 last_bundle: 5, 724 updated_at: "2024-01-01T00:00:00Z".to_string(), 725 total_size_bytes: 5000, 726 total_uncompressed_size_bytes: 10000, 727 bundles: vec![ 728 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), // end_time = "2024-01-01T01:00:00Z" 729 create_test_bundle_metadata( 730 2, 731 "hash1", 732 "2024-01-01T01:00:00Z", 733 "2024-01-01T02:00:00Z", 734 ), // cursor = bundle 1's end_time 735 create_test_bundle_metadata( 736 3, 737 "hash2", 738 "2024-01-01T02:00:00Z", 739 "2024-01-01T03:00:00Z", 740 ), // cursor = bundle 2's end_time 741 create_test_bundle_metadata( 742 4, 743 "hash3", 744 "2024-01-01T03:00:00Z", 745 "2024-01-01T04:00:00Z", 746 ), // cursor = bundle 3's end_time 747 create_test_bundle_metadata( 748 5, 749 "hash4", 750 "2024-01-01T04:00:00Z", 751 "2024-01-01T05:00:00Z", 752 ), // cursor = bundle 4's end_time 753 ], 754 }; 755 756 let spec = ChainVerifySpec { 757 start_bundle: 2, 758 end_bundle: Some(4), 759 check_parent_links: true, 760 }; 761 762 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 763 assert!(result.valid); 764 assert_eq!(result.bundles_checked, 3); // Bundles 2, 3, 4 765 assert!(result.errors.is_empty()); 766 } 767 768 #[test] 769 fn test_verify_chain_no_parent_links_check() { 770 let tmp = TempDir::new().unwrap(); 771 let index = Index { 772 version: "1.0".to_string(), 773 origin: "test".to_string(), 774 last_bundle: 2, 775 updated_at: "2024-01-01T00:00:00Z".to_string(), 776 total_size_bytes: 2000, 777 total_uncompressed_size_bytes: 4000, 778 bundles: vec![ 779 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), 780 create_test_bundle_metadata( 781 2, 782 "wrong_hash", 783 "wrong_cursor", 784 "2024-01-01T02:00:00Z", 785 ), // Wrong but won't be checked 786 ], 787 }; 788 789 let spec = ChainVerifySpec { 790 start_bundle: 1, 791 end_bundle: None, 792 check_parent_links: false, 793 }; 794 795 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 796 assert!(result.valid); // Should be valid since we're not checking parent links 797 assert_eq!(result.bundles_checked, 2); 798 assert!(result.errors.is_empty()); 799 } 800 801 #[test] 802 fn test_verify_chain_end_bundle_none() { 803 let tmp = TempDir::new().unwrap(); 804 // Each bundle's cursor should equal the previous bundle's end_time 805 let index = Index { 806 version: "1.0".to_string(), 807 origin: "test".to_string(), 808 last_bundle: 3, 809 updated_at: "2024-01-01T00:00:00Z".to_string(), 810 total_size_bytes: 3000, 811 total_uncompressed_size_bytes: 6000, 812 bundles: vec![ 813 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), // end_time = "2024-01-01T01:00:00Z" 814 create_test_bundle_metadata( 815 2, 816 "hash1", 817 "2024-01-01T01:00:00Z", 818 "2024-01-01T02:00:00Z", 819 ), // cursor = bundle 1's end_time 820 create_test_bundle_metadata( 821 3, 822 "hash2", 823 "2024-01-01T02:00:00Z", 824 "2024-01-01T03:00:00Z", 825 ), // cursor = bundle 2's end_time 826 ], 827 }; 828 829 let spec = ChainVerifySpec { 830 start_bundle: 1, 831 end_bundle: None, // Should default to last_bundle (3) 832 check_parent_links: true, 833 }; 834 835 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 836 assert!(result.valid); 837 assert_eq!(result.bundles_checked, 3); 838 } 839 840 #[test] 841 fn test_verify_chain_end_bundle_specified() { 842 let tmp = TempDir::new().unwrap(); 843 // Each bundle's cursor should equal the previous bundle's end_time 844 let index = Index { 845 version: "1.0".to_string(), 846 origin: "test".to_string(), 847 last_bundle: 5, 848 updated_at: "2024-01-01T00:00:00Z".to_string(), 849 total_size_bytes: 5000, 850 total_uncompressed_size_bytes: 10000, 851 bundles: vec![ 852 create_test_bundle_metadata(1, "", "", "2024-01-01T01:00:00Z"), // end_time = "2024-01-01T01:00:00Z" 853 create_test_bundle_metadata( 854 2, 855 "hash1", 856 "2024-01-01T01:00:00Z", 857 "2024-01-01T02:00:00Z", 858 ), // cursor = bundle 1's end_time 859 create_test_bundle_metadata( 860 3, 861 "hash2", 862 "2024-01-01T02:00:00Z", 863 "2024-01-01T03:00:00Z", 864 ), // cursor = bundle 2's end_time 865 create_test_bundle_metadata( 866 4, 867 "hash3", 868 "2024-01-01T03:00:00Z", 869 "2024-01-01T04:00:00Z", 870 ), // cursor = bundle 3's end_time 871 create_test_bundle_metadata( 872 5, 873 "hash4", 874 "2024-01-01T04:00:00Z", 875 "2024-01-01T05:00:00Z", 876 ), // cursor = bundle 4's end_time 877 ], 878 }; 879 880 let spec = ChainVerifySpec { 881 start_bundle: 1, 882 end_bundle: Some(2), // Only check first 2 bundles 883 check_parent_links: true, 884 }; 885 886 let result = verify_chain(tmp.path(), &index, spec).unwrap(); 887 assert!(result.valid); 888 assert_eq!(result.bundles_checked, 2); 889 } 890}