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 <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 17:47:19 +01:00
1 parent 8e593d33a6
commit 8bf63c8792
2 files changed
+60 -14

No files matched your search

+14 -10
View File
@@ -131,11 +131,10 @@ struct UsedInGlyphs {
} }
fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> {
let parsed = pdfcraft_content::parse(data); // One operator at a time, so a glyph program of millions of them is never held whole. A byte
if parsed.skipped > 0 { // that can't be read anywhere in it still refuses, once the loop has read it all.
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); let mut read = pdfcraft_content::ops(data);
} for op in read.by_ref() {
for op in parsed.ops {
match op.op.as_slice() { match op.op.as_slice() {
b"Do" => { b"Do" => {
if let Some(n) = op.last_name() { 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(()) 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() }); return Err(CosError::Syntax { offset: 0, detail: "redaction exceeded max depth inspecting Type 3 font content".into() });
} }
let joined = Pieces::join(content); let joined = Pieces::join(content);
let parsed = joined.parse(); // One operator at a time, so content of millions of them is never held whole. The scan only
if parsed.skipped > 0 { // reads, and a byte that can't be read anywhere still refuses, once the loop has read it all.
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); let mut read = pdfcraft_content::ops(joined.data());
}
let mut stack: Vec<Option<FontKey>> = Vec::new(); let mut stack: Vec<Option<FontKey>> = Vec::new();
let mut current_font = initial_font; let mut current_font = initial_font;
for op in parsed.ops { for op in read.by_ref() {
match op.op.as_slice() { match op.op.as_slice() {
b"q" => stack.push(current_font), b"q" => stack.push(current_font),
b"Q" => { b"Q" => {
@@ -257,6 +258,9 @@ fn scan_content_stream(
_ => apply_text_op(ctx, res, &op, &mut current_font), _ => 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(()) Ok(())
} }
+46 -4
View File
@@ -44,14 +44,44 @@ fn peak_growth<T>(f: impl FnOnce() -> T) -> Option<(T, u64)> {
Some((out, peak_kib()?.saturating_sub(before) >> 10)) 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 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 mark, and refuses at 2,000,001 operators;
/// - the unlinking check of a page nobody marked, which shares the marked page's resources, /// - 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 /// Each parsed every operator first: the peak grew by 4,415 and 2,527 MiB in the first two. Now
/// most one operator past the walk's limit, and the second none. /// the first holds at most one operator past the walk's limit, and the others none.
#[test] #[test]
fn the_forms_followed_through_resources_are_read_one_operator_at_a_time() { 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. // 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 }; 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_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"); 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");
} }