From d3bbf99b4a9e60867227cee7b96b148bdfd33e2e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:59:03 +0100 Subject: [PATCH] Write CRC-mismatched ZIP members when if_tampered=continue (#525) Fixes #518. With if_tampered=continue, core_archive_extract writes a ZIP member whose CRC does not match (the mismatch stays in contradictions), as its remediation says. --- crates/engine/src/tools/artifact_tools.rs | 29 ++++++++++++++++++++++- crates/formats/src/zip.rs | 14 +++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tools/artifact_tools.rs b/crates/engine/src/tools/artifact_tools.rs index d3930f2b..cbf21fef 100644 --- a/crates/engine/src/tools/artifact_tools.rs +++ b/crates/engine/src/tools/artifact_tools.rs @@ -690,7 +690,15 @@ fn core_archive_extract(ctx: &mut Ctx<'_>, params: Value) -> Result members.push((e.name.clone(), Member::Owned(d))), Err(err @ zip::ZipError::CrcMismatch { .. }) => { contradictions.push(json!({"path": e.name, "reason": err.to_string()})); - members.push((e.name.clone(), Member::Skip(err.to_string()))); + let m = if p.if_tampered == IntegrityPolicy::Continue { + match zip::read_entry_unverified(&bytes, e, p.max_bytes) { + Ok(d) => Member::Owned(d), + Err(read_err) => Member::Skip(read_err.to_string()), + } + } else { + Member::Skip(err.to_string()) + }; + members.push((e.name.clone(), m)); } Err(err) => members.push((e.name.clone(), Member::Skip(err.to_string()))), } @@ -981,6 +989,25 @@ mod tests { assert_eq!(ok["result"]["contradictions"].as_array().unwrap().len(), 1); } + #[test] + fn extract_if_tampered_continue_writes_the_member() { + let dir = tempfile::tempdir().unwrap(); + let mut z = fx::zip_stored(&[("a.txt", b"hello")]); + let pos = z.windows(5).position(|w| w == b"hello").unwrap(); + z[pos] = b'j'; + let p = dir.path().join("bad.zip"); + std::fs::write(&p, &z).unwrap(); + let e = Engine::new(crate::testing::session()); + let err = call(&e, "core_archive_extract", json!({"path": p})).unwrap_err(); + assert_eq!(err.code, ErrorCode::FileHashMismatch); + let ok = call(&e, "core_archive_extract", json!({"path": p, "if_tampered": "continue"})).unwrap(); + let r = &ok["result"]; + assert_eq!(r["files_written"], 1, "{r}"); + assert_eq!(r["contradictions"].as_array().unwrap().len(), 1); + let out = std::path::PathBuf::from(r["output_directory"].as_str().unwrap()); + assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"jello"); + } + #[test] fn safe_components_rules() { assert!(safe_components("../x").is_err()); diff --git a/crates/formats/src/zip.rs b/crates/formats/src/zip.rs index efdf2bd6..43fa48ed 100644 --- a/crates/formats/src/zip.rs +++ b/crates/formats/src/zip.rs @@ -231,6 +231,16 @@ pub fn raw_entry_data<'a>(data: &'a [u8], e: &ZipEntry) -> Result<&'a [u8], ZipE /// Read and decompress one entry, refusing to produce more than `max_bytes`. pub fn read_entry(data: &[u8], e: &ZipEntry, max_bytes: u64) -> Result, ZipError> { + let out = read_entry_unverified(data, e, max_bytes)?; + let found = crc32(&out); + if found != e.crc32 { + return Err(ZipError::CrcMismatch { expected: e.crc32, found }); + } + Ok(out) +} + +/// Like [`read_entry`] but without comparing the stored CRC-32, so a caller that accepts a mismatch can still get the bytes. +pub fn read_entry_unverified(data: &[u8], e: &ZipEntry, max_bytes: u64) -> Result, ZipError> { if e.encrypted { return Err(ZipError::Unsupported(format!("{} is encrypted", e.name))); } @@ -248,10 +258,6 @@ pub fn read_entry(data: &[u8], e: &ZipEntry, max_bytes: u64) -> Result, 93 => crate::decompress::zstd(raw, max_bytes).map_err(|err| ZipError::Corrupt(format!("zstd stream for {} is damaged: {err}", e.name)))?, m => return Err(ZipError::Unsupported(format!("compression method {m} ({}) for {}", e.method_name(), e.name))), }; - let found = crc32(&out); - if found != e.crc32 { - return Err(ZipError::CrcMismatch { expected: e.crc32, found }); - } Ok(out) }