diff --git a/crates/socket-patch-core/src/hash/git_sha256.rs b/crates/socket-patch-core/src/hash/git_sha256.rs index 16e5e583c..515a4a34b 100644 --- a/crates/socket-patch-core/src/hash/git_sha256.rs +++ b/crates/socket-patch-core/src/hash/git_sha256.rs @@ -111,6 +111,18 @@ pub(crate) fn compute_git_sha256_from_std_reader( Ok(hex::encode(hasher.finalize())) } +/// Git SHA-256 of the zip member `name`, streamed through +/// [`compute_git_sha256_from_std_reader`] so the member is never inflated +/// into memory. `None` when the archive has no such member; `Some(Err)` when +/// it can't be read whole (a corrupt stream or a size that disagrees with it). +pub(crate) fn zip_member_git_sha256( + archive: &mut zip::ZipArchive, + name: &str, +) -> Option> { + let entry = archive.by_name(name).ok()?; + Some(compute_git_sha256_from_std_reader(entry.size(), entry)) +} + #[cfg(test)] mod tests { use super::*; @@ -405,4 +417,37 @@ mod tests { let err = result.expect_err("one byte over declared size must error"); assert_eq!(err.kind(), io::ErrorKind::InvalidData); } + + /// `zip_member_git_sha256` hashes stored, deflated and empty members + /// exactly as the buffered hash of their bytes, and reports an absent + /// member as `None`. + #[test] + fn test_zip_member_matches_buffered_hash() { + use std::io::Write as _; + let members: [(&str, &[u8], zip::CompressionMethod); 3] = [ + ("stored.txt", b"hello world", zip::CompressionMethod::Stored), + ( + "deflated.txt", + &[b'a'; 100_000], + zip::CompressionMethod::Deflated, + ), + ("empty.txt", b"", zip::CompressionMethod::Deflated), + ]; + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes, method) in &members { + let opts = zip::write::SimpleFileOptions::default().compression_method(*method); + out.start_file(*name, opts).unwrap(); + out.write_all(bytes).unwrap(); + } + let jar = out.finish().unwrap().into_inner(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(jar)).unwrap(); + for (name, bytes, _) in &members { + assert_eq!( + zip_member_git_sha256(&mut archive, name).unwrap().unwrap(), + compute_git_sha256_from_bytes(bytes), + "{name}" + ); + } + assert!(zip_member_git_sha256(&mut archive, "missing.txt").is_none()); + } } diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index c3feba758..1c9e24d93 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -26,7 +26,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::crawlers::gradle_cache; -use crate::hash::git_sha256::compute_git_sha256_from_bytes; +use crate::hash::git_sha256::{compute_git_sha256_from_bytes, zip_member_git_sha256}; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ apply_file_patch_at, files_in_order, normalize_file_path, ApplyResult, VerifyResult, @@ -95,21 +95,15 @@ pub fn verify_member_bytes( jar: &[u8], files: &HashMap, ) -> Vec { - use std::io::Read as _; let mut archive = zip::ZipArchive::new(std::io::Cursor::new(jar)).ok(); files_in_order(files) .into_iter() .map(|(key, info)| { let member = normalize_file_path(key); - let content = archive.as_mut().and_then(|a| { - let mut entry = a.by_name(member).ok()?; - // Not `with_capacity(entry.size())`: the size is the jar's own claim, - // and a crafted one would abort the allocation. - let mut buf = Vec::new(); - entry.read_to_end(&mut buf).ok()?; - Some(buf) - }); - let (status, current) = match content.map(|c| compute_git_sha256_from_bytes(&c)) { + let hash = archive + .as_mut() + .and_then(|a| zip_member_git_sha256(a, member)?.ok()); + let (status, current) = match hash { None if info.before_hash.is_empty() => (VerifyStatus::Ready, None), None => (VerifyStatus::NotFound, None), Some(h) if h == info.after_hash => (VerifyStatus::AlreadyPatched, Some(h)), @@ -1274,4 +1268,56 @@ mod tests { assert!(back[0].success, "{:?}", back[0].error); assert_eq!(std::fs::read(copy.join("lib-1.0.jar")).unwrap(), original); } + + /// A large deflated member is verified by streaming it through the + /// shared zip-member hasher: the verdict matches the buffered hash for + /// both record states, and an absent member keeps its own status. + #[test] + fn verify_member_bytes_streams_a_large_deflated_member() { + use std::io::Read as _; + const SIZE: u64 = 16 * 1024 * 1024; + let zeros_hash = crate::hash::git_sha256::compute_git_sha256_from_std_reader( + SIZE, + std::io::repeat(0).take(SIZE), + ) + .unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + out.start_file("big.bin", opts).unwrap(); + std::io::copy(&mut std::io::repeat(0).take(SIZE), &mut out).unwrap(); + out.start_file("a/B.class", opts).unwrap(); + out.write_all(b"code").unwrap(); + let bytes = out.finish().unwrap().into_inner(); + assert!((bytes.len() as u64) < SIZE / 100, "member must be deflated"); + + let ready = HashMap::from([( + "big.bin".to_string(), + PatchFileInfo { + before_hash: zeros_hash.clone(), + after_hash: compute_git_sha256_from_bytes(b"patched"), + }, + )]); + let got = verify_member_bytes(&bytes, &ready); + assert_eq!(got[0].status, VerifyStatus::Ready); + assert_eq!(got[0].current_hash.as_deref(), Some(zeros_hash.as_str())); + + let applied = HashMap::from([( + "big.bin".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(b"pristine"), + after_hash: zeros_hash.clone(), + }, + )]); + assert_eq!( + verify_member_bytes(&bytes, &applied)[0].status, + VerifyStatus::AlreadyPatched + ); + + let absent = HashMap::from([("gone.bin".to_string(), info(b"x", b"y"))]); + assert_eq!( + verify_member_bytes(&bytes, &absent)[0].status, + VerifyStatus::NotFound + ); + } }