From f227d63ab1c453ee42a93e2b5bcbe726e65d4daa Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 15:49:18 +0100 Subject: [PATCH] M8: redaction reads the forms and glyphs it follows with /DecodeParms by reference To tell which resources content still draws, redaction reads the forms it follows, and the Type 3 glyph programs, with `Stream::decoded`, which reads `/DecodeParms` only when direct. Behind a predictor whose parameters are an indirect object, a form read as the predictor's tag bytes and row differences instead of its operators. So: - the check of what a page still draws (`resource_ops`), the unlinking check (`content_draws`) and the unlinking from surviving forms' resources (`unlink_from_forms`) didn't see such a form draw an image again, and the image went from resources it is still drawn through; - the Type 3 cleanup didn't see such a form, drawn or not, show a glyph, and dropped the glyph's program; - it refused the redaction over a glyph program behind such a predictor, which it couldn't parse. All six reads now go through `Document::with_direct_parms`. Each has a test, the form or glyph behind a predictor whose parameters are given by reference, that fails with that read reverted: - an_image_a_form_with_parameters_by_reference_draws_again_is_kept (`resource_ops` and `content_draws`); - an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept; - a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept; - a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept; - an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp/prune.rs | 12 ++- crates/redact/src/tests.rs | 70 ++++++++++++++++ .../redact/src/tests/surviving_references.rs | 84 +++++++++++++++++++ crates/redact/src/type3.rs | 6 +- 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index b73b34e231..3e9b49bd04 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -49,7 +49,8 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de if depth >= MAX_DEPTH { return None; } - let Ok(data) = s.decoded() else { return None }; + // Its `/DecodeParms` given by reference are read too. + let Ok(data) = doc.with_direct_parms(s).decoded() else { return None }; match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { Some(false) => {} other => return other, @@ -222,7 +223,7 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet Op Some(ops) } +/// The operators of `content`, and of every form without /Resources of its own that it draws +/// through `res`, nested ones too: what draws with these resources. Each such form is read once, +/// with its `/DecodeParms` given by reference. An error when that can't all be read: the parser +/// can't read all of `content` or of such a form, the walk passes 2,000,000 operators, or such a +/// form doesn't decode. pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { let parsed = Pieces::join(content).parse(); if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { @@ -290,7 +296,7 @@ pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> R && s.dict.name(b"Subtype") == Some(b"Form") && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) { - let parsed = pdfcraft_content::parse(&s.decoded()?); + let parsed = pdfcraft_content::parse(&doc.with_direct_parms(s).decoded()?); if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 04624a5b5e..5c6de2b7b8 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1771,6 +1771,76 @@ fn what_content_draws_through_its_resources_is_read_up_to_two_million_operators( } } +/// `content` behind a PNG predictor (Up, one column) whose `/DecodeParms` are given by reference, +/// in a stream with `entries` too: read without the parameters, its bytes are the predictor's tags +/// and differences, not the operators. +fn behind_parms_by_reference(doc: &mut Document, entries: &[(&str, Object)], content: &[u8]) -> Stream { + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12))]))); + let mut s = Stream::flate(Dict::new(), &rows); + for (key, value) in entries { + s.dict.set(key.as_bytes().to_vec(), value.clone()); + } + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s +} + +/// A form's entries: `/Subtype /Form` and `bbox`. +fn form_entries(bbox: [i64; 4]) -> Vec<(&'static str, Object)> { + vec![("Subtype", Object::name("Form")), ("BBox", Object::Array(bbox.into_iter().map(Object::Int).collect()))] +} + +/// A form outside the mark draws the image again through the page's resources, its content behind +/// a predictor whose `/DecodeParms` are given by reference. Both checks of whether the page still +/// draws it read the form through them, the page's (`resource_ops`) and the unlinking one at the +/// end (`content_draws`): the page keeps the image for the form. +#[test] +fn an_image_a_form_with_parameters_by_reference_draws_again_is_kept() { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + let form = behind_parms_by_reference(&mut doc, &form_entries([0, 0, 100, 100]), b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the form still draws it"); +} + +/// A surviving form outside the mark shares the page's resources and draws the image itself, its +/// content behind a predictor whose `/DecodeParms` are given by reference: unlinking the redacted +/// image from the forms' resources reads it through them, and keeps the image the form draws. +#[test] +fn an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + b"<< /XObject << /Im 6 0 R /Fm 7 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [150 150 300 300]", b""), + ]); + let mut entries = form_entries([150, 150, 300, 300]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 200 200 cm /Im Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(surviving_references::full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} + /// A form with resources of its own draws through those, not its caller's: one on another page /// that names a different image /Im1 doesn't keep the redacted /Im1 in the dictionary both pages /// inherit. diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 477f03f9a5..db420ed952 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -298,3 +298,87 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { assert_eq!(report.glyphs, 1); assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); } + +/// A Type 3 font with glyphs A (its program object 7) and B (object 8), at object 6. +const TYPE3: &str = "<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> >>"; + +/// A form outside the mark shows glyph B of a Type 3 font, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps B. +#[test] +fn a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the form still shows B"); +} + +/// A form the page lists but doesn't draw shows glyph B, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads every surviving form, this one +/// through them, and keeps B. +#[test] +fn a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(ObjRef::new(6, 0)))])))])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the undrawn form still shows B"); +} + +/// Glyph B's program draws an image from the font's resources, behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps the +/// image B still draws while the one only the redacted A drew goes. +#[test] +fn an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept() { + let image = "/Type /XObject /Subtype /Image /Width 12 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> /CharProcs"); + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"/ImA Do"), + stream("", b""), + stream(image, b"SECRET_IMG_A"), + stream(image, b"PUBLIC_IMG_B"), + ]); + let glyph = super::behind_parms_by_reference(&mut doc, &[], b"/ImB Do"); + doc.set(ObjRef::new(8, 0), glyph); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_IMG_A"), "the image only the redacted glyph drew survived"); + assert!(full_save_holds(&doc, b"PUBLIC_IMG_B"), "glyph B still draws its image"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 57971571af..9c82d540ac 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -327,7 +327,7 @@ fn scan_xobject( if !ctx.seen_forms.insert((r, current_font, through)) { return Ok(()); } - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; match form_own_res { Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1), None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), @@ -482,7 +482,7 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { for (form_ref, form_res) in &form_holders { if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; } } @@ -616,7 +616,7 @@ fn clean_type3_font( } .into()); }; - let data = s.decoded()?; + let data = doc.with_direct_parms(s).decoded()?; collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; }