use crate::Result;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use sha1::{Digest, Sha1};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use tracing::{debug, debug_span, info, info_span, trace, warn};
const READ_BUF_CAPACITY: usize = 64 * 1024;
const SHA1_DIGEST_LEN: usize = 20;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HashAlgorithm {
Sha1,
}
impl HashAlgorithm {
#[must_use]
pub const fn digest_len(self) -> usize {
match self {
HashAlgorithm::Sha1 => SHA1_DIGEST_LEN,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExpectedHash {
Whole {
algorithm: HashAlgorithm,
hash: Vec<u8>,
},
Blocks {
algorithm: HashAlgorithm,
block_size: u64,
hashes: Vec<Vec<u8>>,
},
}
impl ExpectedHash {
#[must_use]
pub fn whole_sha1(hash: Vec<u8>) -> Self {
ExpectedHash::Whole {
algorithm: HashAlgorithm::Sha1,
hash,
}
}
#[must_use]
pub fn blocks_sha1(block_size: u64, hashes: Vec<Vec<u8>>) -> Self {
ExpectedHash::Blocks {
algorithm: HashAlgorithm::Sha1,
block_size,
hashes,
}
}
#[must_use]
pub fn algorithm(&self) -> HashAlgorithm {
match self {
ExpectedHash::Whole { algorithm, .. } | ExpectedHash::Blocks { algorithm, .. } => {
*algorithm
}
}
}
fn validate(&self) -> Result<()> {
let want = self.algorithm().digest_len();
match self {
ExpectedHash::Whole { hash, .. } => {
if hash.len() != want {
return Err(crate::ZiPatchError::InvalidField {
context: "ExpectedHash::Whole digest has wrong length for algorithm",
});
}
}
ExpectedHash::Blocks {
block_size, hashes, ..
} => {
if *block_size == 0 {
return Err(crate::ZiPatchError::InvalidField {
context: "ExpectedHash::Blocks block_size must be non-zero",
});
}
for h in hashes {
if h.len() != want {
return Err(crate::ZiPatchError::InvalidField {
context: "ExpectedHash::Blocks per-block digest has wrong length for algorithm",
});
}
}
}
}
Ok(())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileVerifyOutcome {
Match,
WholeMismatch {
expected: Vec<u8>,
actual: Vec<u8>,
},
BlockMismatches {
mismatched_blocks: Vec<usize>,
expected_block_count: usize,
actual_block_count: usize,
},
Missing,
IoError {
kind: std::io::ErrorKind,
message: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HashVerifyReport {
pub files: BTreeMap<PathBuf, FileVerifyOutcome>,
}
impl HashVerifyReport {
#[must_use]
pub fn is_clean(&self) -> bool {
self.files
.values()
.all(|o| matches!(o, FileVerifyOutcome::Match))
}
pub fn failures(&self) -> impl Iterator<Item = (&Path, &FileVerifyOutcome)> {
self.files
.iter()
.filter(|(_, o)| !matches!(o, FileVerifyOutcome::Match))
.map(|(p, o)| (p.as_path(), o))
}
#[must_use]
pub fn failure_count(&self) -> usize {
self.failures().count()
}
}
#[derive(Debug, Default)]
pub struct HashVerifier {
tasks: Vec<(PathBuf, ExpectedHash)>,
}
impl HashVerifier {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn expect(mut self, path: impl Into<PathBuf>, expected: ExpectedHash) -> Self {
self.tasks.push((path.into(), expected));
self
}
pub fn execute(self) -> Result<HashVerifyReport> {
let span = info_span!("verify_hashes", files = self.tasks.len());
let _enter = span.enter();
let started = std::time::Instant::now();
for (_, exp) in &self.tasks {
exp.validate()?;
}
let mut seen: BTreeMap<&Path, &ExpectedHash> = BTreeMap::new();
for (path, exp) in &self.tasks {
match seen.get(path.as_path()) {
Some(prev) if *prev == exp => {}
Some(_) => {
return Err(crate::ZiPatchError::InvalidField {
context: "HashVerifier: same path registered with conflicting ExpectedHash values",
});
}
None => {
seen.insert(path.as_path(), exp);
}
}
}
let mut report = HashVerifyReport::default();
let parent = &span;
let results: Vec<(PathBuf, FileVerifyOutcome, u64)> = self
.tasks
.into_par_iter()
.map(|(path, expected)| {
parent.in_scope(|| {
let sub = debug_span!("verify_file", path = %path.display());
let _e = sub.enter();
let mut scratch = vec![0u8; READ_BUF_CAPACITY];
let (outcome, bytes) = verify_one(&path, &expected, &mut scratch);
match &outcome {
FileVerifyOutcome::Match => {
debug!(bytes_hashed = bytes, "verify_hashes: file match");
}
FileVerifyOutcome::Missing => {
warn!("verify_hashes: file missing");
}
FileVerifyOutcome::IoError { kind, message } => {
warn!(?kind, error = %message, "verify_hashes: io error during hash");
}
FileVerifyOutcome::WholeMismatch { .. } => {
debug!(bytes_hashed = bytes, "verify_hashes: whole-file mismatch");
}
FileVerifyOutcome::BlockMismatches {
mismatched_blocks, ..
} => {
debug!(
bytes_hashed = bytes,
bad_blocks = mismatched_blocks.len(),
"verify_hashes: block-mode mismatches"
);
}
}
(path, outcome, bytes)
})
})
.collect();
let mut total_bytes: u64 = 0;
for (path, outcome, bytes) in results {
total_bytes += bytes;
report.files.insert(path, outcome);
}
let failures = report.failure_count();
info!(
files = report.files.len(),
failures,
bytes_hashed = total_bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
"verify_hashes: run complete"
);
Ok(report)
}
}
fn verify_one(
path: &Path,
expected: &ExpectedHash,
scratch: &mut [u8],
) -> (FileVerifyOutcome, u64) {
let mut file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (FileVerifyOutcome::Missing, 0);
}
Err(e) => {
return (
FileVerifyOutcome::IoError {
kind: e.kind(),
message: e.to_string(),
},
0,
);
}
};
match expected {
ExpectedHash::Whole { algorithm, hash } => match hash_whole(*algorithm, &mut file, scratch)
{
Ok((actual, n)) => {
if actual.as_slice() == hash.as_slice() {
(FileVerifyOutcome::Match, n)
} else {
(
FileVerifyOutcome::WholeMismatch {
expected: hash.clone(),
actual,
},
n,
)
}
}
Err(e) => (
FileVerifyOutcome::IoError {
kind: e.kind(),
message: e.to_string(),
},
0,
),
},
ExpectedHash::Blocks {
algorithm,
block_size,
hashes,
} => hash_blocks(*algorithm, &mut file, *block_size, hashes, scratch),
}
}
fn hash_whole<R: Read>(
algo: HashAlgorithm,
reader: &mut R,
scratch: &mut [u8],
) -> std::io::Result<(Vec<u8>, u64)> {
match algo {
HashAlgorithm::Sha1 => {
let mut hasher = Sha1::new();
let mut total: u64 = 0;
loop {
let n = reader.read(scratch)?;
if n == 0 {
break;
}
hasher.update(&scratch[..n]);
total += n as u64;
trace!(chunk_bytes = n, "verify_hashes: whole-file chunk");
}
Ok((hasher.finalize().to_vec(), total))
}
}
}
fn hash_blocks<R: Read>(
algo: HashAlgorithm,
reader: &mut R,
block_size: u64,
expected: &[Vec<u8>],
scratch: &mut [u8],
) -> (FileVerifyOutcome, u64) {
let mut mismatched: Vec<usize> = Vec::new();
let mut block_idx: usize = 0;
let mut total_bytes: u64 = 0;
let mut hasher = block_hasher(algo);
let mut block_bytes_remaining: u64 = block_size;
let mut block_had_bytes = false;
loop {
let want = block_bytes_remaining.min(scratch.len() as u64) as usize;
if want == 0 {
finish_and_compare(algo, &mut hasher, block_idx, expected, &mut mismatched);
block_idx += 1;
block_bytes_remaining = block_size;
block_had_bytes = false;
continue;
}
let n = match reader.read(&mut scratch[..want]) {
Ok(n) => n,
Err(e) => {
return (
FileVerifyOutcome::IoError {
kind: e.kind(),
message: e.to_string(),
},
total_bytes,
);
}
};
if n == 0 {
if block_had_bytes {
finish_and_compare(algo, &mut hasher, block_idx, expected, &mut mismatched);
block_idx += 1;
}
break;
}
match &mut hasher {
BlockHasher::Sha1(h) => h.update(&scratch[..n]),
}
total_bytes += n as u64;
block_bytes_remaining -= n as u64;
block_had_bytes = true;
trace!(block_idx, chunk_bytes = n, "verify_hashes: block chunk");
}
for missing in block_idx..expected.len() {
mismatched.push(missing);
}
let actual_block_count = block_idx;
let expected_block_count = expected.len();
let outcome = if mismatched.is_empty() && actual_block_count == expected_block_count {
FileVerifyOutcome::Match
} else {
mismatched.sort_unstable();
mismatched.dedup();
FileVerifyOutcome::BlockMismatches {
mismatched_blocks: mismatched,
expected_block_count,
actual_block_count,
}
};
(outcome, total_bytes)
}
enum BlockHasher {
Sha1(Sha1),
}
fn block_hasher(algo: HashAlgorithm) -> BlockHasher {
match algo {
HashAlgorithm::Sha1 => BlockHasher::Sha1(Sha1::new()),
}
}
fn finish_and_compare(
algo: HashAlgorithm,
hasher: &mut BlockHasher,
block_idx: usize,
expected: &[Vec<u8>],
mismatched: &mut Vec<usize>,
) {
let finished = std::mem::replace(hasher, block_hasher(algo));
let digest: Vec<u8> = match finished {
BlockHasher::Sha1(h) => h.finalize().to_vec(),
};
match expected.get(block_idx) {
Some(want) if want.as_slice() == digest.as_slice() => {}
_ => mismatched.push(block_idx),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn sha1_of(bytes: &[u8]) -> Vec<u8> {
let mut h = Sha1::new();
h.update(bytes);
h.finalize().to_vec()
}
fn write_tmp(bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("f.bin");
let mut f = File::create(&path).unwrap();
f.write_all(bytes).unwrap();
f.sync_all().unwrap();
(dir, path)
}
#[test]
fn report_is_clean_when_empty() {
let r = HashVerifyReport::default();
assert!(r.is_clean());
assert_eq!(r.failure_count(), 0);
assert_eq!(r.failures().count(), 0);
}
#[test]
fn whole_sha1_match() {
let payload = b"hello world".repeat(1000);
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(sha1_of(&payload)))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn whole_sha1_mismatch() {
let (_d, path) = write_tmp(b"abc");
let bad = vec![0u8; 20];
let report = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(bad.clone()))
.execute()
.unwrap();
assert!(!report.is_clean());
match report.files.get(&path).unwrap() {
FileVerifyOutcome::WholeMismatch { expected, actual } => {
assert_eq!(expected, &bad);
assert_eq!(actual, &sha1_of(b"abc"));
}
other => panic!("expected WholeMismatch, got {other:?}"),
}
}
#[test]
fn block_mode_match() {
let block_size: u64 = 256;
let mut payload = Vec::new();
for i in 0..5u8 {
payload.extend(std::iter::repeat_n(i, block_size as usize));
}
payload.extend_from_slice(&[0xAB; 17]);
let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, hashes.clone()))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
assert_eq!(hashes.len(), 6); }
#[test]
fn block_mode_specific_block_mismatch() {
let block_size: u64 = 128;
let mut payload = vec![0u8; (block_size as usize) * 4];
let clean = payload.clone();
payload[(block_size as usize) * 2 + 7] = 0xFF;
let expected: Vec<Vec<u8>> = clean.chunks(block_size as usize).map(sha1_of).collect();
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
.execute()
.unwrap();
match report.files.get(&path).unwrap() {
FileVerifyOutcome::BlockMismatches {
mismatched_blocks,
expected_block_count,
actual_block_count,
} => {
assert_eq!(mismatched_blocks, &vec![2]);
assert_eq!(*expected_block_count, 4);
assert_eq!(*actual_block_count, 4);
}
other => panic!("expected BlockMismatches, got {other:?}"),
}
}
#[test]
fn missing_file_reported() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let report = HashVerifier::new()
.expect(&missing, ExpectedHash::whole_sha1(vec![0u8; 20]))
.execute()
.unwrap();
assert_eq!(
report.files.get(&missing).unwrap(),
&FileVerifyOutcome::Missing
);
assert!(!report.is_clean());
}
#[test]
fn block_mode_file_shorter_than_expected_flags_trailing_missing_blocks() {
let block_size: u64 = 64;
let payload = vec![0u8; (block_size as usize) * 2];
let expected: Vec<Vec<u8>> = payload
.chunks(block_size as usize)
.map(sha1_of)
.chain(std::iter::repeat_n(vec![0u8; 20], 2))
.collect();
assert_eq!(expected.len(), 4);
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
.execute()
.unwrap();
match report.files.get(&path).unwrap() {
FileVerifyOutcome::BlockMismatches {
mismatched_blocks,
expected_block_count,
actual_block_count,
} => {
assert_eq!(*expected_block_count, 4);
assert_eq!(*actual_block_count, 2);
assert_eq!(mismatched_blocks, &vec![2, 3]);
}
other => panic!("expected BlockMismatches, got {other:?}"),
}
}
#[test]
fn block_mode_file_longer_than_expected_flags_extra_blocks() {
let block_size: u64 = 32;
let payload = vec![0u8; (block_size as usize) * 4];
let expected: Vec<Vec<u8>> = payload
.chunks(block_size as usize)
.take(2)
.map(sha1_of)
.collect();
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
.execute()
.unwrap();
match report.files.get(&path).unwrap() {
FileVerifyOutcome::BlockMismatches {
mismatched_blocks,
expected_block_count,
actual_block_count,
} => {
assert_eq!(*expected_block_count, 2);
assert_eq!(*actual_block_count, 4);
assert_eq!(mismatched_blocks, &vec![2, 3]);
}
other => panic!("expected BlockMismatches, got {other:?}"),
}
}
#[test]
fn empty_file_whole_mode_matches_sha1_of_empty() {
let (_d, path) = write_tmp(&[]);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(sha1_of(&[])))
.execute()
.unwrap();
assert!(report.is_clean());
}
#[test]
fn empty_file_block_mode_matches_zero_blocks() {
let (_d, path) = write_tmp(&[]);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(1024, vec![]))
.execute()
.unwrap();
assert!(report.is_clean());
}
#[test]
fn zero_block_size_is_rejected_up_front() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("any");
let err = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(0, vec![]))
.execute()
.unwrap_err();
assert!(
matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("block_size")),
"got {err:?}"
);
}
#[test]
fn whole_mode_wrong_digest_length_is_rejected_up_front() {
let (_d, path) = write_tmp(b"x");
let err = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(vec![0u8; 19]))
.execute()
.unwrap_err();
assert!(
matches!(err, crate::ZiPatchError::InvalidField { .. }),
"got {err:?}"
);
}
#[test]
fn block_mode_wrong_per_block_digest_length_is_rejected_up_front() {
let (_d, path) = write_tmp(b"y");
let bad = ExpectedHash::Blocks {
algorithm: HashAlgorithm::Sha1,
block_size: 16,
hashes: vec![vec![0u8; 19]],
};
let err = HashVerifier::new()
.expect(&path, bad)
.execute()
.unwrap_err();
assert!(matches!(err, crate::ZiPatchError::InvalidField { .. }));
}
#[test]
fn block_mode_block_size_exceeds_read_buf_capacity_match() {
let block_size: u64 = 200 * 1024;
let mut payload = Vec::with_capacity((block_size as usize) * 3 + 17);
for i in 0..3u8 {
payload.extend(std::iter::repeat_n(i.wrapping_mul(31), block_size as usize));
}
payload.extend_from_slice(&[0xCD; 17]);
let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
assert_eq!(hashes.len(), 4);
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn block_mode_block_size_exceeds_read_buf_capacity_mismatch() {
let block_size: u64 = 200 * 1024;
let mut payload = Vec::with_capacity((block_size as usize) * 3);
for i in 0..3u8 {
payload.extend(std::iter::repeat_n(i.wrapping_mul(17), block_size as usize));
}
let clean = payload.clone();
payload[(block_size as usize) + 150 * 1024] ^= 0xFF;
let expected: Vec<Vec<u8>> = clean.chunks(block_size as usize).map(sha1_of).collect();
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, expected))
.execute()
.unwrap();
match report.files.get(&path).unwrap() {
FileVerifyOutcome::BlockMismatches {
mismatched_blocks,
expected_block_count,
actual_block_count,
} => {
assert_eq!(mismatched_blocks, &vec![1]);
assert_eq!(*expected_block_count, 3);
assert_eq!(*actual_block_count, 3);
}
other => panic!("expected BlockMismatches, got {other:?}"),
}
}
#[test]
fn block_mode_single_short_block_distinguishes_from_empty_file() {
let block_size: u64 = 200 * 1024;
let payload = vec![0x7Eu8; 1000]; let hashes = vec![sha1_of(&payload)];
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[cfg(target_family = "unix")]
#[test]
fn permission_denied_open_reports_io_error_with_kind() {
use std::os::unix::fs::PermissionsExt;
let (_d, path) = write_tmp(b"forbidden");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if File::open(&path).is_ok() {
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
eprintln!("skipping: running with CAP_DAC_OVERRIDE, chmod 0o000 does not block open");
return;
}
let report = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(vec![0u8; 20]))
.execute()
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
match report.files.get(&path).unwrap() {
FileVerifyOutcome::IoError { kind, message } => {
assert_eq!(*kind, std::io::ErrorKind::PermissionDenied, "got {kind:?}");
assert!(!message.is_empty(), "message should carry the error text");
}
other => panic!("expected IoError with PermissionDenied kind, got {other:?}"),
}
}
#[test]
fn duplicate_identical_registration_is_noop() {
let (_d, path) = write_tmp(b"abc");
let expected = ExpectedHash::whole_sha1(sha1_of(b"abc"));
let report = HashVerifier::new()
.expect(&path, expected.clone())
.expect(&path, expected)
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
assert_eq!(report.files.len(), 1);
}
#[test]
fn duplicate_conflicting_registration_errors() {
let (_d, path) = write_tmp(b"abc");
let err = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(sha1_of(b"abc")))
.expect(&path, ExpectedHash::whole_sha1(vec![0u8; 20]))
.execute()
.unwrap_err();
assert!(
matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("conflicting")),
"got {err:?}"
);
}
#[test]
fn failures_iter_excludes_matches() {
let (_d1, ok) = write_tmp(b"a");
let (_d2, bad) = write_tmp(b"b");
let report = HashVerifier::new()
.expect(&ok, ExpectedHash::whole_sha1(sha1_of(b"a")))
.expect(&bad, ExpectedHash::whole_sha1(vec![0u8; 20]))
.execute()
.unwrap();
let fails: Vec<_> = report.failures().collect();
assert_eq!(fails.len(), 1);
assert_eq!(fails[0].0, bad.as_path());
}
struct FailAfter {
remaining_ok: usize,
kind: std::io::ErrorKind,
}
impl Read for FailAfter {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.remaining_ok == 0 {
return Err(std::io::Error::new(self.kind, "injected"));
}
let n = self.remaining_ok.min(buf.len());
buf[..n].fill(0);
self.remaining_ok -= n;
Ok(n)
}
}
#[test]
fn hash_whole_propagates_mid_read_io_error() {
let mut reader = FailAfter {
remaining_ok: 32,
kind: std::io::ErrorKind::Other,
};
let mut scratch = vec![0u8; 16];
let err = hash_whole(HashAlgorithm::Sha1, &mut reader, &mut scratch).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::Other);
}
#[test]
fn hash_blocks_surfaces_mid_read_io_error_as_outcome() {
let mut reader = FailAfter {
remaining_ok: 40,
kind: std::io::ErrorKind::ConnectionAborted,
};
let mut scratch = vec![0u8; 16];
let expected = vec![vec![0u8; 20]; 4];
let (outcome, bytes) = hash_blocks(
HashAlgorithm::Sha1,
&mut reader,
64,
&expected,
&mut scratch,
);
match outcome {
FileVerifyOutcome::IoError { kind, .. } => {
assert_eq!(kind, std::io::ErrorKind::ConnectionAborted);
}
other => panic!("expected IoError outcome, got {other:?}"),
}
assert_eq!(
bytes, 40,
"bytes hashed up to the failure should be reported"
);
}
#[test]
fn execute_with_no_tasks_returns_clean_empty_report() {
let report = HashVerifier::new().execute().unwrap();
assert!(report.is_clean());
assert_eq!(report.files.len(), 0);
assert_eq!(report.failure_count(), 0);
}
#[test]
fn report_nonempty_all_match_is_clean() {
let (_d1, p1) = write_tmp(b"one");
let (_d2, p2) = write_tmp(b"two");
let report = HashVerifier::new()
.expect(&p1, ExpectedHash::whole_sha1(sha1_of(b"one")))
.expect(&p2, ExpectedHash::whole_sha1(sha1_of(b"two")))
.execute()
.unwrap();
assert_eq!(report.files.len(), 2);
assert!(report.is_clean());
assert_eq!(report.failure_count(), 0);
assert_eq!(report.failures().count(), 0);
}
#[test]
fn failure_count_equals_failures_iter_count() {
let (_d1, ok) = write_tmp(b"good");
let (_d2, bad1) = write_tmp(b"bad1");
let (_d3, bad2) = write_tmp(b"bad2");
let report = HashVerifier::new()
.expect(&ok, ExpectedHash::whole_sha1(sha1_of(b"good")))
.expect(&bad1, ExpectedHash::whole_sha1(vec![0u8; 20]))
.expect(&bad2, ExpectedHash::whole_sha1(vec![0u8; 20]))
.execute()
.unwrap();
assert_eq!(report.failure_count(), report.failures().count());
assert_eq!(report.failure_count(), 2);
}
#[test]
fn report_files_iteration_order_is_by_path() {
let dir = tempfile::tempdir().unwrap();
let pb = dir.path().join("b.bin");
let pa = dir.path().join("a.bin");
let pc = dir.path().join("c.bin");
for p in [&pb, &pa, &pc] {
let mut f = File::create(p).unwrap();
f.write_all(b"x").unwrap();
}
let report = HashVerifier::new()
.expect(&pb, ExpectedHash::whole_sha1(sha1_of(b"x")))
.expect(&pa, ExpectedHash::whole_sha1(sha1_of(b"x")))
.expect(&pc, ExpectedHash::whole_sha1(sha1_of(b"x")))
.execute()
.unwrap();
let keys: Vec<&PathBuf> = report.files.keys().collect();
assert_eq!(keys[0], &pa);
assert_eq!(keys[1], &pb);
assert_eq!(keys[2], &pc);
}
#[test]
fn file_verify_outcome_clone_and_partialeq() {
let outcomes = [
FileVerifyOutcome::Match,
FileVerifyOutcome::Missing,
FileVerifyOutcome::WholeMismatch {
expected: vec![0u8; 20],
actual: vec![1u8; 20],
},
FileVerifyOutcome::BlockMismatches {
mismatched_blocks: vec![0, 2],
expected_block_count: 3,
actual_block_count: 3,
},
FileVerifyOutcome::IoError {
kind: std::io::ErrorKind::Other,
message: "oops".to_string(),
},
];
for o in &outcomes {
let cloned = o.clone();
assert_eq!(o, &cloned, "Clone+PartialEq round-trip failed for {o:?}");
}
assert_ne!(
FileVerifyOutcome::Match,
FileVerifyOutcome::Missing,
"distinct variants must not compare equal"
);
}
#[test]
fn blocks_validate_valid_then_invalid_hash_surfaces_error() {
let (_d, path) = write_tmp(b"z");
let bad = ExpectedHash::Blocks {
algorithm: HashAlgorithm::Sha1,
block_size: 8,
hashes: vec![
vec![0u8; 20], vec![0u8; 5], ],
};
let err = HashVerifier::new()
.expect(&path, bad)
.execute()
.unwrap_err();
assert!(matches!(err, crate::ZiPatchError::InvalidField { .. }));
}
#[test]
fn many_chained_expects_all_evaluated() {
let dir = tempfile::tempdir().unwrap();
let n = 10usize;
let mut builder = HashVerifier::new();
let mut paths = Vec::with_capacity(n);
for i in 0..n {
let p = dir.path().join(format!("f{i}.bin"));
let mut f = File::create(&p).unwrap();
f.write_all(&[i as u8]).unwrap();
builder = builder.expect(&p, ExpectedHash::whole_sha1(sha1_of(&[i as u8])));
paths.push(p);
}
let report = builder.execute().unwrap();
assert_eq!(report.files.len(), n);
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn whole_then_blocks_registration_for_same_path_conflicts() {
let (_d, path) = write_tmp(b"hi");
let err = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(sha1_of(b"hi")))
.expect(&path, ExpectedHash::blocks_sha1(2, vec![sha1_of(b"hi")]))
.execute()
.unwrap_err();
assert!(
matches!(err, crate::ZiPatchError::InvalidField { context } if context.contains("conflicting")),
"got {err:?}"
);
}
#[test]
fn block_mode_exact_multiple_of_block_size_no_trailing() {
let block_size: u64 = 64;
let payload = vec![0xAAu8; (block_size as usize) * 3];
let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
assert_eq!(hashes.len(), 3);
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn block_mode_n_blocks_plus_one_byte_trailing() {
let block_size: u64 = 64;
let mut payload = vec![0xBBu8; (block_size as usize) * 3];
payload.push(0xCC);
let hashes: Vec<Vec<u8>> = payload.chunks(block_size as usize).map(sha1_of).collect();
assert_eq!(hashes.len(), 4);
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn block_mode_single_byte_file() {
let (_d, path) = write_tmp(&[0x42]);
let hashes = vec![sha1_of(&[0x42])];
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(1024, hashes))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn block_mode_block_size_one_each_byte_is_own_block() {
let payload = b"abc";
let hashes: Vec<Vec<u8>> = payload.iter().map(|b| sha1_of(&[*b])).collect();
assert_eq!(hashes.len(), 3);
let (_d, path) = write_tmp(payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(1, hashes))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn block_hasher_state_does_not_bleed_between_identical_content_blocks() {
let block_size: u64 = 32;
let content = vec![0x5Au8; block_size as usize];
let payload: Vec<u8> = content.iter().chain(content.iter()).copied().collect();
let correct_hash = sha1_of(&content);
let wrong_hash = vec![0u8; 20];
assert_ne!(correct_hash, wrong_hash);
let hashes = vec![correct_hash, wrong_hash];
let (_d, path) = write_tmp(&payload);
let report = HashVerifier::new()
.expect(&path, ExpectedHash::blocks_sha1(block_size, hashes))
.execute()
.unwrap();
match report.files.get(&path).unwrap() {
FileVerifyOutcome::BlockMismatches {
mismatched_blocks,
expected_block_count,
actual_block_count,
} => {
assert_eq!(mismatched_blocks, &vec![1]);
assert_eq!(*expected_block_count, 2);
assert_eq!(*actual_block_count, 2);
}
other => panic!("expected BlockMismatches for block 1 only, got {other:?}"),
}
}
#[test]
fn path_with_spaces_and_utf8() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("file with spaces café.bin");
let mut f = File::create(&path).unwrap();
f.write_all(b"data").unwrap();
f.sync_all().unwrap();
let report = HashVerifier::new()
.expect(&path, ExpectedHash::whole_sha1(sha1_of(b"data")))
.execute()
.unwrap();
assert!(report.is_clean(), "got {report:?}");
}
#[test]
fn parallel_fan_out_report_is_deterministic_and_sorted() {
const N: usize = 32;
let dir = tempfile::tempdir().unwrap();
let mut builder = HashVerifier::new();
let mut expected_failures = 0usize;
let mut paths: Vec<PathBuf> = Vec::with_capacity(N);
for i in 0..N {
let p = dir.path().join(format!("file_{i:03}.bin"));
let payload = vec![i as u8; 1024 * 1024];
let mut f = File::create(&p).unwrap();
f.write_all(&payload).unwrap();
f.sync_all().unwrap();
let hash = if i % 2 == 0 {
sha1_of(&payload)
} else {
expected_failures += 1;
vec![0u8; 20]
};
builder = builder.expect(&p, ExpectedHash::whole_sha1(hash));
paths.push(p);
}
let run1 = builder.execute().unwrap();
assert_eq!(run1.files.len(), N);
assert_eq!(run1.failure_count(), expected_failures);
let keys: Vec<&PathBuf> = run1.files.keys().collect();
for w in keys.windows(2) {
assert!(w[0] < w[1], "BTreeMap keys out of order: {w:?}");
}
let mut builder2 = HashVerifier::new();
for (i, p) in paths.iter().enumerate() {
let payload = vec![i as u8; 1024 * 1024];
let hash = if i % 2 == 0 {
sha1_of(&payload)
} else {
vec![0u8; 20]
};
builder2 = builder2.expect(p, ExpectedHash::whole_sha1(hash));
}
let run2 = builder2.execute().unwrap();
assert_eq!(run1, run2, "two equivalent runs produced different reports");
}
#[test]
fn parallel_fan_out_shuffled_registration_order_report_sorted() {
const N: usize = 32;
let dir = tempfile::tempdir().unwrap();
let indices: Vec<usize> = (0..N).rev().collect();
let mut builder = HashVerifier::new();
let mut paths: Vec<PathBuf> = Vec::with_capacity(N);
for i in 0..N {
let p = dir.path().join(format!("z_{i:03}.bin"));
let mut f = File::create(&p).unwrap();
f.write_all(&[i as u8]).unwrap();
f.sync_all().unwrap();
paths.push(p);
}
for &i in &indices {
let payload = [i as u8];
builder = builder.expect(&paths[i], ExpectedHash::whole_sha1(sha1_of(&payload)));
}
let report = builder.execute().unwrap();
assert_eq!(report.files.len(), N);
assert!(report.is_clean(), "all files should match; got {report:?}");
let keys: Vec<&PathBuf> = report.files.keys().collect();
for w in keys.windows(2) {
assert!(w[0] < w[1], "report keys not sorted: {w:?}");
}
}
}