1use crate::Result;
54use sha1::{Digest, Sha1};
55use std::collections::BTreeMap;
56use std::fs::File;
57use std::io::Read;
58use std::path::{Path, PathBuf};
59use tracing::{debug, debug_span, info, info_span, trace, warn};
60
61const READ_BUF_CAPACITY: usize = 64 * 1024;
62const SHA1_DIGEST_LEN: usize = 20;
63
64#[non_exhaustive]
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum HashAlgorithm {
72 Sha1,
74}
75
76impl HashAlgorithm {
77 #[must_use]
79 pub const fn digest_len(self) -> usize {
80 match self {
81 HashAlgorithm::Sha1 => SHA1_DIGEST_LEN,
82 }
83 }
84}
85
86#[non_exhaustive]
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum ExpectedHash {
101 Whole {
103 algorithm: HashAlgorithm,
105 hash: Vec<u8>,
107 },
108 Blocks {
111 algorithm: HashAlgorithm,
113 block_size: u64,
115 hashes: Vec<Vec<u8>>,
118 },
119}
120
121impl ExpectedHash {
122 #[must_use]
124 pub fn whole_sha1(hash: Vec<u8>) -> Self {
125 ExpectedHash::Whole {
126 algorithm: HashAlgorithm::Sha1,
127 hash,
128 }
129 }
130
131 #[must_use]
133 pub fn blocks_sha1(block_size: u64, hashes: Vec<Vec<u8>>) -> Self {
134 ExpectedHash::Blocks {
135 algorithm: HashAlgorithm::Sha1,
136 block_size,
137 hashes,
138 }
139 }
140
141 #[must_use]
143 pub fn algorithm(&self) -> HashAlgorithm {
144 match self {
145 ExpectedHash::Whole { algorithm, .. } | ExpectedHash::Blocks { algorithm, .. } => {
146 *algorithm
147 }
148 }
149 }
150
151 fn validate(&self) -> Result<()> {
152 let want = self.algorithm().digest_len();
153 match self {
154 ExpectedHash::Whole { hash, .. } => {
155 if hash.len() != want {
156 return Err(crate::ZiPatchError::InvalidField {
157 context: "ExpectedHash::Whole digest has wrong length for algorithm",
158 });
159 }
160 }
161 ExpectedHash::Blocks {
162 block_size, hashes, ..
163 } => {
164 if *block_size == 0 {
165 return Err(crate::ZiPatchError::InvalidField {
166 context: "ExpectedHash::Blocks block_size must be non-zero",
167 });
168 }
169 for h in hashes {
170 if h.len() != want {
171 return Err(crate::ZiPatchError::InvalidField {
172 context: "ExpectedHash::Blocks per-block digest has wrong length for algorithm",
173 });
174 }
175 }
176 }
177 }
178 Ok(())
179 }
180}
181
182#[non_exhaustive]
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum FileVerifyOutcome {
189 Match,
192 WholeMismatch {
194 expected: Vec<u8>,
196 actual: Vec<u8>,
198 },
199 BlockMismatches {
209 mismatched_blocks: Vec<usize>,
211 expected_block_count: usize,
213 actual_block_count: usize,
215 },
216 Missing,
218 IoError {
225 kind: std::io::ErrorKind,
227 message: String,
229 },
230}
231
232#[non_exhaustive]
239#[derive(Debug, Clone, PartialEq, Eq, Default)]
240pub struct HashVerifyReport {
241 pub files: BTreeMap<PathBuf, FileVerifyOutcome>,
243}
244
245impl HashVerifyReport {
246 #[must_use]
248 pub fn is_clean(&self) -> bool {
249 self.files
250 .values()
251 .all(|o| matches!(o, FileVerifyOutcome::Match))
252 }
253
254 pub fn failures(&self) -> impl Iterator<Item = (&Path, &FileVerifyOutcome)> {
256 self.files
257 .iter()
258 .filter(|(_, o)| !matches!(o, FileVerifyOutcome::Match))
259 .map(|(p, o)| (p.as_path(), o))
260 }
261
262 #[must_use]
264 pub fn failure_count(&self) -> usize {
265 self.failures().count()
266 }
267}
268
269#[derive(Debug, Default)]
301pub struct HashVerifier {
302 tasks: Vec<(PathBuf, ExpectedHash)>,
303}
304
305impl HashVerifier {
306 #[must_use]
308 pub fn new() -> Self {
309 Self::default()
310 }
311
312 #[must_use]
322 pub fn expect(mut self, path: impl Into<PathBuf>, expected: ExpectedHash) -> Self {
323 self.tasks.push((path.into(), expected));
324 self
325 }
326
327 pub fn execute(self) -> Result<HashVerifyReport> {
342 let span = info_span!("verify_hashes", files = self.tasks.len());
343 let _enter = span.enter();
344 let started = std::time::Instant::now();
345
346 for (_, exp) in &self.tasks {
347 exp.validate()?;
348 }
349
350 let mut seen: BTreeMap<&Path, &ExpectedHash> = BTreeMap::new();
351 for (path, exp) in &self.tasks {
352 match seen.get(path.as_path()) {
353 Some(prev) if *prev == exp => {}
354 Some(_) => {
355 return Err(crate::ZiPatchError::InvalidField {
356 context: "HashVerifier: same path registered with conflicting ExpectedHash values",
357 });
358 }
359 None => {
360 seen.insert(path.as_path(), exp);
361 }
362 }
363 }
364
365 let mut report = HashVerifyReport::default();
366 let mut scratch = vec![0u8; READ_BUF_CAPACITY];
367 let mut total_bytes: u64 = 0;
368
369 for (path, expected) in self.tasks {
370 let sub = debug_span!("verify_file", path = %path.display());
371 let _e = sub.enter();
372 let (outcome, bytes) = verify_one(&path, &expected, &mut scratch);
373 total_bytes += bytes;
374 match &outcome {
375 FileVerifyOutcome::Match => {
376 debug!(bytes_hashed = bytes, "verify_hashes: file match");
377 }
378 FileVerifyOutcome::Missing => {
379 warn!("verify_hashes: file missing");
380 }
381 FileVerifyOutcome::IoError { kind, message } => {
382 warn!(?kind, error = %message, "verify_hashes: io error during hash");
383 }
384 FileVerifyOutcome::WholeMismatch { .. } => {
385 debug!(bytes_hashed = bytes, "verify_hashes: whole-file mismatch");
386 }
387 FileVerifyOutcome::BlockMismatches {
388 mismatched_blocks, ..
389 } => {
390 debug!(
391 bytes_hashed = bytes,
392 bad_blocks = mismatched_blocks.len(),
393 "verify_hashes: block-mode mismatches"
394 );
395 }
396 }
397 report.files.insert(path, outcome);
398 }
399
400 let failures = report.failure_count();
401 info!(
402 files = report.files.len(),
403 failures,
404 bytes_hashed = total_bytes,
405 elapsed_ms = started.elapsed().as_millis() as u64,
406 "verify_hashes: run complete"
407 );
408 Ok(report)
409 }
410}
411
412fn verify_one(
413 path: &Path,
414 expected: &ExpectedHash,
415 scratch: &mut [u8],
416) -> (FileVerifyOutcome, u64) {
417 let mut file = match File::open(path) {
418 Ok(f) => f,
419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
420 return (FileVerifyOutcome::Missing, 0);
421 }
422 Err(e) => {
423 return (
424 FileVerifyOutcome::IoError {
425 kind: e.kind(),
426 message: e.to_string(),
427 },
428 0,
429 );
430 }
431 };
432
433 match expected {
434 ExpectedHash::Whole { algorithm, hash } => match hash_whole(*algorithm, &mut file, scratch)
435 {
436 Ok((actual, n)) => {
437 if actual.as_slice() == hash.as_slice() {
438 (FileVerifyOutcome::Match, n)
439 } else {
440 (
441 FileVerifyOutcome::WholeMismatch {
442 expected: hash.clone(),
443 actual,
444 },
445 n,
446 )
447 }
448 }
449 Err(e) => (
450 FileVerifyOutcome::IoError {
451 kind: e.kind(),
452 message: e.to_string(),
453 },
454 0,
455 ),
456 },
457 ExpectedHash::Blocks {
458 algorithm,
459 block_size,
460 hashes,
461 } => hash_blocks(*algorithm, &mut file, *block_size, hashes, scratch),
462 }
463}
464
465fn hash_whole<R: Read>(
466 algo: HashAlgorithm,
467 reader: &mut R,
468 scratch: &mut [u8],
469) -> std::io::Result<(Vec<u8>, u64)> {
470 match algo {
471 HashAlgorithm::Sha1 => {
472 let mut hasher = Sha1::new();
473 let mut total: u64 = 0;
474 loop {
475 let n = reader.read(scratch)?;
476 if n == 0 {
477 break;
478 }
479 hasher.update(&scratch[..n]);
480 total += n as u64;
481 trace!(chunk_bytes = n, "verify_hashes: whole-file chunk");
482 }
483 Ok((hasher.finalize().to_vec(), total))
484 }
485 }
486}
487
488fn hash_blocks<R: Read>(
489 algo: HashAlgorithm,
490 reader: &mut R,
491 block_size: u64,
492 expected: &[Vec<u8>],
493 scratch: &mut [u8],
494) -> (FileVerifyOutcome, u64) {
495 let mut mismatched: Vec<usize> = Vec::new();
498 let mut block_idx: usize = 0;
499 let mut total_bytes: u64 = 0;
500 let mut hasher = block_hasher(algo);
501 let mut block_bytes_remaining: u64 = block_size;
502 let mut block_had_bytes = false;
503
504 loop {
505 let want = block_bytes_remaining.min(scratch.len() as u64) as usize;
507 if want == 0 {
508 finish_and_compare(algo, &mut hasher, block_idx, expected, &mut mismatched);
509 block_idx += 1;
510 block_bytes_remaining = block_size;
511 block_had_bytes = false;
512 continue;
513 }
514 let n = match reader.read(&mut scratch[..want]) {
515 Ok(n) => n,
516 Err(e) => {
517 return (
518 FileVerifyOutcome::IoError {
519 kind: e.kind(),
520 message: e.to_string(),
521 },
522 total_bytes,
523 );
524 }
525 };
526 if n == 0 {
527 if block_had_bytes {
528 finish_and_compare(algo, &mut hasher, block_idx, expected, &mut mismatched);
530 block_idx += 1;
531 }
532 break;
533 }
534 match &mut hasher {
535 BlockHasher::Sha1(h) => h.update(&scratch[..n]),
536 }
537 total_bytes += n as u64;
538 block_bytes_remaining -= n as u64;
539 block_had_bytes = true;
540 trace!(block_idx, chunk_bytes = n, "verify_hashes: block chunk");
541 }
542
543 for missing in block_idx..expected.len() {
548 mismatched.push(missing);
549 }
550
551 let actual_block_count = block_idx;
552 let expected_block_count = expected.len();
553 let outcome = if mismatched.is_empty() && actual_block_count == expected_block_count {
554 FileVerifyOutcome::Match
555 } else {
556 mismatched.sort_unstable();
557 mismatched.dedup();
558 FileVerifyOutcome::BlockMismatches {
559 mismatched_blocks: mismatched,
560 expected_block_count,
561 actual_block_count,
562 }
563 };
564 (outcome, total_bytes)
565}
566
567enum BlockHasher {
568 Sha1(Sha1),
569}
570
571fn block_hasher(algo: HashAlgorithm) -> BlockHasher {
572 match algo {
573 HashAlgorithm::Sha1 => BlockHasher::Sha1(Sha1::new()),
574 }
575}
576
577fn finish_and_compare(
578 algo: HashAlgorithm,
579 hasher: &mut BlockHasher,
580 block_idx: usize,
581 expected: &[Vec<u8>],
582 mismatched: &mut Vec<usize>,
583) {
584 let finished = std::mem::replace(hasher, block_hasher(algo));
587 let digest: Vec<u8> = match finished {
588 BlockHasher::Sha1(h) => h.finalize().to_vec(),
589 };
590 match expected.get(block_idx) {
591 Some(want) if want.as_slice() == digest.as_slice() => {}
592 _ => mismatched.push(block_idx),
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use std::io::Write;
600
601 fn sha1_of(bytes: &[u8]) -> Vec<u8> {
602 let mut h = Sha1::new();
603 h.update(bytes);
604 h.finalize().to_vec()
605 }
606
607 fn write_tmp(bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
608 let dir = tempfile::tempdir().unwrap();
609 let path = dir.path().join("f.bin");
610 let mut f = File::create(&path).unwrap();
611 f.write_all(bytes).unwrap();
612 f.sync_all().unwrap();
613 (dir, path)
614 }
615
616 #[test]
617 fn report_is_clean_when_empty() {
618 let r = HashVerifyReport::default();
619 assert!(r.is_clean());
620 assert_eq!(r.failure_count(), 0);
621 assert_eq!(r.failures().count(), 0);
622 }
623
624 #[test]
625 fn whole_sha1_match() {
626 let payload = b"hello world".repeat(1000);
627 let (_d, path) = write_tmp(&payload);
628 let report = HashVerifier::new()
629 .expect(&path, ExpectedHash::whole_sha1(sha1_of(&payload)))
630 .execute()
631 .unwrap();
632 assert!(report.is_clean(), "got {report:?}");
633 }
634
635 #[test]
636 fn whole_sha1_mismatch() {
637 let (_d, path) = write_tmp(b"abc");
638 let bad = vec![0u8; 20];
639 let report = HashVerifier::new()
640 .expect(&path, ExpectedHash::whole_sha1(bad.clone()))
641 .execute()
642 .unwrap();
643 assert!(!report.is_clean());
644 match report.files.get(&path).unwrap() {
645 FileVerifyOutcome::WholeMismatch { expected, actual } => {
646 assert_eq!(expected, &bad);
647 assert_eq!(actual, &sha1_of(b"abc"));
648 }
649 other => panic!("expected WholeMismatch, got {other:?}"),
650 }
651 }
652
653 #[test]
654 fn block_mode_match() {
655 let block_size: u64 = 256;
656 let mut payload = Vec::new();
657 for i in 0..5u8 {
658 payload.extend(std::iter::repeat_n(i, block_size as usize));
659 }
660 payload.extend_from_slice(&[0xAB; 17]);
662
663 let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
664 let (_d, path) = write_tmp(&payload);
665
666 let report = HashVerifier::new()
667 .expect(&path, ExpectedHash::blocks_sha1(block_size, hashes.clone()))
668 .execute()
669 .unwrap();
670 assert!(report.is_clean(), "got {report:?}");
671 assert_eq!(hashes.len(), 6); }
673
674 #[test]
675 fn block_mode_specific_block_mismatch() {
676 let block_size: u64 = 128;
677 let mut payload = vec![0u8; (block_size as usize) * 4];
678 let clean = payload.clone();
681 payload[(block_size as usize) * 2 + 7] = 0xFF;
682
683 let expected: Vec<Vec<u8>> = clean.chunks(block_size as usize).map(sha1_of).collect();
684 let (_d, path) = write_tmp(&payload);
685
686 let report = HashVerifier::new()
687 .expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
688 .execute()
689 .unwrap();
690 match report.files.get(&path).unwrap() {
691 FileVerifyOutcome::BlockMismatches {
692 mismatched_blocks,
693 expected_block_count,
694 actual_block_count,
695 } => {
696 assert_eq!(mismatched_blocks, &vec![2]);
697 assert_eq!(*expected_block_count, 4);
698 assert_eq!(*actual_block_count, 4);
699 }
700 other => panic!("expected BlockMismatches, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn missing_file_reported() {
706 let dir = tempfile::tempdir().unwrap();
707 let missing = dir.path().join("does-not-exist");
708 let report = HashVerifier::new()
709 .expect(&missing, ExpectedHash::whole_sha1(vec![0u8; 20]))
710 .execute()
711 .unwrap();
712 assert_eq!(
713 report.files.get(&missing).unwrap(),
714 &FileVerifyOutcome::Missing
715 );
716 assert!(!report.is_clean());
717 }
718
719 #[test]
720 fn block_mode_file_shorter_than_expected_flags_trailing_missing_blocks() {
721 let block_size: u64 = 64;
722 let payload = vec![0u8; (block_size as usize) * 2];
724 let expected: Vec<Vec<u8>> = payload
725 .chunks(block_size as usize)
726 .map(sha1_of)
727 .chain(std::iter::repeat_n(vec![0u8; 20], 2))
728 .collect();
729 assert_eq!(expected.len(), 4);
730 let (_d, path) = write_tmp(&payload);
731
732 let report = HashVerifier::new()
733 .expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
734 .execute()
735 .unwrap();
736 match report.files.get(&path).unwrap() {
737 FileVerifyOutcome::BlockMismatches {
738 mismatched_blocks,
739 expected_block_count,
740 actual_block_count,
741 } => {
742 assert_eq!(*expected_block_count, 4);
743 assert_eq!(*actual_block_count, 2);
744 assert_eq!(mismatched_blocks, &vec![2, 3]);
745 }
746 other => panic!("expected BlockMismatches, got {other:?}"),
747 }
748 }
749
750 #[test]
751 fn block_mode_file_longer_than_expected_flags_extra_blocks() {
752 let block_size: u64 = 32;
753 let payload = vec![0u8; (block_size as usize) * 4];
754 let expected: Vec<Vec<u8>> = payload
756 .chunks(block_size as usize)
757 .take(2)
758 .map(sha1_of)
759 .collect();
760 let (_d, path) = write_tmp(&payload);
761
762 let report = HashVerifier::new()
763 .expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
764 .execute()
765 .unwrap();
766 match report.files.get(&path).unwrap() {
767 FileVerifyOutcome::BlockMismatches {
768 mismatched_blocks,
769 expected_block_count,
770 actual_block_count,
771 } => {
772 assert_eq!(*expected_block_count, 2);
773 assert_eq!(*actual_block_count, 4);
774 assert_eq!(mismatched_blocks, &vec![2, 3]);
775 }
776 other => panic!("expected BlockMismatches, got {other:?}"),
777 }
778 }
779
780 #[test]
781 fn empty_file_whole_mode_matches_sha1_of_empty() {
782 let (_d, path) = write_tmp(&[]);
783 let report = HashVerifier::new()
784 .expect(&path, ExpectedHash::whole_sha1(sha1_of(&[])))
785 .execute()
786 .unwrap();
787 assert!(report.is_clean());
788 }
789
790 #[test]
791 fn empty_file_block_mode_matches_zero_blocks() {
792 let (_d, path) = write_tmp(&[]);
794 let report = HashVerifier::new()
795 .expect(&path, ExpectedHash::blocks_sha1(1024, vec![]))
796 .execute()
797 .unwrap();
798 assert!(report.is_clean());
799 }
800
801 #[test]
802 fn zero_block_size_is_rejected_up_front() {
803 let dir = tempfile::tempdir().unwrap();
804 let path = dir.path().join("any");
805 let err = HashVerifier::new()
806 .expect(&path, ExpectedHash::blocks_sha1(0, vec![]))
807 .execute()
808 .unwrap_err();
809 assert!(
810 matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("block_size")),
811 "got {err:?}"
812 );
813 }
814
815 #[test]
816 fn whole_mode_wrong_digest_length_is_rejected_up_front() {
817 let (_d, path) = write_tmp(b"x");
818 let err = HashVerifier::new()
819 .expect(&path, ExpectedHash::whole_sha1(vec![0u8; 19]))
820 .execute()
821 .unwrap_err();
822 assert!(
823 matches!(err, crate::ZiPatchError::InvalidField { .. }),
824 "got {err:?}"
825 );
826 }
827
828 #[test]
829 fn block_mode_wrong_per_block_digest_length_is_rejected_up_front() {
830 let (_d, path) = write_tmp(b"y");
831 let bad = ExpectedHash::Blocks {
832 algorithm: HashAlgorithm::Sha1,
833 block_size: 16,
834 hashes: vec![vec![0u8; 19]],
835 };
836 let err = HashVerifier::new()
837 .expect(&path, bad)
838 .execute()
839 .unwrap_err();
840 assert!(matches!(err, crate::ZiPatchError::InvalidField { .. }));
841 }
842
843 #[test]
844 fn block_mode_block_size_exceeds_read_buf_capacity_match() {
845 let block_size: u64 = 200 * 1024;
849 let mut payload = Vec::with_capacity((block_size as usize) * 3 + 17);
850 for i in 0..3u8 {
851 payload.extend(std::iter::repeat_n(i.wrapping_mul(31), block_size as usize));
852 }
853 payload.extend_from_slice(&[0xCD; 17]);
854
855 let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
856 assert_eq!(hashes.len(), 4);
857 let (_d, path) = write_tmp(&payload);
858
859 let report = HashVerifier::new()
860 .expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
861 .execute()
862 .unwrap();
863 assert!(report.is_clean(), "got {report:?}");
864 }
865
866 #[test]
867 fn block_mode_block_size_exceeds_read_buf_capacity_mismatch() {
868 let block_size: u64 = 200 * 1024;
872 let mut payload = Vec::with_capacity((block_size as usize) * 3);
873 for i in 0..3u8 {
874 payload.extend(std::iter::repeat_n(i.wrapping_mul(17), block_size as usize));
875 }
876 let clean = payload.clone();
877 payload[(block_size as usize) + 150 * 1024] ^= 0xFF;
879
880 let expected: Vec<Vec<u8>> = clean.chunks(block_size as usize).map(sha1_of).collect();
881 let (_d, path) = write_tmp(&payload);
882
883 let report = HashVerifier::new()
884 .expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
885 .execute()
886 .unwrap();
887 match report.files.get(&path).unwrap() {
888 FileVerifyOutcome::BlockMismatches {
889 mismatched_blocks,
890 expected_block_count,
891 actual_block_count,
892 } => {
893 assert_eq!(mismatched_blocks, &vec![1]);
894 assert_eq!(*expected_block_count, 3);
895 assert_eq!(*actual_block_count, 3);
896 }
897 other => panic!("expected BlockMismatches, got {other:?}"),
898 }
899 }
900
901 #[test]
902 fn block_mode_single_short_block_distinguishes_from_empty_file() {
903 let block_size: u64 = 200 * 1024;
907 let payload = vec![0x7Eu8; 1000]; let hashes = vec![sha1_of(&payload)];
909 let (_d, path) = write_tmp(&payload);
910
911 let report = HashVerifier::new()
912 .expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
913 .execute()
914 .unwrap();
915 assert!(report.is_clean(), "got {report:?}");
916 }
917
918 #[cfg(target_family = "unix")]
919 #[test]
920 fn permission_denied_open_reports_io_error_with_kind() {
921 use std::os::unix::fs::PermissionsExt;
922
923 let (_d, path) = write_tmp(b"forbidden");
924 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
927
928 if File::open(&path).is_ok() {
933 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
934 eprintln!("skipping: running with CAP_DAC_OVERRIDE, chmod 0o000 does not block open");
935 return;
936 }
937
938 let report = HashVerifier::new()
939 .expect(&path, ExpectedHash::whole_sha1(vec![0u8; 20]))
940 .execute()
941 .unwrap();
942
943 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
945
946 match report.files.get(&path).unwrap() {
947 FileVerifyOutcome::IoError { kind, message } => {
948 assert_eq!(*kind, std::io::ErrorKind::PermissionDenied, "got {kind:?}");
949 assert!(!message.is_empty(), "message should carry the error text");
950 }
951 other => panic!("expected IoError with PermissionDenied kind, got {other:?}"),
952 }
953 }
954
955 #[test]
963 fn duplicate_identical_registration_is_noop() {
964 let (_d, path) = write_tmp(b"abc");
965 let expected = ExpectedHash::whole_sha1(sha1_of(b"abc"));
966 let report = HashVerifier::new()
967 .expect(&path, expected.clone())
968 .expect(&path, expected)
969 .execute()
970 .unwrap();
971 assert!(report.is_clean(), "got {report:?}");
972 assert_eq!(report.files.len(), 1);
973 }
974
975 #[test]
976 fn duplicate_conflicting_registration_errors() {
977 let (_d, path) = write_tmp(b"abc");
978 let err = HashVerifier::new()
979 .expect(&path, ExpectedHash::whole_sha1(sha1_of(b"abc")))
980 .expect(&path, ExpectedHash::whole_sha1(vec![0u8; 20]))
981 .execute()
982 .unwrap_err();
983 assert!(
984 matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("conflicting")),
985 "got {err:?}"
986 );
987 }
988
989 #[test]
990 fn failures_iter_excludes_matches() {
991 let (_d1, ok) = write_tmp(b"a");
992 let (_d2, bad) = write_tmp(b"b");
993 let report = HashVerifier::new()
994 .expect(&ok, ExpectedHash::whole_sha1(sha1_of(b"a")))
995 .expect(&bad, ExpectedHash::whole_sha1(vec![0u8; 20]))
996 .execute()
997 .unwrap();
998 let fails: Vec<_> = report.failures().collect();
999 assert_eq!(fails.len(), 1);
1000 assert_eq!(fails[0].0, bad.as_path());
1001 }
1002
1003 struct FailAfter {
1007 remaining_ok: usize,
1008 kind: std::io::ErrorKind,
1009 }
1010
1011 impl Read for FailAfter {
1012 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1013 if self.remaining_ok == 0 {
1014 return Err(std::io::Error::new(self.kind, "injected"));
1015 }
1016 let n = self.remaining_ok.min(buf.len());
1017 buf[..n].fill(0);
1018 self.remaining_ok -= n;
1019 Ok(n)
1020 }
1021 }
1022
1023 #[test]
1024 fn hash_whole_propagates_mid_read_io_error() {
1025 let mut reader = FailAfter {
1026 remaining_ok: 32,
1027 kind: std::io::ErrorKind::Other,
1028 };
1029 let mut scratch = vec![0u8; 16];
1030 let err = hash_whole(HashAlgorithm::Sha1, &mut reader, &mut scratch).unwrap_err();
1031 assert_eq!(err.kind(), std::io::ErrorKind::Other);
1032 }
1033
1034 #[test]
1035 fn hash_blocks_surfaces_mid_read_io_error_as_outcome() {
1036 let mut reader = FailAfter {
1037 remaining_ok: 40,
1038 kind: std::io::ErrorKind::ConnectionAborted,
1039 };
1040 let mut scratch = vec![0u8; 16];
1041 let expected = vec![vec![0u8; 20]; 4];
1042 let (outcome, bytes) = hash_blocks(
1043 HashAlgorithm::Sha1,
1044 &mut reader,
1045 64,
1046 &expected,
1047 &mut scratch,
1048 );
1049 match outcome {
1050 FileVerifyOutcome::IoError { kind, .. } => {
1051 assert_eq!(kind, std::io::ErrorKind::ConnectionAborted);
1052 }
1053 other => panic!("expected IoError outcome, got {other:?}"),
1054 }
1055 assert_eq!(
1056 bytes, 40,
1057 "bytes hashed up to the failure should be reported"
1058 );
1059 }
1060
1061 #[test]
1064 fn execute_with_no_tasks_returns_clean_empty_report() {
1065 let report = HashVerifier::new().execute().unwrap();
1066 assert!(report.is_clean());
1067 assert_eq!(report.files.len(), 0);
1068 assert_eq!(report.failure_count(), 0);
1069 }
1070
1071 #[test]
1074 fn report_nonempty_all_match_is_clean() {
1075 let (_d1, p1) = write_tmp(b"one");
1076 let (_d2, p2) = write_tmp(b"two");
1077 let report = HashVerifier::new()
1078 .expect(&p1, ExpectedHash::whole_sha1(sha1_of(b"one")))
1079 .expect(&p2, ExpectedHash::whole_sha1(sha1_of(b"two")))
1080 .execute()
1081 .unwrap();
1082 assert_eq!(report.files.len(), 2);
1083 assert!(report.is_clean());
1084 assert_eq!(report.failure_count(), 0);
1085 assert_eq!(report.failures().count(), 0);
1086 }
1087
1088 #[test]
1089 fn failure_count_equals_failures_iter_count() {
1090 let (_d1, ok) = write_tmp(b"good");
1091 let (_d2, bad1) = write_tmp(b"bad1");
1092 let (_d3, bad2) = write_tmp(b"bad2");
1093 let report = HashVerifier::new()
1094 .expect(&ok, ExpectedHash::whole_sha1(sha1_of(b"good")))
1095 .expect(&bad1, ExpectedHash::whole_sha1(vec![0u8; 20]))
1096 .expect(&bad2, ExpectedHash::whole_sha1(vec![0u8; 20]))
1097 .execute()
1098 .unwrap();
1099 assert_eq!(report.failure_count(), report.failures().count());
1100 assert_eq!(report.failure_count(), 2);
1101 }
1102
1103 #[test]
1104 fn report_files_iteration_order_is_by_path() {
1105 let dir = tempfile::tempdir().unwrap();
1108 let pb = dir.path().join("b.bin");
1109 let pa = dir.path().join("a.bin");
1110 let pc = dir.path().join("c.bin");
1111 for p in [&pb, &pa, &pc] {
1112 let mut f = File::create(p).unwrap();
1113 f.write_all(b"x").unwrap();
1114 }
1115 let report = HashVerifier::new()
1116 .expect(&pb, ExpectedHash::whole_sha1(sha1_of(b"x")))
1117 .expect(&pa, ExpectedHash::whole_sha1(sha1_of(b"x")))
1118 .expect(&pc, ExpectedHash::whole_sha1(sha1_of(b"x")))
1119 .execute()
1120 .unwrap();
1121 let keys: Vec<&PathBuf> = report.files.keys().collect();
1122 assert_eq!(keys[0], &pa);
1123 assert_eq!(keys[1], &pb);
1124 assert_eq!(keys[2], &pc);
1125 }
1126
1127 #[test]
1130 fn file_verify_outcome_clone_and_partialeq() {
1131 let outcomes = [
1132 FileVerifyOutcome::Match,
1133 FileVerifyOutcome::Missing,
1134 FileVerifyOutcome::WholeMismatch {
1135 expected: vec![0u8; 20],
1136 actual: vec![1u8; 20],
1137 },
1138 FileVerifyOutcome::BlockMismatches {
1139 mismatched_blocks: vec![0, 2],
1140 expected_block_count: 3,
1141 actual_block_count: 3,
1142 },
1143 FileVerifyOutcome::IoError {
1144 kind: std::io::ErrorKind::Other,
1145 message: "oops".to_string(),
1146 },
1147 ];
1148 for o in &outcomes {
1149 let cloned = o.clone();
1150 assert_eq!(o, &cloned, "Clone+PartialEq round-trip failed for {o:?}");
1151 }
1152 assert_ne!(
1153 FileVerifyOutcome::Match,
1154 FileVerifyOutcome::Missing,
1155 "distinct variants must not compare equal"
1156 );
1157 }
1158
1159 #[test]
1162 fn blocks_validate_valid_then_invalid_hash_surfaces_error() {
1163 let (_d, path) = write_tmp(b"z");
1164 let bad = ExpectedHash::Blocks {
1165 algorithm: HashAlgorithm::Sha1,
1166 block_size: 8,
1167 hashes: vec![
1168 vec![0u8; 20], vec![0u8; 5], ],
1171 };
1172 let err = HashVerifier::new()
1173 .expect(&path, bad)
1174 .execute()
1175 .unwrap_err();
1176 assert!(matches!(err, crate::ZiPatchError::InvalidField { .. }));
1177 }
1178
1179 #[test]
1182 fn many_chained_expects_all_evaluated() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let n = 10usize;
1185 let mut builder = HashVerifier::new();
1186 let mut paths = Vec::with_capacity(n);
1187 for i in 0..n {
1188 let p = dir.path().join(format!("f{i}.bin"));
1189 let mut f = File::create(&p).unwrap();
1190 f.write_all(&[i as u8]).unwrap();
1191 builder = builder.expect(&p, ExpectedHash::whole_sha1(sha1_of(&[i as u8])));
1192 paths.push(p);
1193 }
1194 let report = builder.execute().unwrap();
1195 assert_eq!(report.files.len(), n);
1196 assert!(report.is_clean(), "got {report:?}");
1197 }
1198
1199 #[test]
1200 fn whole_then_blocks_registration_for_same_path_conflicts() {
1201 let (_d, path) = write_tmp(b"hi");
1202 let err = HashVerifier::new()
1203 .expect(&path, ExpectedHash::whole_sha1(sha1_of(b"hi")))
1204 .expect(&path, ExpectedHash::blocks_sha1(2, vec![sha1_of(b"hi")]))
1205 .execute()
1206 .unwrap_err();
1207 assert!(
1208 matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("conflicting")),
1209 "got {err:?}"
1210 );
1211 }
1212
1213 #[test]
1216 fn block_mode_exact_multiple_of_block_size_no_trailing() {
1217 let block_size: u64 = 64;
1218 let payload = vec![0xAAu8; (block_size as usize) * 3];
1219 let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
1220 assert_eq!(hashes.len(), 3);
1221 let (_d, path) = write_tmp(&payload);
1222 let report = HashVerifier::new()
1223 .expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
1224 .execute()
1225 .unwrap();
1226 assert!(report.is_clean(), "got {report:?}");
1227 }
1228
1229 #[test]
1230 fn block_mode_n_blocks_plus_one_byte_trailing() {
1231 let block_size: u64 = 64;
1232 let mut payload = vec![0xBBu8; (block_size as usize) * 3];
1233 payload.push(0xCC);
1234 let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
1235 assert_eq!(hashes.len(), 4);
1236 let (_d, path) = write_tmp(&payload);
1237 let report = HashVerifier::new()
1238 .expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
1239 .execute()
1240 .unwrap();
1241 assert!(report.is_clean(), "got {report:?}");
1242 }
1243
1244 #[test]
1245 fn block_mode_single_byte_file() {
1246 let (_d, path) = write_tmp(&[0x42]);
1247 let hashes = vec![sha1_of(&[0x42])];
1248 let report = HashVerifier::new()
1249 .expect(&path, ExpectedHash::blocks_sha1(1024, hashes))
1250 .execute()
1251 .unwrap();
1252 assert!(report.is_clean(), "got {report:?}");
1253 }
1254
1255 #[test]
1256 fn block_mode_block_size_one_each_byte_is_own_block() {
1257 let payload = b"abc";
1258 let hashes: Vec<Vec<u8>> = payload.iter().map(|b| sha1_of(&[*b])).collect();
1259 assert_eq!(hashes.len(), 3);
1260 let (_d, path) = write_tmp(payload);
1261 let report = HashVerifier::new()
1262 .expect(&path, ExpectedHash::blocks_sha1(1, hashes))
1263 .execute()
1264 .unwrap();
1265 assert!(report.is_clean(), "got {report:?}");
1266 }
1267
1268 #[test]
1271 fn block_hasher_state_does_not_bleed_between_identical_content_blocks() {
1272 let block_size: u64 = 32;
1278 let content = vec![0x5Au8; block_size as usize];
1279 let payload: Vec<u8> = content.iter().chain(content.iter()).copied().collect();
1280 let correct_hash = sha1_of(&content);
1281 let wrong_hash = vec![0u8; 20];
1282 assert_ne!(correct_hash, wrong_hash);
1283 let hashes = vec![correct_hash, wrong_hash];
1284 let (_d, path) = write_tmp(&payload);
1285 let report = HashVerifier::new()
1286 .expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
1287 .execute()
1288 .unwrap();
1289 match report.files.get(&path).unwrap() {
1290 FileVerifyOutcome::BlockMismatches {
1291 mismatched_blocks,
1292 expected_block_count,
1293 actual_block_count,
1294 } => {
1295 assert_eq!(mismatched_blocks, &vec![1]);
1296 assert_eq!(*expected_block_count, 2);
1297 assert_eq!(*actual_block_count, 2);
1298 }
1299 other => panic!("expected BlockMismatches for block 1 only, got {other:?}"),
1300 }
1301 }
1302
1303 #[test]
1306 fn path_with_spaces_and_utf8() {
1307 let dir = tempfile::tempdir().unwrap();
1308 let path = dir.path().join("file with spaces café.bin");
1309 let mut f = File::create(&path).unwrap();
1310 f.write_all(b"data").unwrap();
1311 f.sync_all().unwrap();
1312 let report = HashVerifier::new()
1313 .expect(&path, ExpectedHash::whole_sha1(sha1_of(b"data")))
1314 .execute()
1315 .unwrap();
1316 assert!(report.is_clean(), "got {report:?}");
1317 }
1318}