forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
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}