From 6a99d26c7d641228c2628c8d2457db551532d534 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 13:59:36 +0100 Subject: [PATCH] M11: redaction reads /DecodeParms given by reference before clearing pixels Stream::decoded_within reads /DecodeParms only when it is direct, so an image whose PNG predictor was given by reference decoded with its row tags still in the data. The cleared bytes then drifted one byte further from the covered pixels each row, and the covered samples survived in the redacted copy, recoverable by applying the predictor. Parameters are now made direct first (Document::with_direct_parms). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/redact/Cargo.toml | 1 + crates/redact/src/image.rs | 3 +++ crates/redact/src/tests.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f9280eff06..c6ee963a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4572,6 +4572,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-edit", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..8d1515d8a4 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,4 +20,5 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 601700f257..4e26fbd1fc 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -45,6 +45,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") { return None; } + // `Stream::decoded*` reads `/DecodeParms` only when it is direct: a predictor given by + // reference would be skipped, and the cleared bytes would then miss the covered pixels. + let s = &*doc.with_direct_parms(s); let supported = |n: &[u8]| { matches!( n, diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..6db9cec8c7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -549,6 +549,32 @@ fn redaction_clears_pixels_intersected_without_covering_their_centres() { } } +/// An image whose PNG predictor is given by reference has its covered pixel cleared, and only +/// that one: `Stream::decoded` reads only a direct `/DecodeParms`, so the row tags stayed in the +/// data, the cleared byte drifted off the covered pixel and the covered sample survived. +#[test] +fn an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared() { + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let mut doc = one_page( + b"q 40 0 0 20 0 0 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /DecodeParms 8 0 R", + predicted, + ), + b"<< /Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns 4 >>".to_vec(), + ], + ); + // The top row's second sample. + mark(&mut doc, 0, &[[12.0, 12.0, 18.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![10, 0, 30, 40, 50, 60, 70, 80])); +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them.