Type 3 pruning decodes a page's appearances within one byte allowance

prune_type3_from_resources decoded every appearance without resources of
its own into one list before counting any operators, each up to the
single-stream limit, so their sum had no bound. They are now decoded one
at a time against one page's DecodeBudget, as the page content is, and
passing it refuses the redaction.
This commit is contained in:
oracola91 committed 2026-11-07 16:53:20 +02:00
1 parent a842564e3f
commit f7d8fbf21d
2 files changed
+38 -7

No files matched your search

@@ -412,6 +412,33 @@ fn a_page_s_appearances_share_one_operator_total() {
assert!(refused.is_err(), "two appearances over the operator cap together were judged in part: {refused:?}");
}
/// Their decoded bytes share one page's allowance too. Two compressed appearances, each under it
/// but past it together (and blank, so far from the operator cap), refuse the redaction rather
/// than being decoded and held in full.
#[test]
fn a_page_s_appearances_share_one_decoded_byte_allowance() {
let packed = pdfcraft_filters::encode_flate(&vec![b' '; pdfcraft_cos::MAX_PAGE_DECODED / 2 + 1]);
let big = || stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Filter /FlateDecode", packed.as_slice());
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 /Annots [7 0 R 8 0 R] >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"),
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
type3_abc(11),
b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /AP << /N 9 0 R >> >>".to_vec(),
b"<< /Type /Annot /Subtype /FreeText /Rect [150 240 250 260] /P 3 0 R /AP << /N 10 0 R >> >>".to_vec(),
big(),
big(),
stream("", b"% GLYPH_A"),
stream("", b"% GLYPH_B"),
stream("", b"% GLYPH_C"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
let refused = apply(&mut doc, None);
assert!(refused.is_err(), "two appearances past the page's decoded-byte allowance together were read in full: {refused:?}");
}
/// Whether the first page of a full save of `doc` lists the font `name` in its resources.
fn saved_first_page_lists_font(doc: &Document, name: &[u8]) -> bool {
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
+11 -7
View File
@@ -381,9 +381,9 @@ fn appearance_streams(doc: &Document, annot: &Object) -> Vec<ObjRef> {
streams
}
/// The decoded appearance streams of a page's annotations that have no resources of their own:
/// they draw through the page's, as [`scan_stream_object`] reads them.
fn resourceless_appearances(doc: &Document, page: &Dict) -> Result<Vec<Vec<u8>>, CosError> {
/// The appearance streams of a page's annotations that have no resources of their own: they draw
/// through the page's, as [`scan_stream_object`] reads them.
fn resourceless_appearances(doc: &Document, page: &Dict) -> Vec<ObjRef> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for annot in crate::annots_of(doc, page) {
@@ -394,11 +394,11 @@ fn resourceless_appearances(doc: &Document, page: &Dict) -> Result<Vec<Vec<u8>>,
if let Object::Stream(s) = &*doc.get(r)
&& s.dict.get(b"Resources").is_none_or(|ro| doc.resolve(ro).as_dict().is_none())
{
out.push(s.decoded()?);
out.push(r);
}
}
}
Ok(out)
out
}
/// Add to `ops` those of the soft mask groups without resources of their own that `ops` set
@@ -495,8 +495,12 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content
if fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) {
if let Some(page) = page {
// One total for all of them, as for the content: each appearance under the cap could
// still add up to far more than it.
for data in resourceless_appearances(doc, page)? {
// still add up to far more than it. Their decoded bytes share one page's allowance
// too, and each is decoded only in its turn, so they are never all held at once.
let mut budget = pdfcraft_cos::DecodeBudget::page();
for r in resourceless_appearances(doc, page) {
let Object::Stream(s) = &*doc.get(r) else { continue };
let data = budget.decode(s)?;
let more = crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?;
if ops.len().saturating_add(more.len()) > 2_000_000 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect annotation appearances".into() });