From 1206c0156e956fd48ca22edf83dcd1419fb15c2e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 04:28:27 +0000 Subject: [PATCH] fix(format): charge an archive member's unpacking even when the read fails The shared unpacking total (and Sketch's) was debited only when a member read succeeded, so a snapshot or page member damaged near its end could be named again and again by an index and unpacked each time without ever spending the budget. A failed read is now charged its recorded size; one refused for that size before any unpacking still costs nothing. --- crates/format/src/lib.rs | 13 ++++++++++--- crates/format/src/sketch.rs | 8 +++++--- crates/format/src/unpacked_total_tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3a69ca848..02445da74 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -707,9 +707,16 @@ impl Unpacked { if self.exceeded(z, name) && z.by_name(name).is_ok_and(|f| f.size() <= limit) { return Err(FormatError::Corrupt(format!("{name}: {}", self.passed()))); } - let data = read_member(z, name, limit.min(self.left))?; - self.left = self.left.saturating_sub(data.len() as u64); - Ok(data) + let cap = limit.min(self.left); + let size = z.by_name(name).map_or(0, |f| f.size()); + let data = read_member(z, name, cap); + // A read that failed part way (a bad checksum at the end, say) did its unpacking all the + // same: it is charged in full, so an index naming that member again can't repeat it. One + // refused for its recorded size before any unpacking costs nothing. + if size <= cap { + self.left = self.left.saturating_sub(data.as_ref().map_or(size, |d| d.len() as u64)); + } + data } } diff --git a/crates/format/src/sketch.rs b/crates/format/src/sketch.rs index 8186ce26d..3a2c602df 100644 --- a/crates/format/src/sketch.rs +++ b/crates/format/src/sketch.rs @@ -277,9 +277,11 @@ fn capped_member( if size > allowed { return Err(corrupt(format!("{name} is too large"))); } - let data = crate::read_member(z, name, allowed)?; - *total += data.len() as u64; - Ok(data) + let data = crate::read_member(z, name, allowed); + // A failed read (a bad checksum at the end, say) unpacked all the same: charged in full, so + // a page list naming the member again can't repeat it within the budget. + *total += data.as_ref().map_or(size, |d| d.len() as u64); + data } /// JSON member `name`, read by [`capped_member`] within the file's [`MAX_JSON_TOTAL`]. diff --git a/crates/format/src/unpacked_total_tests.rs b/crates/format/src/unpacked_total_tests.rs index 8ed304ddc..92db654e5 100644 --- a/crates/format/src/unpacked_total_tests.rs +++ b/crates/format/src/unpacked_total_tests.rs @@ -93,3 +93,26 @@ fn the_total_leaves_room_for_the_largest_real_files() { // Below what the per-area caps added up to (about 10 GB). const { assert!(MAX_UNPACKED < 10 * 1024 * 1024 * 1024) }; } + +#[test] +fn a_read_that_fails_part_way_still_spends_the_total() { + // A member whose compressed bytes are damaged near the end unpacks almost all of itself + // before failing: that work is charged, so an index naming it again can't repeat it for free. + let body: Vec = (0..200_000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); + // Stored, so a byte flipped in the middle is read through and fails only on the checksum. + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + w.start_file("snapshots/v1.json", zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap(); + w.write_all(&body).unwrap(); + w.finish().unwrap(); + let mut bytes = buf.into_inner(); + let at = 30 + "snapshots/v1.json".len() + body.len() / 2; + bytes[at] ^= 0xff; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let size = z.by_name("snapshots/v1.json").unwrap().size(); + assert_eq!(size, body.len() as u64); + let mut total = crate::Unpacked::new(size + size / 2); + assert!(total.read(&mut z, "snapshots/v1.json", 1 << 20).is_err(), "the damaged member fails"); + let again = total.read(&mut z, "snapshots/v1.json", 1 << 20).unwrap_err().to_string(); + assert!(again.contains("together pass"), "the second read is refused for the total, not read again: {again}"); +}