diff --git a/crates/socket-patch-bench/src/fixtures/npm.rs b/crates/socket-patch-bench/src/fixtures/npm.rs index 1985f703a..af1ee68e4 100644 --- a/crates/socket-patch-bench/src/fixtures/npm.rs +++ b/crates/socket-patch-bench/src/fixtures/npm.rs @@ -623,7 +623,13 @@ pub fn build_yarn_berry(t: &mut Tree, size: Size) -> std::io::Result { t.write("project/node_modules/.yarn-state.yml", "# Warning: This file is automatically generated. Removing it is fine, but will\n# cause your node_modules installation to become invalidated.\n\n__metadata:\n version: 1\n nmMode: classic\n")?; g.install_hoisted(t, "project/")?; t.mkdir("home")?; - Ok(fixture(&g, g.patches(true), &["yarn.lock"], &[])) + // Hosted Berry pins both descriptor resolutions and their lock entries. + Ok(fixture( + &g, + g.patches(true), + &["package.json", "yarn.lock"], + &[], + )) } // ── bun ──────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 64030bd51..198281ee6 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -307,13 +307,13 @@ async fn read_zip_artifact_capped(archive_path: &Path, cap: u64) -> Option, ) -> bool { - use std::io::Read as _; - - use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::hash::git_sha256::compute_git_sha256_from_std_reader; let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else { return false; }; @@ -324,14 +324,12 @@ pub(crate) fn zip_bytes_match_after_hashes( if !is_safe_relative_subpath(normalized) { return false; } - let Ok(mut entry) = archive.by_name(normalized) else { + let Ok(entry) = archive.by_name(normalized) else { return false; }; - let mut content = Vec::with_capacity(entry.size() as usize); - if entry.read_to_end(&mut content).is_err() { - return false; - } - if compute_git_sha256_from_bytes(&content) != info.after_hash { + if !compute_git_sha256_from_std_reader(entry.size(), entry) + .is_ok_and(|hash| hash == info.after_hash) + { return false; } } @@ -1009,6 +1007,85 @@ mod tests { ); } + #[test] + fn zip_matches_after_hashes_handles_empty_and_multichunk_members() { + let body: Vec = (0..20_003).map(|i| (i % 251) as u8).collect(); + let entries = [ + ("empty".to_string(), Vec::new(), 0o644), + ("lib/data".to_string(), body, 0o644), + ]; + let bytes = write_zip_entries(&entries).unwrap(); + let mut files: HashMap<_, _> = entries + .iter() + .map(|(name, content, _)| { + ( + name.clone(), + PatchFileInfo { + before_hash: "before".to_string(), + after_hash: compute_git_sha256_from_bytes(content), + }, + ) + }) + .collect(); + assert!(zip_bytes_match_after_hashes(&bytes, &files)); + files.get_mut("empty").unwrap().after_hash = "0".repeat(64); + assert!(!zip_bytes_match_after_hashes(&bytes, &files)); + } + + #[test] + fn zip_matches_after_hashes_rejects_incorrect_declared_sizes() { + let dir = tempfile::tempdir().unwrap(); + let (_, files, bytes) = in_sync_jar_fixture(dir.path()); + let central = bytes.windows(4).position(|w| w == b"PK\x01\x02").unwrap(); + for size in [7u32, 9] { + let mut altered = bytes.clone(); + // Only the uncompressed size changes. The deflated payload, + // CRC and afterHash still describe the original eight bytes. + altered[22..26].copy_from_slice(&size.to_le_bytes()); + altered[central + 24..central + 28].copy_from_slice(&size.to_le_bytes()); + assert!( + !zip_bytes_match_after_hashes(&altered, &files), + "declared size {size} must agree with the streamed body" + ); + } + } + + /// Run alone under a memory profiler to check the verifier's working + /// set. Build the fixture in chunks too, so it never allocates the + /// inflated body. The member exceeds the extraction path's 64 MiB cap: + /// comparison must accept it without introducing a new size limit. + #[test] + fn zip_matches_after_hashes_streams_large_entry() { + use std::io::Write as _; + + const SIZE: usize = 64 * 1024 * 1024 + 1; + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + writer + .start_file( + "large.bin", + zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated), + ) + .unwrap(); + let chunk = [0u8; 8192]; + for _ in 0..SIZE / chunk.len() { + writer.write_all(&chunk).unwrap(); + } + writer.write_all(&chunk[..SIZE % chunk.len()]).unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + assert!(bytes.len() < 128 * 1024); + let files = HashMap::from([( + "large.bin".to_string(), + PatchFileInfo { + before_hash: "before".to_string(), + // Python hashlib: sha256(b"blob 67108865\0" + 67108865 zero bytes). + after_hash: "453f670092f0481b99614c8a1a5846335799943dd26f5375274e08fa5f12744f" + .to_string(), + }, + )]); + assert!(zip_bytes_match_after_hashes(&bytes, &files)); + } + /// Bytes that aren't a zip archive at all (a truncated or clobbered /// `.jar` / `.nupkg`) must read as out-of-sync, not error or panic. #[tokio::test]