From a55eaa90e3f2f4aee400f4b95ced62cbfd24a762 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 19:40:35 -0500 Subject: [PATCH] fix(redact): prune the resources of Forms a surviving Type 3 glyph draws (#5924) --- .../redact/src/tests/surviving_references.rs | 44 ++++++++++++++ crates/redact/src/type3.rs | 57 ++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 477f03f9a5..c561b7f8d1 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -161,6 +161,30 @@ fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() { assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB"); } +/// The surviving glyph B draws a Form whose own resources also name A's image, which the Form +/// never draws. Pruning only the font's resources left that entry, so the image of the removed +/// glyph stayed in the file through the Form. +#[test] +fn a_redacted_type3_glyphs_image_does_not_survive_in_a_surviving_glyphs_form() { + 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 (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /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 >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources << /XObject << /ImA 9 0 R /ImB 11 0 R >> >>", b"/ImB Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "the removed glyph's image survives through the surviving glyph's form"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { @@ -298,3 +322,23 @@ 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 Form the surviving glyph draws holds `/Resources` as an array that references the removed +/// glyph's image: it can't be pruned, so the redaction refuses rather than keep the image. +#[test] +fn a_type3_glyphs_form_with_non_dictionary_resources_fails_closed() { + 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 (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /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 >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources [9 0 R]", b""), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 57971571af..3bba33a645 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -135,7 +135,13 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res if parsed.skipped > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); } - for op in parsed.ops { + parsed.ops.iter().for_each(|op| record_used(op, used)); + Ok(()) +} + +/// Record the resource name `op` uses, if any. +fn record_used(op: &pdfcraft_content::Op, used: &mut UsedInGlyphs) { + { match op.op.as_slice() { b"Do" => { if let Some(n) = op.last_name() { @@ -175,6 +181,54 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res _ => {} } } +} + +/// Prune the own `/Resources` of every Form a Type 3 font's (already pruned) resources still +/// list, and of the Forms those list in turn, to the names each Form's content uses. A Form a +/// surviving glyph draws may name, unused, an image only a removed glyph drew; that entry would +/// keep the image in the file. Pruning only unused entries is safe for a shared Form: no drawer +/// of it can use a name its content doesn't. Fails closed when a Form can't be read. +fn prune_font_forms(doc: &mut Document, font_res: &Dict) -> Result<(), CosError> { + let mut pending: Vec<(ObjRef, usize)> = res_dict(doc, font_res, b"XObject").iter().filter_map(|(_, v)| v.as_ref()).map(|r| (r, 0)).collect(); + let mut seen = HashSet::new(); + while let Some((form_ref, depth)) = pending.pop() { + if !seen.insert(form_ref) { + continue; + } + if depth > MAX_DEPTH || seen.len() > MAX_HOLDERS { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: forms nest too deep".into() }); + } + let Object::Stream(form) = &*doc.get(form_ref) else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + // A Form without resources of its own (no key, or `null`) draws through its caller's, + // which were pruned already. Any other value that isn't a dictionary may still reference + // what a removed glyph drew, and can't be pruned: fail closed. + let mut res = match form.dict.get(b"Resources").map(|r| doc.resolve(r)) { + None => continue, + Some(r) if matches!(*r, Object::Null) => continue, + Some(r) => match r.as_dict() { + Some(d) => d.clone(), + None => { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: a form's /Resources is not a dictionary".into(), + }); + } + }, + }; + let data = form.decoded()?; + let mut used = UsedInGlyphs::default(); + crate::interp::resource_ops(doc, &res, std::slice::from_ref(&data))?.iter().for_each(|op| record_used(op, &mut used)); + // `rg`, `g` and `k` draw through these entries when the Form keeps them as its defaults, + // and nothing cites them with `cs`/`CS`: pruning them would change the Form's colours. + used.colorspaces.extend([b"DefaultRGB".to_vec(), b"DefaultGray".to_vec(), b"DefaultCMYK".to_vec()]); + if prune_type3_font_resources(doc, &mut res, &used) { + doc.update_dict(form_ref, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.extend(res_dict(doc, &res, b"XObject").iter().filter_map(|(_, v)| v.as_ref()).map(|r| (r, depth + 1))); + } Ok(()) } @@ -631,6 +685,7 @@ fn clean_type3_font( } })?; } + prune_font_forms(doc, &f_res)?; Ok(()) }