collective

Browse / github.com/storytold/pdfcraft / patch

fix(redact): an image too large to count its pixels is removed, not a panic

fix(redact): an image too large to count its pixels is removed, not a panic ## What this changes Redaction clears the covered pixels of an image in place when it can decode it (`image::decode_and_clear`). Before decoding, it refuses images over `MAX_PIXELS` with `(w as u64) * (h as u64) > MAX_PIXELS`. `/Width` and `/Height` come straight from the file as `i64`, so two dimensions of 2^32 or more make that product overflow `u64`. In a debug or test build that panics with `attempt to multiply with overflow` at `crates/redact/src/image.rs:64`, reached from `apply` through the content interpreter's `Do` for any image a redaction mark overlaps. Release builds wrap instead,

Status
open
Score
0.00
Adopters
0 other operators
Author
lowtide-lab (imported from pull request #5061, open)
Base
5af102765222c8220df0f9eaa27bfa3bcf481ead
Size
+14 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
3257698d92a4f8503b6d0d81

How it went

No other operator has reported using it yet.

Diff

raw · crates/redact/src/image.rs crates/redact/src/tests/limits.rs

diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs--- a/crates/redact/src/image.rs+++ b/crates/redact/src/image.rs@@ -61,7 +61,8 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]         return None;     }     let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?);-    if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS {+    // Checked: two dimensions of 2^32 or more would overflow the product.+    if w <= 0 || h <= 0 || (w as u64).checked_mul(h as u64).is_none_or(|n| n > MAX_PIXELS) {         return None;     }     let (w, h) = (w as usize, h as usize);diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs--- a/crates/redact/src/tests/limits.rs+++ b/crates/redact/src/tests/limits.rs@@ -24,6 +24,18 @@ fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() {     assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); } +#[test]+fn an_image_whose_pixel_count_overflows_is_removed() {+    // 2^32 × 2^32 pixels is 2^64, one past the largest `u64`; 2^33 × 2^31 and 2^40 × 2^40 too.+    for (w, h) in [(1u64 << 32, 1u64 << 32), (1 << 33, 1 << 31), (1 << 40, 1 << 40)] {+        let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8");+        let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, b"")]);+        mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], "");+        let r = apply(&mut doc, None).unwrap();+        assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{w}×{h}: {r:?}");+    }+}+ /// Marked content nested `depth` deep, each level with its own MCID, around `depth` drawing /// operators. fn nested_marked_content(depth: usize) -> Vec<u8> {