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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-06 15:50:40 +01:00
1 parent ea51451efc
commit f227d63ab1
4 files changed
+166 -6

No files matched your search

+9 -3
View File
@@ -49,7 +49,8 @@ fn content_draws(doc: &Document, content: &[Vec<u8>], 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<Ob
pending.push(res);
continue;
}
let data = form.decoded()?;
let data = doc.with_direct_parms(form).decoded()?;
let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?;
if pruned {
res.set(b"XObject".to_vec(), Object::Dict(xo));
@@ -272,6 +273,11 @@ pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> 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<u8>]) -> Result<Vec<Op>, 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<u8>]) -> 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() });
}
+70
View File
@@ -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.
@@ -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");
}
+3 -3
View File
@@ -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)?;
}