From 8bf63c8792fbbf8c0121eedd41a6b669d8cec72d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 17:47:19 +0100 Subject: [PATCH] M8: the Type 3 cleanup reads content and glyph programs one operator at a time The Type 3 cleanup at the end of a redaction (unlink_type3) scans every page and every form it reaches for the glyphs they show, and reads the programs of the glyphs it keeps for the resources they use. Both parsed each stream whole before checking for bytes they couldn't read, outside the page's allowance and up to 1 GiB a stream. So a form with resources of its own outside the mark, which no other reader follows, cost about 79 bytes of memory per byte of content: the peak grew by 632 MiB for 8 MiB of `n` operators. On main too, wherever a Type 3 font survives in a page's or a form's resources. Both now read with pdfcraft_content::ops, one operator at a time, and refuse for unreadable bytes once they have read the whole stream, as before. The scan only reads, and either error refuses the whole operation, so acting on operators before that check changes nothing. The memory test gains a case for each: the_forms_followed_through_resources_are_read_one_operator_at_a_time fails with either read reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/type3.rs | 24 +++++++------ crates/redact/tests/operator_memory.rs | 50 +++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 80d913e6ad..1280cf774e 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -131,11 +131,10 @@ struct UsedInGlyphs { } fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { - let parsed = pdfcraft_content::parse(data); - 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 { + // One operator at a time, so a glyph program of millions of them is never held whole. A byte + // that can't be read anywhere in it still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(data); + for op in read.by_ref() { match op.op.as_slice() { b"Do" => { if let Some(n) = op.last_name() { @@ -175,6 +174,9 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res _ => {} } } + if read.skipped() > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); + } Ok(()) } @@ -233,15 +235,14 @@ fn scan_content_stream( return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() }); } let joined = Pieces::join(content); - let parsed = joined.parse(); - if parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); - } + // One operator at a time, so content of millions of them is never held whole. The scan only + // reads, and a byte that can't be read anywhere still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(joined.data()); let mut stack: Vec> = Vec::new(); let mut current_font = initial_font; - for op in parsed.ops { + for op in read.by_ref() { match op.op.as_slice() { b"q" => stack.push(current_font), b"Q" => { @@ -257,6 +258,9 @@ fn scan_content_stream( _ => apply_text_op(ctx, res, &op, &mut current_font), } } + if read.skipped() > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); + } Ok(()) } diff --git a/crates/redact/tests/operator_memory.rs b/crates/redact/tests/operator_memory.rs index 925a091a0d..14e6a5d9f3 100644 --- a/crates/redact/tests/operator_memory.rs +++ b/crates/redact/tests/operator_memory.rs @@ -44,14 +44,44 @@ fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { Some((out, peak_kib()?.saturating_sub(before) >> 10)) } -/// Two reads of a form of 16 million operators: +/// A page that draws an image under the mark, a form `form` with resources of its own outside it, +/// and a glyph of a Type 3 font outside it, whose program is `glyph`: the Type 3 cleanup reads +/// every page and form, and the programs of the glyphs it keeps. +fn type3_page(form: &[u8], glyph: &[u8]) -> Document { + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(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 << /Font << /T3 7 0 R >> /XObject << /Im1 5 0 R /Fm 6 0 R >> >> >>" + .to_vec(), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /Fm Do BT /T3 10 Tf 10 10 Td (a) Tj ET"), + stream_obj(IMAGE, b"o"), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << >>", b""), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [1000] /Resources << >> >>" + .to_vec(), + stream_obj("", b""), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(6, 0), form); + compress(&mut doc, ObjRef::new(8, 0), glyph); + mark(&mut doc); + doc +} + +/// Four reads of a stream of 16 million operators: /// - the page's check of what it still draws walks a form without resources of its own outside /// the mark, and refuses at 2,000,001 operators; /// - the unlinking check of a page nobody marked, which shares the marked page's resources, -/// looks through such a form for the image the marked page removed, and finds no `Do`. +/// looks through such a form for the image the marked page removed, and finds no `Do`; +/// - the Type 3 cleanup scans a form with resources of its own that nothing else reads, for the +/// glyphs it shows; +/// - the Type 3 cleanup reads the program of a glyph it keeps, for the resources it uses. /// -/// Each parsed every operator first: the peak grew by 4,415 and 2,527 MiB. Now the first holds at -/// most one operator past the walk's limit, and the second none. +/// Each parsed every operator first: the peak grew by 4,415 and 2,527 MiB in the first two. Now +/// the first holds at most one operator past the walk's limit, and the others none. #[test] fn the_forms_followed_through_resources_are_read_one_operator_at_a_time() { // The page's check: an image under the mark, and the form outside it. @@ -96,4 +126,16 @@ fn the_forms_followed_through_resources_are_read_one_operator_at_a_time() { let Some((result, grew)) = peak_growth(|| apply(&mut doc, Some(&[0]))) else { return }; assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the marked page's image goes"); assert!(grew < 1024, "the unlinking check grew the peak by {grew} MiB"); + + let mut doc = type3_page(&operators(), b"1000 0 d0"); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the Type 3 scan: the image goes"); + assert!(grew < 1024, "the Type 3 cleanup's scan grew the peak by {grew} MiB"); + + let mut glyph = b"1000 0 d0\n".to_vec(); + glyph.extend(operators()); + let mut doc = type3_page(b"", &glyph); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1), "the glyph program: the image goes"); + assert!(grew < 1024, "the Type 3 cleanup's read of a glyph program grew the peak by {grew} MiB"); }