Skip to main content

zipatch_rs/verify/
mod.rs

1//! Post-apply integrity check for files produced by either the sequential
2//! [`apply_to`](crate::ZiPatchReader::apply_to) driver or the indexed
3//! [`IndexApplier::execute`](crate::index::IndexApplier::execute) driver.
4//!
5//! The per-chunk CRC32 the parser already enforces catches transit corruption
6//! of the patch stream itself, but it cannot detect silent corruption of the
7//! *resulting* `SqPack` files on disk. Square Enix's patch lists carry SHA1
8//! hashes for the post-apply `.index` / `.dat` files (whole-file or split into
9//! fixed-size blocks); [`HashVerifier`] reads those files back from disk and
10//! compares against caller-supplied expected hashes.
11//!
12//! This is a separate verification step the caller invokes **after**
13//! [`apply_to`](crate::ZiPatchReader::apply_to) or
14//! [`IndexApplier::execute`](crate::index::IndexApplier::execute) returns
15//! `Ok`. The library never bakes hash verification into the apply loop —
16//! parsing the SE patch list to build the expected-hash input is the
17//! consumer's responsibility (in practice, `gaveloc-patcher`).
18//!
19//! # Modes
20//!
21//! - **Whole-file** ([`ExpectedHash::Whole`]) — single hash over the entire
22//!   file. Cheap to express; an opaque single failure for multi-GiB files.
23//! - **Block-mode** ([`ExpectedHash::Blocks`]) — file is split into
24//!   fixed-size blocks (the SE patch list uses 50 MiB); one hash per block.
25//!   Pinpoints *which* block is bad, so a user-facing repair flow can
26//!   re-fetch a narrow range rather than the whole file.
27//!
28//! Both modes share a [`HashAlgorithm`] discriminant. Only SHA1 is supported
29//! today; the enum is `#[non_exhaustive]` so future algorithms can be added
30//! without a `SemVer` break.
31//!
32//! # Example
33//!
34//! ```no_run
35//! use zipatch_rs::verify::{ExpectedHash, HashAlgorithm, HashVerifier};
36//!
37//! let report = HashVerifier::new()
38//!     .expect(
39//!         "/opt/ffxiv/game/sqpack/ffxiv/000000.win32.index",
40//!         ExpectedHash::whole_sha1(vec![0u8; 20]),
41//!     )
42//!     .execute()
43//!     .unwrap();
44//!
45//! if !report.is_clean() {
46//!     for (path, outcome) in report.failures() {
47//!         eprintln!("{}: {outcome:?}", path.display());
48//!     }
49//! }
50//! # let _ = HashAlgorithm::Sha1;
51//! ```
52
53use 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/// Hash algorithm tag carried on an [`ExpectedHash`].
65///
66/// Only SHA1 is implemented today — it is what FFXIV patch lists carry.
67/// `#[non_exhaustive]` reserves room for future additions (e.g. SHA256) to
68/// land as a minor-version, non-breaking addition.
69#[non_exhaustive]
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum HashAlgorithm {
72    /// SHA-1, the algorithm Square Enix's patch list carries.
73    Sha1,
74}
75
76impl HashAlgorithm {
77    /// Expected digest length in bytes.
78    #[must_use]
79    pub const fn digest_len(self) -> usize {
80        match self {
81            HashAlgorithm::Sha1 => SHA1_DIGEST_LEN,
82        }
83    }
84}
85
86/// Expected hash spec for a single file.
87///
88/// Either a single whole-file digest, or a fixed-block-size digest per block.
89/// Block-mode is what FFXIV patch lists actually carry for `.dat` files
90/// (50 MiB blocks), because it pinpoints *which* block is bad. Whole-file
91/// mode is the natural fit for small files (e.g. `.index` files), where a
92/// single mismatched bit is best surfaced as a single failure.
93///
94/// # Stability
95///
96/// `#[non_exhaustive]` — future hash-spec shapes may be added without a
97/// `SemVer` break.
98#[non_exhaustive]
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum ExpectedHash {
101    /// Whole-file hash mode: a single `algorithm` digest over the full file.
102    Whole {
103        /// Hash algorithm used.
104        algorithm: HashAlgorithm,
105        /// Expected digest bytes. Length must equal `algorithm.digest_len()`.
106        hash: Vec<u8>,
107    },
108    /// Block-mode hash: file is split into `block_size`-byte chunks, each
109    /// hashed independently. The last block may be shorter than `block_size`.
110    Blocks {
111        /// Hash algorithm used.
112        algorithm: HashAlgorithm,
113        /// Block size in bytes. Must be non-zero.
114        block_size: u64,
115        /// One digest per block, in file order. Each digest's length must
116        /// equal `algorithm.digest_len()`.
117        hashes: Vec<Vec<u8>>,
118    },
119}
120
121impl ExpectedHash {
122    /// Construct a whole-file SHA1 spec from a 20-byte digest.
123    #[must_use]
124    pub fn whole_sha1(hash: Vec<u8>) -> Self {
125        ExpectedHash::Whole {
126            algorithm: HashAlgorithm::Sha1,
127            hash,
128        }
129    }
130
131    /// Construct a block-mode SHA1 spec.
132    #[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    /// Hash algorithm in use.
142    #[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/// Per-file outcome of a [`HashVerifier::execute`] run.
183///
184/// `#[non_exhaustive]` so future outcome shapes (e.g. permission-denied vs
185/// generic IO) can be split without a `SemVer` break.
186#[non_exhaustive]
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum FileVerifyOutcome {
189    /// File matched the expected hash (whole-file mode) or every block matched
190    /// (block-mode).
191    Match,
192    /// Whole-file mode: the computed digest did not equal the expected digest.
193    WholeMismatch {
194        /// Expected digest.
195        expected: Vec<u8>,
196        /// Digest computed over the on-disk file.
197        actual: Vec<u8>,
198    },
199    /// Block-mode: one or more blocks failed.
200    ///
201    /// `mismatched_blocks` holds the zero-based indices of blocks whose hash
202    /// did not match, in ascending order. `expected_block_count` is the number
203    /// of block hashes the caller supplied. `actual_block_count` is the number
204    /// of blocks the file would contain at `block_size` (i.e. `ceil(size /
205    /// block_size)`); a difference means the file is shorter or longer than
206    /// the caller's expectation and every "extra" or "missing" block index is
207    /// reported in `mismatched_blocks`.
208    BlockMismatches {
209        /// Zero-based indices of mismatched blocks, ascending.
210        mismatched_blocks: Vec<usize>,
211        /// Number of block hashes the caller supplied.
212        expected_block_count: usize,
213        /// Number of blocks the on-disk file would split into at `block_size`.
214        actual_block_count: usize,
215    },
216    /// The file does not exist on disk.
217    Missing,
218    /// An I/O error occurred while reading the file. `kind` is the
219    /// [`std::io::ErrorKind`] callers branch on (e.g. [`std::io::ErrorKind::PermissionDenied`]
220    /// to prompt for elevation, [`std::io::ErrorKind::NotFound`] is reported
221    /// as [`FileVerifyOutcome::Missing`] instead). `message` is the
222    /// `std::io::Error` `Display` rendering, preserved as a string so the
223    /// report stays `Clone + PartialEq` for downstream consumers.
224    IoError {
225        /// `std::io::ErrorKind` of the underlying error.
226        kind: std::io::ErrorKind,
227        /// Human-readable rendering of the error.
228        message: String,
229    },
230}
231
232/// Structured outcome of a [`HashVerifier::execute`] run.
233///
234/// One entry per file the caller registered via [`HashVerifier::expect`].
235/// Iteration order is by [`PathBuf`] ordering (the underlying `BTreeMap`).
236///
237/// `#[non_exhaustive]`: future per-run aggregate fields may be added.
238#[non_exhaustive]
239#[derive(Debug, Clone, PartialEq, Eq, Default)]
240pub struct HashVerifyReport {
241    /// Per-file outcome, keyed by the absolute path the caller registered.
242    pub files: BTreeMap<PathBuf, FileVerifyOutcome>,
243}
244
245impl HashVerifyReport {
246    /// `true` iff every registered file matched.
247    #[must_use]
248    pub fn is_clean(&self) -> bool {
249        self.files
250            .values()
251            .all(|o| matches!(o, FileVerifyOutcome::Match))
252    }
253
254    /// Iterate the failing files (everything that is not [`FileVerifyOutcome::Match`]).
255    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    /// Count of failing files.
263    #[must_use]
264    pub fn failure_count(&self) -> usize {
265        self.failures().count()
266    }
267}
268
269/// Build up a set of `(path, expected_hash)` pairs, then [`Self::execute`] to
270/// hash the on-disk files and compare against the expected values.
271///
272/// The verifier never writes — it opens each registered file read-only, hashes
273/// it (whole-file or per-block), and produces a [`HashVerifyReport`]. Missing
274/// files and I/O errors during read are recorded as per-file outcomes rather
275/// than aborting the run — consumers want the full picture in a single pass.
276///
277/// # Error semantics
278///
279/// `execute` returns `Err` only for *programmer* errors detected up front
280/// (e.g. a zero `block_size`, or a digest whose length does not match its
281/// declared algorithm). Filesystem errors against the registered paths are
282/// captured per-file in [`FileVerifyOutcome::IoError`] / [`FileVerifyOutcome::Missing`].
283///
284/// # Security
285///
286/// Files are opened via [`std::fs::File::open`], which follows symbolic
287/// links on every platform `zipatch-rs` supports. The verifier itself never
288/// writes — the worst-case outcome of a hostile symlink pointed at a file
289/// outside the install root is an information-disclosure-via-hash: the
290/// target file's SHA1 would appear in the report's
291/// [`FileVerifyOutcome::WholeMismatch`] `actual` field.
292///
293/// If the caller derives registered paths from untrusted input (e.g. a
294/// patch-list response from a server that could be tampered with), it is
295/// **the caller's responsibility** to canonicalize the install root and
296/// reject paths that escape it before passing them to [`Self::expect`].
297/// `zipatch-rs` does not canonicalize or symlink-fence on the caller's
298/// behalf, because the appropriate root depends on the consumer's install
299/// layout.
300#[derive(Debug, Default)]
301pub struct HashVerifier {
302    tasks: Vec<(PathBuf, ExpectedHash)>,
303}
304
305impl HashVerifier {
306    /// Construct an empty verifier.
307    #[must_use]
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    /// Register `path` with `expected`.
313    ///
314    /// Registering the same path twice with **identical** [`ExpectedHash`]
315    /// values is a no-op (the second registration is silently absorbed at
316    /// [`Self::execute`] time). Registering the same path twice with
317    /// **different** [`ExpectedHash`] values is a programmer error and causes
318    /// [`Self::execute`] to return [`crate::ZiPatchError::InvalidField`].
319    /// The check fires at execute-time rather than here so the builder API
320    /// stays infallible.
321    #[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    /// Hash each registered file and compare against its expected hash.
328    ///
329    /// Returns a [`HashVerifyReport`] describing every file. The report is
330    /// always populated for every registered task — `is_clean()` distinguishes
331    /// a fully-passing run from a failing one. See the struct docs for the
332    /// error policy.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`crate::ZiPatchError::InvalidField`] if any registered
337    /// [`ExpectedHash`] is malformed (wrong digest length, zero `block_size`).
338    /// Filesystem errors are *not* returned here — they appear as
339    /// [`FileVerifyOutcome::IoError`] / [`FileVerifyOutcome::Missing`] entries
340    /// in the report.
341    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    // Stream-hash one block at a time so memory stays O(scratch) regardless of
496    // file size.
497    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        // Cap reads so we never spill across a block boundary.
506        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                // Trailing short block at EOF — finalize and compare.
529                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    // File ran out before we hit `expected.len()` blocks — flag each missing
544    // index as a mismatch. Conversely, if more blocks fit than the caller
545    // supplied, every excess block index past `expected.len()` has already
546    // been flagged inside `finish_and_compare`.
547    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    // Replace the in-progress hasher with a fresh one, taking ownership of the
585    // finished state so we can finalize it without disturbing the loop.
586    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        // Add a short trailing block.
661        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); // 5 full + 1 short
672    }
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        // Corrupt block 2 by writing to the on-disk file *after* computing the
679        // expected hashes from the clean payload.
680        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        // On-disk file: 2 full blocks. Caller expects 4 blocks of hashes.
723        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        // Caller supplies only 2 of 4 block hashes (matching the first two).
755        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        // Zero blocks expected; zero blocks present.
793        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        // Each block is larger than READ_BUF_CAPACITY (64 KiB) so the inner
846        // read loop must iterate multiple times before `want == 0` triggers
847        // the finalize branch. Use 200 KiB blocks: 3 full + 1 short trailing.
848        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        // Same shape as the match test, but corrupt a byte deep inside block 1
869        // (past the first 64 KiB read) so the mismatch only surfaces if the
870        // multi-read accumulation inside a single block works.
871        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        // Corrupt block 1 at offset 150 KiB (well past the first 64 KiB read).
878        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        // File shorter than `block_size` with exactly one expected hash. This
904        // exercises the trailing-short-block finalize path when the *only*
905        // block is short — distinct from the empty-file (zero blocks) path.
906        let block_size: u64 = 200 * 1024;
907        let payload = vec![0x7Eu8; 1000]; // far less than block_size
908        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        // Drop read permission. TempDir cleanup uses unlink (not
925        // open-for-read), so 0o000 on the file itself does not block cleanup.
926        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
927
928        // Skip when running as root — chmod 0o000 is bypassed by
929        // CAP_DAC_OVERRIDE, so root can still open the file. Probe via
930        // File::open: if the open succeeds against 0o000, the running user
931        // has the cap and the test would not exercise the IoError path.
932        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        // Restore so the TempDir cleanup is robust regardless of platform quirks.
944        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    // Note: the mid-read `Err` branch in `hash_blocks` (the second `IoError`
956    // construction site) is not directly tested. Provoking a mid-read IO
957    // error deterministically requires substituting a custom `Read` impl for
958    // `File`, which the current `hash_blocks` signature does not accept. The
959    // permission-denied test above covers the `IoError` construction shape
960    // (kind + message), and the open-time and mid-read arms are byte-identical.
961
962    #[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    /// Reader that yields `n_ok` bytes of zeros, then fails on the next read
1004    /// with the given `ErrorKind`. Used to exercise the mid-read IO error
1005    /// branches in `hash_whole` and `hash_blocks`.
1006    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    // --- execute() with zero tasks ---
1062
1063    #[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    // --- HashVerifyReport invariants ---
1072
1073    #[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        // BTreeMap guarantees sorted-key iteration; verify the contract holds
1106        // by registering paths out of lexicographic order and checking order.
1107        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    // --- FileVerifyOutcome derive sanity ---
1128
1129    #[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    // --- ExpectedHash::validate paths ---
1160
1161    #[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], // valid length
1169                vec![0u8; 5],  // invalid length — should surface the error
1170            ],
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    // --- HashVerifier::expect builder semantics ---
1180
1181    #[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    // --- Block boundary conditions ---
1214
1215    #[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    // --- BlockHasher state isolation between blocks ---
1269
1270    #[test]
1271    fn block_hasher_state_does_not_bleed_between_identical_content_blocks() {
1272        // Both blocks contain the same bytes. Expected[0] matches; expected[1]
1273        // is deliberately wrong. If state bled, block 1's hash would equal
1274        // block 0's hash (which happens to equal expected[0]) — masking the
1275        // mismatch. A correct implementation resets the hasher between blocks,
1276        // so expected[1] != actual[1] and block 1 is flagged.
1277        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    // --- Path edge cases ---
1304
1305    #[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}