diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index acadca26f0..edb1a68708 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -202,6 +202,12 @@ impl<'a> Scope<'a> { } } + /// What is left of the page's allowance once the content is walked, for the cleanups after it + /// that read the forms drawing through the page's resources ([`prune`]) to go on spending. + pub(crate) fn into_budget(self) -> DecodeBudget { + self.budget + } + /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { self.hidden_text.is_some() || self.layers_enabled @@ -1065,18 +1071,18 @@ fn xobject( } if let Some(data) = &new_data { let content = std::slice::from_ref(data); - match prune_properties(doc, &mut cleaned_res, content) { + match prune_properties(doc, &mut scope.budget, &mut cleaned_res, content) { Ok(retired) => out.retired_properties.extend(retired), Err(error) => { out.error = Some(error); return None; } } - if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) { + if let Err(error) = prune_form_resources(doc, &mut scope.budget, &mut cleaned_res, content) { out.error = Some(error); return None; } - if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut scope.budget, &mut cleaned_res, content) { out.error = Some(error); return None; } diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 3e9b49bd04..97701a26c6 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,22 +1,23 @@ use super::{ - CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, - overlaps, page_streams, res_dict, + CosError, DecodeBudget, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, + is_vertical, num, overlaps, page_streams, res_dict, }; /// Drop every `replaced` name of the XObject resources `xo` that the rewritten `content` no /// longer draws, so a full-rewrite save stops reaching the original image or form it named. A /// name still drawn is kept: by a surviving `Do` (the same image shown again outside a mark), or -/// by a form without /Resources of its own, which draws through `xo` too. Fails, as the cleanup of -/// the fonts and properties these forms draw with does, when that can't be told: `Stream::decoded` -/// refuses such a form (an image codec or a /Filter it can't run, say, or more than 1 GiB), the -/// walk passes 2,000,000 operators, or the parser can't read all of such a form or of `content`. +/// 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`. /// Corrupt data in such a form is read, tolerantly, up to the damage, which may be nothing. -pub(crate) fn prune(doc: &Document, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { +pub(crate) fn prune(doc: &Document, budget: &mut DecodeBudget, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { if replaced.is_empty() { return Ok(()); } let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(xo.clone()))]); - let ops = resource_ops(doc, &res, content)?; + let ops = resource_ops(doc, budget, &res, content)?; let drawn: HashSet<&[u8]> = ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name).collect(); for n in replaced { if !drawn.contains(n.as_slice()) { @@ -162,7 +163,8 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c } let mut used = HashSet::new(); for content in contents { - for op in resource_ops(doc, res, content)? { + // Each is one page's content or one form's, with the allowance of a page. + for op in resource_ops(doc, &mut DecodeBudget::page(), res, content)? { if matches!(op.op.as_slice(), b"BDC" | b"DP") && let Some(name) = op.name(1) { @@ -275,10 +277,11 @@ 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. 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. -pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { +/// 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 +/// 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`. +pub(crate) fn resource_ops(doc: &Document, budget: &mut DecodeBudget, res: &Dict, content: &[Vec]) -> Result, CosError> { let parsed = Pieces::join(content).parse(); if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully read the content that uses these resources".into() }); @@ -296,7 +299,7 @@ pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> R && s.dict.name(b"Subtype") == Some(b"Form") && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) { - let parsed = pdfcraft_content::parse(&doc.with_direct_parms(s).decoded()?); + let parsed = pdfcraft_content::parse(&budget.decode(&doc.with_direct_parms(s))?); if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); } @@ -307,13 +310,13 @@ pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> R Ok(ops) } -pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { +pub(crate) fn prune_properties(doc: &Document, budget: &mut DecodeBudget, res: &mut Dict, content: &[Vec]) -> Result, CosError> { let mut properties = res_dict(doc, res, b"Properties"); if properties.is_empty() { return Ok(Vec::new()); } let mut retired = Vec::new(); - let ops = resource_ops(doc, res, content)?; + let ops = resource_ops(doc, budget, res, content)?; let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) @@ -330,8 +333,8 @@ pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { - let ops = resource_ops(doc, res, content)?; +pub(super) fn prune_form_resources(doc: &Document, budget: &mut DecodeBudget, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, budget, res, content)?; let mut xo = res_dict(doc, res, b"XObject"); let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); for name in names { diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 737a41a9e5..9318a56329 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -772,9 +772,11 @@ fn redact_page_content( let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { + // What the walk leaves of the page's allowance, the cleanups below spend. + let (out, mut budget) = { let mut scope = Scope::new(rects, Mode::Apply, report, budget, images); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); + (out, scope.into_budget()) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot // tell whether those glyphs lie under a mark. Rather than report success and possibly @@ -825,7 +827,7 @@ fn redact_page_content( _ => Vec::new(), }) .collect(); - interp::prune(doc, &mut xo, &out.replaced, &page_content)?; + interp::prune(doc, &mut budget, &mut xo, &out.replaced, &page_content)?; for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } @@ -845,8 +847,8 @@ fn redact_page_content( // A Type 3 glyph without resources paints with patterns nobody tracked: keep them all. let painted = if out.keeps_all_patterns { &out.patterns_seen } else { &out.patterns_painted }; report.patterns_removed += interp::prune_patterns(doc, &mut res, &out.patterns_seen, painted, pruned_patterns); - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + retired_properties.extend(interp::prune_properties(doc, &mut budget, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut budget, &mut res, &cleaned_data)?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 7941268576..61862aa81f 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -384,7 +384,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let blocks = scope.layer_blocks; let glyphs = scope.report.glyphs; let retained_for_page = std::mem::take(&mut scope.retained_off_layers); - drop(scope); + // What the walk leaves of the page's allowance, the cleanups below spend. + let mut budget = scope.into_budget(); total += out.residue + blocks + glyphs; retained_off_layers.extend(retained_for_page); if !write { @@ -418,14 +419,14 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed form or image bytes cannot survive in the saved file. - crate::interp::prune(doc, &mut xo, &out.replaced, &page_content)?; + crate::interp::prune(doc, &mut budget, &mut xo, &out.replaced, &page_content)?; for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } res.set(b"XObject".to_vec(), Object::Dict(xo)); } - retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); - crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; + retired_properties.extend(crate::interp::prune_properties(doc, &mut budget, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut budget, &mut res, &page_content)?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 5c6de2b7b8..7cc726a09c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1746,7 +1746,7 @@ fn the_pages_check_does_not_follow_a_form_with_resources_of_its_own() { stream(image, b"p"), ]); let mut xo = Dict::from_iter([(b"FmOwn".to_vec(), Object::Ref(ObjRef::new(3, 0))), (b"Im1".to_vec(), Object::Ref(ObjRef::new(5, 0)))]); - crate::interp::prune(&doc, &mut xo, &[b"Im1".to_vec()], &[b"/FmOwn Do".to_vec()]).unwrap(); + crate::interp::prune(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &mut xo, &[b"Im1".to_vec()], &[b"/FmOwn Do".to_vec()]).unwrap(); assert!(!xo.contains(b"Im1") && xo.contains(b"FmOwn"), "{xo:?}"); } @@ -1757,7 +1757,11 @@ fn what_content_draws_through_its_resources_is_read_up_to_two_million_operators( let doc = Document::new_empty(); for (ops, read) in [(2_000_000, true), (2_000_001, false)] { let content = b"n ".repeat(ops); - assert_eq!(crate::interp::resource_ops(&doc, &Dict::new(), &[content]).is_ok(), read, "{ops} operators"); + assert_eq!( + crate::interp::resource_ops(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &Dict::new(), &[content]).is_ok(), + read, + "{ops} operators" + ); } // The content's own `/Fm Do` is one of them. for (ops, read) in [(1_999_999, true), (2_000_000, false)] { @@ -1767,7 +1771,11 @@ fn what_content_draws_through_its_resources_is_read_up_to_two_million_operators( stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", b"n ".repeat(ops)), ]); let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm".to_vec(), Object::Ref(ObjRef::new(3, 0)))])))]); - assert_eq!(crate::interp::resource_ops(&doc, &res, &[b"/Fm Do".to_vec()]).is_ok(), read, "a form of {ops} operators"); + assert_eq!( + crate::interp::resource_ops(&doc, &mut pdfcraft_cos::DecodeBudget::page(), &res, &[b"/Fm Do".to_vec()]).is_ok(), + read, + "a form of {ops} operators" + ); } } @@ -1841,6 +1849,88 @@ fn an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept() { assert!(surviving_references::full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); } +/// The walk of what content draws through its resources charges the forms it reads to the +/// allowance it is given: a form decoding to more than is left is refused, one within it read. +#[test] +fn what_content_draws_through_its_resources_is_read_within_its_allowance() { + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [] /Count 0 >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", b"n ".repeat(1000)), + ]); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm".to_vec(), Object::Ref(ObjRef::new(3, 0)))])))]); + for (allowance, read) in [(2000, true), (1999, false)] { + let mut budget = pdfcraft_cos::DecodeBudget::new(allowance, 0); + assert_eq!(crate::interp::resource_ops(&doc, &mut budget, &res, &[b"/Fm Do".to_vec()]).is_ok(), read, "{allowance} bytes"); + } +} + +/// 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. +#[test] +fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance() { + for (mib, refused) in [(100, true), (50, 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", + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + } +} + +/// The cleanups of a form the redaction rewrites spend the page's allowance too. The rewritten +/// form draws a form without resources of its own that crosses the mark: the walk reads it, 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. +#[test] +fn the_cleanups_of_a_rewritten_form_spend_the_pages_allowance() { + for (mib, refused) in [(100, true), (50, false)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"/FmText Do", + "/XObject << /FmText 7 0 R /FmBare 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 20 250 Td (SECRET) Tj ET /FmBare Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /FlateDecode", &blank), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert_eq!(apply(&mut doc, None).is_err(), refused, "{mib} MiB"); + } +} + +/// 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. +#[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)] { + let blank = pdfcraft_filters::encode_flate(&vec![b' '; mib << 20]); + let mut doc = one_page( + b"/FmHidden Do /FmBare Do", + "/XObject << /FmHidden 7 0 R /FmBare 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 3 Tr 20 250 Td (HIDDEN) Tj ET"), + 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"); + } +} + /// A form with resources of its own draws through those, not its caller's: one on another page /// that names a different image /Im1 doesn't keep the redacted /Im1 in the dictionary both pages /// inherit. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 9c82d540ac..80d913e6ad 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -343,7 +343,12 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { /// 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. -pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { +pub(crate) fn prune_type3_from_resources( + doc: &Document, + budget: &mut pdfcraft_cos::DecodeBudget, + res: &mut Dict, + content: &[Vec], +) -> Result<(), CosError> { let mut fonts = res_dict(doc, res, b"Font"); if fonts.is_empty() { return Ok(()); @@ -351,7 +356,7 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content // 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 ops = crate::interp::resource_ops(doc, res, content)?; + let ops = crate::interp::resource_ops(doc, budget, res, content)?; let mut drawn_font_names = HashSet::new(); for op in &ops {