Skip to content
This repository was archived by the owner on Jul 11, 2026. It is now read-only.

Commit ed2a04d

Browse files
h4x0rclaude
andcommitted
feat(verify): GREEN — parallel chunk decompression in verify()
verify() is now &self and decompresses chunks in parallel BATCHES (rayon into_par_iter over threads*4 chunks), feeding them to the MD5/SHA1 hashers IN ORDER — hashing is serial, but zlib decompression (the CPU cost of a full-image hash) now uses every core. Splits a cacheless decompress_chunk out of read_chunk so streaming the whole image neither pollutes nor contends the LRU. Peak memory bounded to one batch of decompressed chunks. Correctness validated by the existing differential oracle: e2e_dftt 27/27, incl. exfat1/nps verify() computed-MD5 == stored MD5 (parallel decompression reproduces the exact serial byte stream). New RED test green. Adds rayon to ewf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6dba9ed commit ed2a04d

4 files changed

Lines changed: 102 additions & 24 deletions

File tree

Cargo.lock

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ keywords = ["forensics", "ewf", "e01", "encase", "disk-image"]
1919
# members write `dep.workspace = true`. The inter-crate `ewf` version lives here
2020
# too, so a bump touches one line instead of every member.
2121
[workspace.dependencies]
22-
ewf = { path = "ewf", version = "0.2.1" }
22+
ewf = { path = "ewf", version = "0.2.2" }
2323
flate2 = "1"
2424
lru = "0.12"
25+
rayon = "1"
2526
thiserror = "2"
2627
log = "0.4"
2728
glob = "0.3"

ewf/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ verify = ["dep:md-5", "dep:sha-1"]
2121
[dependencies]
2222
flate2 = { workspace = true }
2323
lru = { workspace = true }
24+
rayon = { workspace = true }
2425
thiserror = { workspace = true }
2526
log = { workspace = true }
2627
glob = { workspace = true }

ewf/src/reader.rs

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -825,33 +825,45 @@ impl EwfReader {
825825
/// }
826826
/// ```
827827
#[cfg(feature = "verify")]
828-
pub fn verify(&mut self) -> Result<VerifyResult> {
828+
pub fn verify(&self) -> Result<VerifyResult> {
829829
use md5::Digest;
830-
831-
let has_sha1 = self.stored_sha1.is_some();
830+
use rayon::prelude::*;
832831

833832
let mut md5_hasher = md5::Md5::new();
834-
let mut sha1_hasher = if has_sha1 {
833+
let mut sha1_hasher = if self.stored_sha1.is_some() {
835834
Some(sha1::Sha1::new())
836835
} else {
837836
None
838837
};
839838

840-
self.position = 0;
841-
let mut buf = vec![0u8; self.chunk_size as usize];
842-
let mut remaining = self.total_size;
843-
844-
while remaining > 0 {
845-
let to_read = std::cmp::min(remaining, buf.len() as u64) as usize;
846-
let n = io::Read::read(self, &mut buf[..to_read])?;
847-
if n == 0 {
848-
break;
849-
}
850-
md5_hasher.update(&buf[..n]);
851-
if let Some(ref mut h) = sha1_hasher {
852-
h.update(&buf[..n]);
839+
// Hashing is serial (MD5/SHA1 chain their state), but zlib decompression
840+
// — the CPU cost of a full-image hash — is not. Decompress chunks in
841+
// parallel BATCHES, then feed them to the hashers IN ORDER. A batch of
842+
// `threads * 4` chunks keeps every core busy while bounding peak memory
843+
// to one batch of decompressed chunks. `decompress_chunk` is cacheless,
844+
// so streaming the whole image neither pollutes nor contends the LRU.
845+
let chunk_count = self.chunks.len();
846+
let batch = rayon::current_num_threads().saturating_mul(4).max(1);
847+
let mut hashed: u64 = 0;
848+
849+
for start in (0..chunk_count).step_by(batch) {
850+
let end = (start + batch).min(chunk_count);
851+
let pages: Vec<Vec<u8>> = (start..end)
852+
.into_par_iter()
853+
.map(|ci| self.decompress_chunk(ci))
854+
.collect::<Result<Vec<_>>>()?;
855+
for page in pages {
856+
// Trim the final chunk to the image's true length: a chunk
857+
// decompresses into a full chunk_size buffer, but the last one
858+
// may back fewer logical bytes.
859+
let remaining = self.total_size.saturating_sub(hashed);
860+
let take = (page.len() as u64).min(remaining) as usize;
861+
md5_hasher.update(&page[..take]);
862+
if let Some(ref mut h) = sha1_hasher {
863+
h.update(&page[..take]);
864+
}
865+
hashed += take as u64;
853866
}
854-
remaining -= n as u64;
855867
}
856868

857869
let computed_md5: [u8; 16] = md5_hasher.finalize().into();
@@ -891,6 +903,23 @@ impl EwfReader {
891903
}
892904
}
893905

906+
let page = self.decompress_chunk(chunk_id)?;
907+
908+
let mut cache = self
909+
.cache
910+
.lock()
911+
.unwrap_or_else(std::sync::PoisonError::into_inner);
912+
cache.put(chunk_id, page.clone());
913+
Ok(page)
914+
}
915+
916+
/// Decompress a chunk by index WITHOUT touching the cache.
917+
///
918+
/// The cacheless counterpart to [`read_chunk`]: used by the parallel
919+
/// [`verify`](Self::verify), which streams every chunk exactly once and so
920+
/// must neither pollute the LRU nor serialize on its lock. Positioned read
921+
/// + decompress, all through `&self`.
922+
fn decompress_chunk(&self, chunk_id: usize) -> Result<Vec<u8>> {
894923
let mut page = vec![0u8; self.chunk_size as usize];
895924
let chunk = self.chunks[chunk_id].clone();
896925
let file = &self.segments[chunk.segment_idx];
@@ -924,11 +953,6 @@ impl EwfReader {
924953
}
925954
}
926955

927-
let mut cache = self
928-
.cache
929-
.lock()
930-
.unwrap_or_else(std::sync::PoisonError::into_inner);
931-
cache.put(chunk_id, page.clone());
932956
Ok(page)
933957
}
934958

0 commit comments

Comments
 (0)