fix(measure): a failed content decode uses up the snap byte budget

Snap geometry charged decoded bytes only on success, so a /Contents
array repeating one stream that inflates past the 16 MiB limit ran that
inflation for every entry without spending any budget. A tolerant decode
fails only on an unsupported filter or after producing the remaining
budget, so a failure now spends the rest of the budget and later parts
are skipped as truncated.
This commit is contained in:
aiongg
2026-10-22 17:08:54 +00:00
parent a16661ac10
commit a34697a8eb
2 changed files with 22 additions and 3 deletions
+9 -3
View File
@@ -214,14 +214,14 @@ impl Walker<'_> {
self.geometry.truncated = true;
break;
}
// An undecodable part is skipped; the rest of the page still snaps.
// An undecodable part is skipped; the parts before it still snap.
match s.decoded_within(remaining) {
Ok(data) => {
joined.extend(data);
// A space, as the renderer joins pieces: a trailing comment runs on.
joined.push(b' ');
}
Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1),
Err(_) => self.decode_failed(remaining),
}
}
}
@@ -236,11 +236,17 @@ impl Walker<'_> {
match stream.decoded_within(remaining) {
Ok(data) => self.ops(&data, resources, initial, depth),
Err(_) => {
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
self.decode_failed(remaining);
Ok(())
}
}
}
/// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes,
/// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work.
fn decode_failed(&mut self, remaining: usize) {
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
self.bytes = self.bytes.saturating_add(remaining);
}
fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> {
self.bytes = self.bytes.saturating_add(data.len());
self.streams = self.streams.saturating_add(1);
+13
View File
@@ -423,3 +423,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
assert!(listing.measurements.iter().map(retained).sum::<usize>() <= MAX_LISTED_BYTES);
assert!(!listing.measurements.is_empty());
}
#[test]
fn a_stream_over_the_byte_budget_uses_it_up_once() {
// 50 references to one Flate stream that inflates past the 16 MiB budget decode once.
let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]);
let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect();
let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap();
let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50));
let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]);
let g = snap::geometry(&d, 0).unwrap();
assert_eq!(g.unreadable, 1);
assert!(g.truncated);
assert_eq!(g.segments.len(), 1, "the parts before it still snap");
}