fix(redact): read each Type 3 glyph program once per cleanup (#5993)

clean_type3_font decoded and parsed every surviving glyph program of a
font, for every font. Many small font dictionaries sharing one
compressed CharProc multiplied that work without multiplying the stored
data, with a fresh allowance for each decode.

The resource names each indirect glyph program uses are now cached by
object across fonts (resource-name interpretation stays per font, since
each font prunes its own /Resources), and all decoding spends one
cleanup-wide DecodeBudget; past it the cleanup fails closed.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 14:47:42 -05:00
1 parent 87aebd1b24
commit 13d6367385
2 files changed
+64 -4

No files matched your search

@@ -390,6 +390,35 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() {
assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived");
}
/// Type 3 cleanup decoded a glyph program again for every font listing it: 2 000 fonts sharing
/// one large program read it 2 000 times. It is read once now, and the cleanup still keeps the
/// glyph every font shows.
#[test]
fn a_glyph_program_many_type3_fonts_share_is_read_once() {
const FONTS: usize = 2_000;
let names: String = (0..FONTS).map(|i| format!("/T{i} {} 0 R ", 6 + i)).collect();
let shows: String = (0..FONTS).map(|i| format!("/T{i} 10 Tf (A) Tj ")).collect();
let mut objs = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << {names}>> >> >>").into_bytes(),
stream("", format!("BT 10 100 Td {shows}ET").as_bytes()),
stream("", [b"SHARED_GLYPH ".as_slice(), &b"0 0 m 1 1 l S ".repeat(50_000)].concat()),
];
objs.extend((0..FONTS).map(|_| {
b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 65 /Widths [0] /Encoding << /Differences [65 /A] >> /CharProcs << /A 5 0 R >> >>".to_vec()
}));
let mut doc = pdf(objs);
// The glyphs are drawn at x 10 with no width, so a mark away from them leaves them all.
mark(&mut doc, 0, &[[200.0, 200.0, 250.0, 250.0]], "");
let started = std::time::Instant::now();
apply(&mut doc, None).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
let font = doc.get(ObjRef::new(6, 0));
let procs = doc.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
assert!(procs.as_dict().unwrap().contains(b"A"), "the glyph every font shows was unlinked");
}
/// 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 >> >>";
+35 -4
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_content::Pieces;
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object};
use crate::{RedactError, page_streams};
@@ -130,6 +130,25 @@ struct UsedInGlyphs {
properties: HashSet<Vec<u8>>,
}
impl UsedInGlyphs {
fn merge(&mut self, other: &UsedInGlyphs) {
self.xobjects.extend(other.xobjects.iter().cloned());
self.colorspaces.extend(other.colorspaces.iter().cloned());
self.patterns.extend(other.patterns.iter().cloned());
self.shadings.extend(other.shadings.iter().cloned());
self.extgstates.extend(other.extgstates.iter().cloned());
self.fonts.extend(other.fonts.iter().cloned());
self.properties.extend(other.properties.iter().cloned());
}
}
/// What one Type 3 cleanup reads of glyph programs: the resource names each indirect program
/// uses, read once however many fonts share it, and one decoding allowance for all of them.
struct GlyphReads {
used: HashMap<ObjRef, UsedInGlyphs>,
budget: DecodeBudget,
}
fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> {
// 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.
@@ -731,9 +750,10 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> {
}
// For each Type 3 font: unlink unused glyphs from CharProcs and prune exclusive resources.
let mut reads = GlyphReads { used: HashMap::new(), budget: DecodeBudget::page() };
for (font_key, font_dict) in &type3_fonts {
let surviving = used_glyphs.get(font_key).cloned().unwrap_or_default();
clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving)?;
clean_type3_font(doc, &holders, &form_holders, font_key, font_dict, &surviving, &mut reads)?;
}
Ok(())
@@ -808,6 +828,7 @@ fn clean_type3_font(
font_key: &FontKey,
font_dict: &Dict,
surviving: &HashSet<Vec<u8>>,
reads: &mut GlyphReads,
) -> Result<(), RedactError> {
let Some(char_procs_val) = font_dict.get(b"CharProcs") else {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: missing CharProcs".into() }.into());
@@ -851,6 +872,10 @@ fn clean_type3_font(
let mut used_in_glyphs = UsedInGlyphs::default();
for (_, proc_val) in char_procs.iter() {
if let Some(used) = proc_val.as_ref().and_then(|r| reads.used.get(&r)) {
used_in_glyphs.merge(used);
continue;
}
let resolved = doc.resolve(proc_val);
let Object::Stream(s) = &*resolved else {
return Err(CosError::Syntax {
@@ -859,8 +884,14 @@ fn clean_type3_font(
}
.into());
};
let data = doc.with_direct_parms(s).decoded()?;
collect_used_resources_in_stream(&data, &mut used_in_glyphs)?;
// Past the allowance the cleanup can't be shown complete, and the redaction is refused.
let data = reads.budget.decode(&doc.with_direct_parms(s))?;
let mut used = UsedInGlyphs::default();
collect_used_resources_in_stream(&data, &mut used)?;
used_in_glyphs.merge(&used);
if let Some(r) = proc_val.as_ref() {
reads.used.insert(r, used);
}
}
if prune_type3_font_resources(doc, &mut f_res, &used_in_glyphs) {