Merge pull request #6860 from Piohrun/fix/redact-type3-check-first

M8: the Type 3 cleanup charges the page only when there is a Type 3 font
This commit is contained in:
FredApps authored and GitHub committed 2026-11-11 14:36:10 +01:00
commit 825ba80eac
3 files changed
+133 -29

No files matched your search

+5 -3
View File
@@ -9,8 +9,9 @@ use super::{
/// by a form without /Resources of its own, which draws through `xo` too. Such forms are read
/// within `budget`, what is left of the page's allowance ([`resource_ops`]). Fails, as the cleanup
/// of the fonts and properties these forms draw with does, when that can't be told: such a form
/// doesn't decode (an image codec or a /Filter it can't run, say) or runs past `budget`, the walk
/// passes 2,000,000 operators, or the parser can't read all of such a form or of `content`.
/// doesn't decode (an image codec or a /Filter it can't run, say) or runs past `budget` (for the
/// fonts' cleanup, an allowance of its own when no Type 3 font is listed), the walk passes
/// 2,000,000 operators, or the parser can't read all of such a form or of `content`.
/// Corrupt data in such a form is read, tolerantly, up to the damage, which may be nothing.
pub(crate) fn prune(doc: &Document, budget: &mut DecodeBudget, xo: &mut Dict, replaced: &[Vec<u8>], content: &[Vec<u8>]) -> Result<(), CosError> {
if replaced.is_empty() {
@@ -473,7 +474,8 @@ pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Op
/// 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, its decoded bytes charged to `budget`, so the
/// cleanups of one page that walk the same forms spend one allowance between them. An error
/// cleanups of one page that walk the same forms spend one allowance between them (all but the
/// fonts' cleanup when no Type 3 font is listed, which has one of its own). 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 or runs past `budget`. Operators are
/// read one at a time, so the walk stops at one past the limit rather than holding them all.
+101 -13
View File
@@ -3571,14 +3571,43 @@ fn what_content_draws_through_its_resources_is_read_within_its_allowance() {
}
}
/// An unused Type 3 font, listed as /T3 beside /F1 in the `/Font` of `holder`'s resources (a page's
/// or a form's), so that the font cleanup, which prunes only Type 3 fonts, charges its read of what
/// they draw to the page's allowance (without one it has an allowance of its own).
fn list_type3(doc: &mut Document, holder: ObjRef) {
let font = Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type3")),
(b"FontBBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1000.into(), 1000.into()])),
(b"FontMatrix".to_vec(), Object::Array(vec![Object::Real(0.001), 0.into(), 0.into(), Object::Real(0.001), 0.into(), 0.into()])),
(b"CharProcs".to_vec(), Object::Dict(Dict::new())),
]));
let font = Object::Ref(doc.add(font));
let list = |d: &mut Dict| {
let Some(Object::Dict(res)) = d.get_mut(b"Resources") else { panic!("{holder:?} has resources") };
let Some(Object::Dict(fonts)) = res.get_mut(b"Font") else { panic!("{holder:?} lists fonts") };
fonts.set(b"T3".to_vec(), font);
};
match &*doc.get(holder) {
Object::Stream(s) => {
let mut s = s.clone();
list(&mut s.dict);
doc.set(holder, Object::Stream(s));
}
_ => doc.update_dict(holder, list).unwrap(),
}
}
/// The cleanups after the content walk spend what the walk left of the page's allowance, one
/// allowance between them. A form without resources of its own that crosses the mark is read by
/// the walk, then again by the check of what the page still draws and by the font cleanup: at
/// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at
/// 50 MiB it goes on.
/// the walk, then again by the check of what the page still draws and by the font cleanup, which
/// charges the page's allowance when the page lists a Type 3 font: at 100 MiB, three reads are more
/// than the page's 256 MiB, and the redaction is refused; at 50 MiB it goes on. With no Type 3 font
/// the font cleanup's read has an allowance of its own, so the page's pays for two reads of
/// 100 MiB, and the redaction goes on.
#[test]
fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance() {
for (mib, refused) in [(100, true), (50, false)] {
for (mib, type3, refused) in [(100, true, true), (50, true, false), (100, false, false)] {
let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]);
let mut doc = one_page(
b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do",
@@ -3588,19 +3617,70 @@ fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"),
],
);
if type3 {
list_type3(&mut doc, ObjRef::new(3, 0));
}
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB");
assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB, Type 3: {type3}");
}
}
/// Text under the mark in a content stream that holds bytes the parser can't read, inside a `TJ`
/// array, is read past by the walk and would stay. The font cleanup's read of what the content
/// draws refuses such bytes in the streams and forms the walk leaves as they were, and makes that
/// read with any font listed, a Type 3 font or not (with none, on an allowance of its own). Here
/// the walk rewrites the text in the page's first stream and leaves the broken `TJ` as it was:
/// Apply Redactions refuses the page, whether that `TJ` is in the page's second stream or in a
/// form without resources of its own, and so does Remove Hidden Information. The cleanups run only
/// when the walk rewrites some of the page, and read what it rewrote as rewritten, so such bytes in
/// a stream it rewrites still go by, and so do those on a page it rewrites nothing of, unless the
/// document still lists a Type 3 font (`unlink_type3` then reads every page).
#[test]
fn text_beside_bytes_the_parser_cannot_read_refuses_the_redaction() {
use crate::sanitize::{Hidden, remove_hidden};
let page_of = |second: &[u8]| {
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 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>".to_vec(),
stream("", b"BT /F1 10 Tf 20 230 Td (SECRET) Tj ET"),
stream("", second),
FONT.replace("95 0 R", "7 0 R").into_bytes(),
widths(),
])
};
for tail in [
&b"BT /F1 10 Tf 20 260 Td [(TAIL) ) (SECRET)] TJ ET"[..],
b"BT /F1 10 Tf 20 260 Td [(TAIL) } (SECRET)] TJ ET",
b"BT /F1 10 Tf 20 260 Td [(TAILSECRET) )] TJ ET",
b"BT /F1 10 Tf 20 260 Td [) (TAILSECRET)] TJ ET",
] {
let mut doc = page_of(tail);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert!(apply(&mut doc, None).is_err(), "{}", String::from_utf8_lossy(tail));
}
let mut doc = one_page(
b"BT /F1 10 Tf 20 230 Td (SECRET) Tj ET /FmBare Do",
"/XObject << /FmBare 7 0 R >>",
vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 20 260 Td [(TAIL) ) (SECRET)] TJ ET")],
);
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert!(apply(&mut doc, None).is_err(), "a form's text");
let mut doc = page_of(b"BT /F1 10 Tf 3 Tr 20 260 Td [(TAIL) ) (HIDDEN)] TJ ET");
doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), b"BT /F1 10 Tf 3 Tr 20 230 Td (HIDDEN) Tj ET".to_vec())));
assert!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), "hidden text");
}
/// The cleanups of a form the redaction rewrites spend the page's allowance too. The rewritten
/// form has resources of its own, so the page's cleanups don't follow it, and draws through them a
/// form without any that crosses the mark: the walk reads that form, then the check of what the
/// rewritten form still draws and its font cleanup read it again. At 100 MiB, three reads are more
/// than the page's 256 MiB, and the redaction is refused; at 50 MiB it goes on.
/// rewritten form still draws and its font cleanup (when it lists a Type 3 font) read it again. At
/// 100 MiB, three reads are more than the page's 256 MiB, and the redaction is refused; at 50 MiB
/// it goes on. With no Type 3 font the font cleanup's read has an allowance of its own, so the
/// page's pays for two reads of 100 MiB, and the redaction goes on.
#[test]
fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() {
for (mib, refused) in [(100, true), (50, false)] {
for (mib, type3, refused) in [(100, true, true), (50, true, false), (100, false, false)] {
let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]);
let mut doc = one_page(
b"/FmText Do",
@@ -3613,20 +3693,25 @@ fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() {
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank),
],
);
if type3 {
list_type3(&mut doc, ObjRef::new(7, 0));
}
mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], "");
assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB");
assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB, Type 3: {type3}");
}
}
/// Removing hidden text spends what its content walk left of the page's allowance too. The page
/// draws a form holding hidden text, which is rewritten, and a form without resources of its own:
/// the walk reads the second, then the check of what the page still draws and the font cleanup
/// read it again. At 100 MiB, three reads are more than the page's 256 MiB, and the removal is
/// refused; at 50 MiB it goes on.
/// (when the page lists a Type 3 font) read it again. At 100 MiB, three reads are more than the
/// page's 256 MiB, and the removal is refused; at 50 MiB it goes on. With no Type 3 font the font
/// cleanup's read has an allowance of its own, so the page's pays for two reads of 100 MiB, and the
/// removal goes on.
#[test]
fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance() {
use crate::sanitize::{Hidden, remove_hidden};
for (mib, refused) in [(100, true), (50, false)] {
for (mib, type3, refused) in [(100, true, true), (50, true, false), (100, false, false)] {
let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]);
let mut doc = one_page(
b"/FmHidden Do /FmBare Do",
@@ -3636,7 +3721,10 @@ fn removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank),
],
);
assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB");
if type3 {
list_type3(&mut doc, ObjRef::new(3, 0));
}
assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenText]).is_err(), refused, "{mib} MiB, Type 3: {type3}");
}
}
+27 -13
View File
@@ -539,10 +539,19 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option<Object> {
font_entry.into_iter().next()
}
/// Whether font `f` (a reference, or a font dictionary) is a Type 3 font, the only kind the font
/// cleanup prunes: its `/Subtype` is read directly, as the cleanup's other readers read it, so one
/// given by reference isn't recognised by any of them. Which allowance the cleanup's read is charged
/// to, and what it prunes, both go by this.
fn is_type3(doc: &Document, f: &Object) -> bool {
doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))
}
/// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten
/// content no longer draws any text using that font name. `page` is the page's dictionary when
/// `res` are a page's resources. What it reads is charged to `budget`, what is left of the page's
/// allowance.
/// allowance, when the resources list a Type 3 font, and otherwise to an allowance of a page of its
/// own (below).
pub(crate) fn prune_type3_from_resources(
doc: &Document,
budget: &mut pdfcraft_cos::DecodeBudget,
@@ -556,12 +565,20 @@ pub(crate) fn prune_type3_from_resources(
}
// The operators of forms that draw through these resources count too: dropping a font a child
// form still shows would leave its text without one.
let mut ops = crate::interp::resource_ops(doc, budget, res, content)?;
// form still shows would leave its text without one. Without a Type 3 font nothing is pruned,
// but the read is still made: it refuses bytes the parser can't read in what the content draws,
// and on a page with nothing replaced and no properties listed no other check does, unless the
// document still lists a Type 3 font (`unlink_type3` then reads every page). The walk reads
// past them, so text they hide would stay under the mark. The read is then charged to an
// allowance of a page of its own, not to what the walk left of the page's, since it decides
// nothing about the fonts.
let type3 = fonts.iter().any(|(_, f)| is_type3(doc, f));
let mut own = pdfcraft_cos::DecodeBudget::page();
let mut ops = crate::interp::resource_ops(doc, if type3 { &mut *budget } else { &mut own }, res, content)?;
// So do those of a page's annotation appearances, and of soft mask groups, without resources of
// their own. They are read only when there is a Type 3 font to keep, so other files are not
// refused over them.
if fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) {
if 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. Their decoded bytes, and their forms', are charged to
@@ -605,15 +622,12 @@ pub(crate) fn prune_type3_from_resources(
let mut changed = false;
let font_names: Vec<Vec<u8>> = fonts.iter().map(|(n, _)| n.to_vec()).collect();
for name in font_names {
if let Some(font_obj) = fonts.get(&name) {
let resolved = doc.resolve(font_obj);
if let Some(d) = resolved.as_dict()
&& d.name(b"Subtype") == Some(b"Type3")
&& !drawn_font_names.contains(&name)
{
fonts.remove(&name);
changed = true;
}
if let Some(font_obj) = fonts.get(&name)
&& is_type3(doc, font_obj)
&& !drawn_font_names.contains(&name)
{
fonts.remove(&name);
changed = true;
}
}