From 7b1301b34f8d53d01600a98ba25cc6b09b7d35e2 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 16:08:24 +0100 Subject: [PATCH 01/16] M8: redaction keeps an image a form without resources still draws When a redaction removes or replaces an image or form under a mark, it takes the name out of the page's resources unless the page still draws it. It looked only at the page's own `Do` operators. A form with no /Resources of its own draws through the page's, so when such a form outside the mark drew the same image, the page lost the name and the form drew nothing there. The page now keeps a name that a form drawing through its resources still draws, found as the cleanup of the fonts and properties these forms use already finds them. As that cleanup does, it refuses the page when such a form can't be read. On a page with fonts or properties to clean up that was already the case; on a page with neither, the name used to go. A rewritten form had the same fault one level down. It dropped the names it replaced before `prune_form_resources` ran. That function already keeps what a form drawing through these resources still draws, so the rewritten form now leaves the decision to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp.rs | 8 ++-- crates/redact/src/interp/prune.rs | 22 ++++++---- crates/redact/src/lib.rs | 2 +- crates/redact/src/sanitize.rs | 2 +- crates/redact/src/tests.rs | 73 +++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index b0a7257cb3..acadca26f0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1053,11 +1053,11 @@ fn xobject( let mut cleaned_res = res.clone(); // Install rewritten nested forms before finding live property names: a new form may // still inherit its caller's resources, and its new Do name must resolve here. - if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { + // `prune_form_resources` below then drops the XObjects this form no longer draws, the + // ones it replaced among them, keeping those a form drawing through these resources + // still draws. + if !inner.xobjects.is_empty() { let mut xo = res_dict(doc, &cleaned_res, b"XObject"); - if let Some(data) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); - } for (name, reference) in &inner.xobjects { xo.set(name.clone(), Object::Ref(*reference)); } diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 188af03abe..8dc32d6db5 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -3,21 +3,25 @@ use super::{ overlaps, page_streams, res_dict, }; -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { +/// 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: such a form +/// won't decode, or the parser can't read all of it or of `content`. +pub(crate) fn prune(doc: &Document, xo: &mut Dict, replaced: &[Vec], content: &[Vec]) -> Result<(), CosError> { if replaced.is_empty() { - return; + return Ok(()); } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + let res = Dict::from_iter([(b"XObject".to_vec(), Object::Dict(xo.clone()))]); + let ops = resource_ops(doc, &res, content)?; + let drawn: HashSet<&[u8]> = ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name).collect(); for n in replaced { - if !still_drawn(n) { + if !drawn.contains(n.as_slice()) { xo.remove(n); } } + Ok(()) } /// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6d323004ad..737a41a9e5 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -825,7 +825,7 @@ fn redact_page_content( _ => Vec::new(), }) .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); + interp::prune(doc, &mut xo, &out.replaced, &page_content)?; for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f8088e0bba..7941268576 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -418,7 +418,7 @@ 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(&mut xo, &out.replaced, &page_content); + crate::interp::prune(doc, &mut xo, &out.replaced, &page_content)?; for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8978e0e7d6..dd4908cbe4 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1570,6 +1570,79 @@ fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() { assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save"); } +/// The page draws an image under the mark and, outside it, a form without /Resources of its own +/// that draws the same image through the page's: the page keeps the name, whether the form lies +/// wholly outside the mark or is entered and left as it was. +#[test] +fn an_image_a_form_draws_again_through_the_pages_resources_is_kept() { + for bbox in ["[0 0 100 100]", "[0 0 300 300]"] { + 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(&format!("/Type /XObject /Subtype /Form /BBox {bbox}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 0), "{bbox}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/FmBare Do"), "{bbox}"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "{bbox}: the form still draws it"); + } +} + +/// The same one level down: a form with resources of its own draws the image under the mark and, +/// outside it, a form without any that draws it again through the first form's. +#[test] +fn an_image_a_nested_form_draws_again_through_its_callers_resources_is_kept() { + let mut doc = one_page( + b"/FmOwn Do", + "/XObject << /FmOwn 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /FmBare 8 0 R /Im1 9 0 R >> >>", + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/PCRedacted1 Do")); + let form = named_stream(&saved, 0, b"PCRedacted1").expect("the rewritten form"); + assert!(form.decoded().unwrap().windows(10).any(|w| w == b"/FmBare Do")); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.get(b"Im1").and_then(Object::as_ref).expect("the rewritten form still names /Im1"); + let Object::Stream(image) = &*saved.get(image) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, image).unwrap(), b"PUBIMGSHOWNAGAIN"); +} + +/// A form without /Resources that won't decode may draw the image under the mark through the +/// page's: the redaction is refused. A page with fonts or properties to clean up refused it +/// already; this one has neither. +#[test] +fn a_form_that_cannot_be_read_refuses_the_redaction_of_an_image_it_may_draw() { + 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 << /XObject << /FmBroken 5 0 R /Im1 6 0 R >> >> >>" + .to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBroken Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Filter /DCTDecode", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ]); + let Object::Stream(broken) = &*doc.get(ObjRef::new(5, 0)) else { panic!("the form is a stream") }; + assert!(broken.decoded().is_err(), "the fixture's form must not decode"); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_))))); +} + /// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited /// /Resources must leave the saved file too. #[test] From 8c06f7a3eb2a02bcaa3d8691cf6ca41861e0aaee Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 16:25:25 +0100 Subject: [PATCH 02/16] M8: a form whose /Resources is null draws through its caller's when redaction unlinks A form whose /Resources is null, or a reference to an object that isn't there, has no resources of its own. The interpreter, the walk the cleanup uses and the Type 3 cleanup all take it so. `content_draws` took it as having its own. It decides at the end of a redaction whether a removed image is still drawn through a page's or a surviving form's resources, so it dropped the name the page had just kept for such a form, and the form drew nothing there. The page's check refuses a page whose content it can't read in full. That includes bytes the parser can't read in a stream the redaction leaves as it was, for Remove Hidden Information as for Apply Redactions. Tests now pin that, and the error no longer calls the content "property resources". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp/prune.rs | 7 +- crates/redact/src/tests.rs | 136 +++++++++++++++++++++--------- 2 files changed, 103 insertions(+), 40 deletions(-) diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 8dc32d6db5..481c94bbd2 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -38,7 +38,10 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + // A /Resources that isn't a dictionary (null, or a missing object) is no resources of its + // own, as for `resource_ops` and the interpreter. + if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.get(b"Resources").is_some_and(|r| doc.resolve(r).as_dict().is_some()) || !seen.insert(r) + { continue; } if depth >= MAX_DEPTH { @@ -270,7 +273,7 @@ pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Op pub(crate) fn resource_ops(doc: &Document, 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 inspect property resources".into() }); + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully read the content that uses these resources".into() }); } let mut ops = parsed.ops; let xobjects = res_dict(doc, res, b"XObject"); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index dd4908cbe4..50ba05e0be 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1570,26 +1570,32 @@ fn an_image_a_form_drew_through_the_pages_resources_does_not_survive() { assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the image the form drew survived the save"); } +/// How a form can have no resources of its own: no /Resources, a null one, or a reference to an +/// object that isn't there (which is null too). +const NO_RESOURCES: [&str; 3] = ["", "/Resources null", "/Resources 99 0 R"]; + /// The page draws an image under the mark and, outside it, a form without /Resources of its own /// that draws the same image through the page's: the page keeps the name, whether the form lies /// wholly outside the mark or is entered and left as it was. #[test] fn an_image_a_form_draws_again_through_the_pages_resources_is_kept() { for bbox in ["[0 0 100 100]", "[0 0 300 300]"] { - 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(&format!("/Type /XObject /Subtype /Form /BBox {bbox}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), - stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), - ], - ); - mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); - let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.images_removed, r.forms_rewritten), (1, 0), "{bbox}: {r:?}"); - let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - assert!(content(&saved, 0).contains("/FmBare Do"), "{bbox}"); - assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "{bbox}: the form still draws it"); + for none in NO_RESOURCES { + 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(&format!("/Type /XObject /Subtype /Form /BBox {bbox} {none}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 0), "{bbox} {none}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/FmBare Do"), "{bbox} {none}"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "{bbox} {none}: the form still draws it"); + } } } @@ -1597,30 +1603,32 @@ fn an_image_a_form_draws_again_through_the_pages_resources_is_kept() { /// outside it, a form without any that draws it again through the first form's. #[test] fn an_image_a_nested_form_draws_again_through_its_callers_resources_is_kept() { - let mut doc = one_page( - b"/FmOwn Do", - "/XObject << /FmOwn 7 0 R >>", - vec![ - stream( - "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /FmBare 8 0 R /Im1 9 0 R >> >>", - b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", - ), - stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), - stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), - ], - ); - mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); - let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{r:?}"); - let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - assert!(content(&saved, 0).contains("/PCRedacted1 Do")); - let form = named_stream(&saved, 0, b"PCRedacted1").expect("the rewritten form"); - assert!(form.decoded().unwrap().windows(10).any(|w| w == b"/FmBare Do")); - let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); - let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); - let image = xo.get(b"Im1").and_then(Object::as_ref).expect("the rewritten form still names /Im1"); - let Object::Stream(image) = &*saved.get(image) else { panic!("/Im1 is a stream") }; - assert_eq!(decoded_image(&saved, image).unwrap(), b"PUBIMGSHOWNAGAIN"); + for none in NO_RESOURCES { + let mut doc = one_page( + b"/FmOwn Do", + "/XObject << /FmOwn 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /FmBare 8 0 R /Im1 9 0 R >> >>", + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + ), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] {none}"), b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{none}: {r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/PCRedacted1 Do"), "{none}"); + let form = named_stream(&saved, 0, b"PCRedacted1").expect("the rewritten form"); + assert!(form.decoded().unwrap().windows(10).any(|w| w == b"/FmBare Do"), "{none}"); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let image = xo.get(b"Im1").and_then(Object::as_ref).unwrap_or_else(|| panic!("{none}: the rewritten form still names /Im1")); + let Object::Stream(image) = &*saved.get(image) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, image).unwrap(), b"PUBIMGSHOWNAGAIN", "{none}"); + } } /// A form without /Resources that won't decode may draw the image under the mark through the @@ -1643,6 +1651,58 @@ fn a_form_that_cannot_be_read_refuses_the_redaction_of_an_image_it_may_draw() { assert!(matches!(apply(&mut doc, None), Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_))))); } +/// A page of two content streams, with no fonts or properties: the first draws an image at +/// (20, 230), 50 pt square, named `image` in the page's resources; the second is `tail`, which +/// draws nothing. `catalog` goes in the catalog, and `image_dict` in the image's dictionary. +fn image_and_a_tail(catalog: &str, image: &str, image_dict: &str, tail: &[u8]) -> Document { + pdf(vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes(), + 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 5 0 R] /Resources << /XObject << /{image} 6 0 R >> >> >>") + .into_bytes(), + stream("", format!("q 50 0 0 50 20 230 cm /{image} Do Q").as_bytes()), + stream("", tail), + stream( + &format!("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image_dict}"), + b"REDIMGONECOVERED", + ), + b"<< /Type /OCG /Name (Secret layer) >>".to_vec(), + ]) +} + +/// What the page draws can't be told when a content stream the redaction leaves as it was holds +/// bytes the parser can't read: the redaction of an image is refused, as it was already on a page +/// with fonts or properties to clean up. +#[test] +fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_the_redaction_of_an_image() { + for (tail, refused) in [(&b"q Q )"[..], true), (b"q Q", false)] { + let mut doc = image_and_a_tail("", "Im1", "", tail); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let result = apply(&mut doc, None); + if refused { + assert!(matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { .. }))), "{result:?}"); + } else { + assert_eq!(result.map(|r| r.images_removed).ok(), Some(1)); + } + } +} + +/// Remove Hidden Information makes the same check: removing an off layer's image is refused on a +/// page whose drawing can't be told. +#[test] +fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_removing_a_hidden_image() { + use crate::sanitize::{Hidden, remove_hidden}; + for (tail, refused) in [(&b"q Q )"[..], true), (b"q Q", false)] { + let mut doc = image_and_a_tail("/OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >>", "ImOC", "/OC 7 0 R", tail); + let result = remove_hidden(&mut doc, &[Hidden::HiddenLayers]); + if refused { + assert!(matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { .. }))), "{result:?}"); + } else { + assert!(result.unwrap().iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0)); + } + } +} + /// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited /// /Resources must leave the saved file too. #[test] From ea51451efc61d03ffd8b9678995059c4908d47f3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 5 Nov 2026 16:34:56 +0100 Subject: [PATCH 03/16] M8: tests pin which forms redaction follows and where its walk stops A form with resources of its own draws through those, so neither check of what is still drawn may follow it: the page's (prune) and the unlinking one at the end of a redaction (content_draws). Nothing pinned either. A test now has a form on another page naming a different image the same way, and the redacted image still leaves the dictionary both pages inherit, which pins the unlinking check. Another calls the page's check directly with such a form, since in a whole redaction the unlinking check would drop the name anyway. The walk of what content draws through its resources refuses content of more than 2,000,000 operators, counting those of the forms it follows. A test now pins that boundary for both counts. Remove Hidden Information keeps a name that a form without resources, left as it was, still draws, as Apply Redactions does. A test now pins that with hidden text, which a form loses or keeps depending on where it is drawn. The doc comment of prune now says when it refuses, with examples of what Stream::decoded refuses, and that corrupt data in such a form is read up to the damage, which may be nothing, not refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp/prune.rs | 6 +- crates/redact/src/tests.rs | 98 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 481c94bbd2..b73b34e231 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -7,8 +7,10 @@ use super::{ /// 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: such a form -/// won't decode, or the parser can't read all of it or of `content`. +/// 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`. +/// 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> { if replaced.is_empty() { return Ok(()); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 50ba05e0be..04624a5b5e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1703,6 +1703,104 @@ fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_removing_a_hidden_image() } } +/// Remove Hidden Information keeps a name that a form without resources still draws, too. Hidden +/// text includes text wholly off the page, so what it removes from a form depends on where it is +/// drawn: the page's /Fm, drawn off the page, is rewritten, while /G draws /Fm again on the page, +/// where its text shows, and stays as it was. The page still names /Fm, with its text. +#[test] +fn removing_hidden_text_keeps_a_form_a_form_without_resources_still_draws() { + use crate::sanitize::{Hidden, remove_hidden}; + for none in NO_RESOURCES { + 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 << /XObject << /Fm 5 0 R /G 6 0 R >> >> >>".to_vec(), + stream("", b"q 1 0 0 1 1000 0 cm /Fm Do Q /G Do"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 7 0 R >> >>", + b"BT /F1 10 Tf 20 20 Td (KEEPTEXT) Tj ET", + ), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {none}"), b"/Fm Do"), + FONT.replace("95 0 R", "8 0 R").into_bytes(), + widths(), + ]); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert!(removed.iter().any(|(h, n)| *h == Hidden::HiddenText && *n > 0), "{none:?}: nothing was removed"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(content(&saved, 0).contains("/G Do"), "{none:?}: /G was rewritten"); + let fm = named_stream(&saved, 0, b"Fm").and_then(|s| s.decoded().ok()); + assert!(fm.is_some_and(|d| d.windows(8).any(|w| w == b"KEEPTEXT")), "{none:?}: the page lost /Fm, which /G still draws"); + } +} + +/// The page's own check follows only forms without resources of their own: a form that names a +/// different image /Im1 in resources of its own doesn't keep the page's redacted /Im1. +#[test] +fn the_pages_check_does_not_follow_a_form_with_resources_of_its_own() { + let image = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + 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] /Resources << /XObject << /Im1 4 0 R >> >>", b"/Im1 Do"), + stream(image, b"o"), + 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(); + assert!(!xo.contains(b"Im1") && xo.contains(b"FmOwn"), "{xo:?}"); +} + +/// What content draws through its resources is read up to 2,000,000 operators, those of the forms +/// it draws without resources of their own included; past that it is refused. +#[test] +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"); + } + // The content's own `/Fm Do` is one of them. + for (ops, read) in [(1_999_999, true), (2_000_000, false)] { + 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(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"); + } +} + +/// 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. +#[test] +fn a_form_with_resources_of_its_own_does_not_keep_its_callers_image() { + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /Resources << /XObject << /Im1 7 0 R /FmOwn 8 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R >>".to_vec(), + stream("", b"/FmOwn Do"), + stream(image, b"REDIMGONECOVERED"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Im1 9 0 R >> >>", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream(image, b"PUBIMGSHOWNAGAIN"), + ]); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "the redacted image survived the save"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let form = named_stream(&saved, 1, b"FmOwn").expect("page 2 still draws its form"); + let res = saved.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = saved.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(own) = &*saved.get(xo.get(b"Im1").and_then(Object::as_ref).unwrap()) else { panic!("/Im1 is a stream") }; + assert_eq!(decoded_image(&saved, own).unwrap(), b"PUBIMGSHOWNAGAIN", "the form's own image stays"); +} + /// Remove Hidden Information has the same rewrite: an off layer's image named by an inherited /// /Resources must leave the saved file too. #[test] From f227d63ab1c453ee42a93e2b5bcbe726e65d4daa Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 15:49:18 +0100 Subject: [PATCH 04/16] M8: redaction reads the forms and glyphs it follows with /DecodeParms by reference To tell which resources content still draws, redaction reads the forms it follows, and the Type 3 glyph programs, with `Stream::decoded`, which reads `/DecodeParms` only when direct. Behind a predictor whose parameters are an indirect object, a form read as the predictor's tag bytes and row differences instead of its operators. So: - the check of what a page still draws (`resource_ops`), the unlinking check (`content_draws`) and the unlinking from surviving forms' resources (`unlink_from_forms`) didn't see such a form draw an image again, and the image went from resources it is still drawn through; - the Type 3 cleanup didn't see such a form, drawn or not, show a glyph, and dropped the glyph's program; - it refused the redaction over a glyph program behind such a predictor, which it couldn't parse. All six reads now go through `Document::with_direct_parms`. Each has a test, the form or glyph behind a predictor whose parameters are given by reference, that fails with that read reverted: - an_image_a_form_with_parameters_by_reference_draws_again_is_kept (`resource_ops` and `content_draws`); - an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept; - a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept; - a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept; - an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp/prune.rs | 12 ++- crates/redact/src/tests.rs | 70 ++++++++++++++++ .../redact/src/tests/surviving_references.rs | 84 +++++++++++++++++++ crates/redact/src/type3.rs | 6 +- 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index b73b34e231..3e9b49bd04 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -49,7 +49,8 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de if depth >= MAX_DEPTH { return None; } - let Ok(data) = s.decoded() else { return None }; + // Its `/DecodeParms` given by reference are read too. + let Ok(data) = doc.with_direct_parms(s).decoded() else { return None }; match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { Some(false) => {} other => return other, @@ -222,7 +223,7 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet Op Some(ops) } +/// 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> { let parsed = Pieces::join(content).parse(); if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { @@ -290,7 +296,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(&s.decoded()?); + let parsed = pdfcraft_content::parse(&doc.with_direct_parms(s).decoded()?); 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() }); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 04624a5b5e..5c6de2b7b8 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1771,6 +1771,76 @@ fn what_content_draws_through_its_resources_is_read_up_to_two_million_operators( } } +/// `content` behind a PNG predictor (Up, one column) whose `/DecodeParms` are given by reference, +/// in a stream with `entries` too: read without the parameters, its bytes are the predictor's tags +/// and differences, not the operators. +fn behind_parms_by_reference(doc: &mut Document, entries: &[(&str, Object)], content: &[u8]) -> Stream { + let mut rows = Vec::new(); + let mut above = 0u8; + for &b in content { + rows.extend([2, b.wrapping_sub(above)]); + above = b; + } + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(12))]))); + let mut s = Stream::flate(Dict::new(), &rows); + for (key, value) in entries { + s.dict.set(key.as_bytes().to_vec(), value.clone()); + } + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s +} + +/// A form's entries: `/Subtype /Form` and `bbox`. +fn form_entries(bbox: [i64; 4]) -> Vec<(&'static str, Object)> { + vec![("Subtype", Object::name("Form")), ("BBox", Object::Array(bbox.into_iter().map(Object::Int).collect()))] +} + +/// A form outside the mark draws the image again through the page's resources, its content behind +/// a predictor whose `/DecodeParms` are given by reference. Both checks of whether the page still +/// draws it read the form through them, the page's (`resource_ops`) and the unlinking one at the +/// end (`content_draws`): the page keeps the image for the form. +#[test] +fn an_image_a_form_with_parameters_by_reference_draws_again_is_kept() { + 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 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + let form = behind_parms_by_reference(&mut doc, &form_entries([0, 0, 100, 100]), b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.images_removed, 1, "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the form still draws it"); +} + +/// A surviving form outside the mark shares the page's resources and draws the image itself, its +/// content behind a predictor whose `/DecodeParms` are given by reference: unlinking the redacted +/// image from the forms' resources reads it through them, and keeps the image the form draws. +#[test] +fn an_image_a_surviving_form_with_parameters_by_reference_draws_is_kept() { + 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 >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + b"<< /XObject << /Im 6 0 R /Fm 7 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [150 150 300 300]", b""), + ]); + let mut entries = form_entries([150, 150, 300, 300]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 200 200 cm /Im Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(surviving_references::full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} + /// 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/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 477f03f9a5..db420ed952 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -298,3 +298,87 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { assert_eq!(report.glyphs, 1); assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); } + +/// 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 >> >>"; + +/// A form outside the mark shows glyph B of a Type 3 font, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps B. +#[test] +fn a_type3_glyph_a_form_with_parameters_by_reference_shows_is_kept() { + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(("Resources", Object::Ref(ObjRef::new(5, 0)))); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the form still shows B"); +} + +/// A form the page lists but doesn't draw shows glyph B, its content behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads every surviving form, this one +/// through them, and keeps B. +#[test] +fn a_type3_glyph_an_undrawn_form_with_parameters_by_reference_shows_is_kept() { + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50]", b""), + ]); + let mut entries = super::form_entries([0, 0, 300, 50]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(ObjRef::new(6, 0)))])))])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"BT /T3 10 Tf 10 20 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_TYPE3_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"PUBLIC_TYPE3_GLYPH_B"), "the undrawn form still shows B"); +} + +/// Glyph B's program draws an image from the font's resources, behind a predictor whose +/// `/DecodeParms` are given by reference: the font's cleanup reads it through them, and keeps the +/// image B still draws while the one only the redacted A drew goes. +#[test] +fn an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept() { + let image = "/Type /XObject /Subtype /Image /Width 12 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImA 9 0 R /ImB 10 0 R >> >> /CharProcs"); + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"/ImA Do"), + stream("", b""), + stream(image, b"SECRET_IMG_A"), + stream(image, b"PUBLIC_IMG_B"), + ]); + let glyph = super::behind_parms_by_reference(&mut doc, &[], b"/ImB Do"); + doc.set(ObjRef::new(8, 0), glyph); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"SECRET_IMG_A"), "the image only the redacted glyph drew survived"); + assert!(full_save_holds(&doc, b"PUBLIC_IMG_B"), "glyph B still draws its image"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 57971571af..9c82d540ac 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -327,7 +327,7 @@ fn scan_xobject( if !ctx.seen_forms.insert((r, current_font, through)) { return Ok(()); } - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; match form_own_res { Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1), None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1), @@ -482,7 +482,7 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> { for (form_ref, form_res) in &form_holders { if !ctx.seen_forms.iter().any(|(form, _, _)| form == form_ref) { let Object::Stream(s) = &*ctx.doc.get(*form_ref) else { continue }; - let data = s.decoded()?; + let data = ctx.doc.with_direct_parms(s).decoded()?; scan_content_stream(&mut ctx, std::slice::from_ref(&data), *form_ref, form_res, None, 0)?; } } @@ -616,7 +616,7 @@ fn clean_type3_font( } .into()); }; - let data = s.decoded()?; + let data = doc.with_direct_parms(s).decoded()?; collect_used_resources_in_stream(&data, &mut used_in_glyphs)?; } From 6eabed25ca114a2f06e767cdab6afb2680387a95 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 15:49:47 +0100 Subject: [PATCH 05/16] M8: the forms redaction follows through resources spend the page's allowance The walk of the forms that content draws through its resources (`resource_ops`) decoded each one outside the page's decode allowance: up to 1 GiB a form, read again by each cleanup that walks them, on every page. The interpreter skips a form outside the marks, so for such a form this walk is the only read. `resource_ops` now charges the bytes it decodes to the `DecodeBudget` it is given. The content walk hands back what it left of the page's allowance (`Scope::into_budget`), and the cleanups after it spend that: the check of what the page still draws (`prune`), the properties and the Type 3 fonts, in Apply Redactions and in Remove Hidden Information. A rewritten form's cleanups spend the walk's allowance as they go. The unlinking pass's check of the properties, which walks every page that shares a resource dictionary, gives each page's content an allowance of its own. Only bytes are charged, not form entries: each cleanup reads the same forms again, so a page drawing many small forms would run out of entries having decoded little. Tests, each failing with the charge reverted, and the last three with their own part of it reverted (the hand-back on either page, the rewritten form's cleanups): - what_content_draws_through_its_resources_is_read_within_its_allowance (a 2,000-byte form read with 2,000 bytes left, refused with 1,999); - the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance; - the_cleanups_of_a_rewritten_form_spend_the_pages_allowance; - removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance. In each of the last three a form without resources of its own is read three times: refused at 100 MiB, more than the page's 256 MiB, and not at 50 MiB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp.rs | 12 +++- crates/redact/src/interp/prune.rs | 39 +++++++------ crates/redact/src/lib.rs | 12 ++-- crates/redact/src/sanitize.rs | 9 +-- crates/redact/src/tests.rs | 96 ++++++++++++++++++++++++++++++- crates/redact/src/type3.rs | 9 ++- 6 files changed, 142 insertions(+), 35 deletions(-) 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 { From 9657f0451b1c58a1b810918f3fbc64c1a64a9c6e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 15:49:47 +0100 Subject: [PATCH 06/16] M8: a page installs its rewritten XObjects before checking what it still draws On a page, the check of which names the rewritten content still draws (`prune`) ran before the rewritten XObjects were added to the page's /XObject dictionary, so it couldn't follow a rewritten form under its new name. A rewritten form without resources of its own gets a copy of the page's, pruned to what it draws, and draws through that. It keeps none of its own only when the copy loses nothing, which takes a form that draws itself, here outside the mark. Such a form draws through the page's resources, and the page lost the names it draws there: the original form, and an image under the mark that it draws again outside it through an unchanged form. The page now installs them first, as a rewritten form does with the forms it rewrites. Remove Hidden Information's page does the same. There every form is entered, so a form that draws itself is followed to the depth limit and every copy above it keeps resources of its own (measured with such a form); its order changes so that the two read alike. Test an_image_a_rewritten_form_draws_again_through_the_pages_resources_is_kept fails with Apply Redactions' old order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/lib.rs | 8 +++++--- crates/redact/src/sanitize.rs | 8 +++++--- crates/redact/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 9318a56329..3e6ffacd71 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -818,6 +818,11 @@ fn redact_page_content( let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // The rewritten XObjects first, as for a rewritten form: one left without resources of + // its own draws through these, and `prune` follows it only under a name it can find. + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed image or form bytes cannot survive in the saved file. let page_content: Vec> = new_list @@ -828,9 +833,6 @@ fn redact_page_content( }) .collect(); 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)); } let (_, cleaned_data) = page_streams( diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 61862aa81f..8f9653b81e 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -417,12 +417,14 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { 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 budget, &mut xo, &out.replaced, &page_content)?; + // The rewritten XObjects first, as on Apply Redactions' pages: `prune` follows a + // rewritten form left without resources of its own only under a name it can find. for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } + // 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 budget, &mut xo, &out.replaced, &page_content)?; res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut budget, &mut res, &page_content)?); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 7cc726a09c..f893ae7981 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1849,6 +1849,40 @@ 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"); } +/// A form without resources of its own that the redaction rewrites gets a new name. It keeps none +/// of its own either when its copy of the page's loses nothing, which takes a form drawing itself +/// (here off the page, so outside the mark), and then goes on drawing through the page's: itself +/// and, through a form it draws unchanged, the image under the mark again outside it. The page's +/// check of what it still draws follows the rewritten form under its new name, so the page keeps +/// both names for it. +#[test] +fn an_image_a_rewritten_form_draws_again_through_the_pages_resources_is_kept() { + let mut doc = one_page( + b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do", + "/XObject << /FmBare 7 0 R /Inner 8 0 R /Im1 9 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"BT /F1 10 Tf 20 250 Td (SECRET) Tj ET /Inner Do q 1 0 0 1 0 -1000 cm /FmBare Do Q", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"q 50 0 0 50 20 20 cm /Im1 Do Q"), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBIMGSHOWNAGAIN"), + ], + ); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.forms_rewritten), (1, 1), "{r:?}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let page = content(&saved, 0); + let drawn: Vec> = + pdfcraft_content::parse(page.as_bytes()).ops.iter().filter(|op| op.is("Do")).filter_map(|op| op.last_name().map(<[u8]>::to_vec)).collect(); + assert!(drawn.len() == 1 && drawn[0] != b"FmBare", "the form is drawn under its new name: {page}"); + let rewritten = named_stream(&saved, 0, &drawn[0]).unwrap(); + assert!(!rewritten.dict.contains(b"Resources"), "the rewritten form keeps no resources of its own"); + assert!(named_stream(&saved, 0, b"FmBare").is_some(), "the rewritten form still draws the original"); + assert_eq!(named_image(&saved, 0, b"Im1").as_deref(), Some(&b"PUBIMGSHOWNAGAIN"[..]), "the unchanged form still draws it"); +} + /// 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] From e08efe540a8546a76b3e4d47d1711398749829ab Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 16:00:49 +0100 Subject: [PATCH 07/16] M8: a test pins a rewritten form's cleanups to the page's allowance on their own the_cleanups_of_a_rewritten_form_spend_the_pages_allowance passed with prune_form_resources doing nothing (cargo-mutants). The rewritten form then kept its caller's resources unchanged, had none of its own, and the page's check read the large form a third time through it. The rewritten form now has resources of its own, which the page's cleanups don't follow, so the three reads are the walk's and the rewritten form's two cleanups'. It fails with prune_form_resources doing nothing, and with each of the rewritten form's cleanups given an allowance of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index f893ae7981..fd11b849fa 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1922,19 +1922,22 @@ fn the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance } /// 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. +/// 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. #[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 >>", + "/XObject << /FmText 7 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] /Resources << /Font << /F1 5 0 R >> /XObject << /FmBare 8 0 R >> >>", + 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), ], ); From 2976caeb32ade27c728457e32fae793835076913 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 16:54:55 +0100 Subject: [PATCH 08/16] M8: the forms redaction follows through resources are read one operator at a time resource_ops parsed the content and each form it follows whole before checking its 2,000,000-operator limit, and content_draws parsed whole streams with no limit at all. A parsed operator takes about 120 bytes, and a form of `n` operators outside the mark cost 78 to 138 bytes of memory per byte of content. Measured as the growth of the process's peak: 629 MiB at 8 MiB, 1,253 MiB at 16 MiB and 4,415 MiB at 32 MiB, so tens of GiB at the 256 MiB the page's allowance admits, from a file of a few hundred KiB. The page's check of what it still draws (prune), added in this branch, reads such forms on pages with no fonts or properties too. On main the font and property cleanups read them, and the unlinking check (content_draws) on any page that shares a dictionary with a removed image, so the content_draws half fixes what main does too. Both now read with pdfcraft_content::ops, one operator at a time. resource_ops holds at most one past its limit, and content_draws holds no operators, as it only looks for Do. Measured as the peak resident memory of the whole process (dev profile): the page's check over a form of 8, 16 and 64 MiB of `n` takes 311, 327 and 422 MiB, and the unlinking check over one of 16 MiB takes 69 MiB, against 1,269 before (2,527 MiB at 32 MiB). This bounds the operators held, not their operands: the parser still holds the operands that no operator ends, so content of operands alone costs about 30 bytes per byte (974 MiB for 32 MiB of `0`). Bounding those is pdfcraft-content's, for every reader of content. It also drops resource_ops' check that it followed no more than 2,000,000 forms. Each form it follows is a Do among the operators it holds, which the operator limit already bounds, so that check could never fail; cargo-mutants found it untested. Test the_forms_followed_through_resources_are_read_one_operator_at_a_time (tests/operator_memory.rs, a binary of its own) measures how much the process's peak grows over 32 MiB of `n` operators through each check, on Linux, and fails with either read reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp/prune.rs | 25 ++++--- crates/redact/tests/operator_memory.rs | 99 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 crates/redact/tests/operator_memory.rs diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 97701a26c6..8d48a0f54c 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -32,8 +32,9 @@ pub(crate) fn prune(doc: &Document, budget: &mut DecodeBudget, xo: &mut Dict, re /// `None` when that cannot be told (a form that will not decode, or nesting past the limit). fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { let joined = Pieces::join(content); - let ops = joined.parse().ops; - for op in ops.iter().filter(|op| op.is("Do")) { + // One operator at a time, since only the `Do`s matter: a stream of millions of operators is + // never held whole. + for op in pdfcraft_content::ops(joined.data()).filter(|op| op.is("Do")) { let Some(n) = op.last_name() else { continue }; if n == name { return Some(true); @@ -280,13 +281,17 @@ pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Op /// 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`. +/// 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. 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 { + const MAX_OPS: usize = 2_000_000; + let joined = Pieces::join(content); + let mut read = pdfcraft_content::ops(joined.data()); + let mut ops: Vec = read.by_ref().take(MAX_OPS + 1).collect(); + // Under the limit the reader ran to the end, so `skipped` counts all of it. + if read.skipped() > 0 || ops.len() > MAX_OPS { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully read the content that uses these resources".into() }); } - let mut ops = parsed.ops; let xobjects = res_dict(doc, res, b"XObject"); let mut seen = HashSet::new(); let mut cursor = 0; @@ -299,11 +304,13 @@ pub(crate) fn resource_ops(doc: &Document, budget: &mut DecodeBudget, res: &Dict && 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(&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 { + let data = budget.decode(&doc.with_direct_parms(s))?; + let mut read = pdfcraft_content::ops(&data); + ops.extend(read.by_ref().take((MAX_OPS + 1).saturating_sub(ops.len()))); + // `seen` gains at most one form for each `Do` in `ops`, so this bounds it too. + if ops.len() > MAX_OPS || read.skipped() > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); } - ops.extend(parsed.ops); } cursor += 1; } diff --git a/crates/redact/tests/operator_memory.rs b/crates/redact/tests/operator_memory.rs new file mode 100644 index 0000000000..925a091a0d --- /dev/null +++ b/crates/redact/tests/operator_memory.rs @@ -0,0 +1,99 @@ +//! Memory regressions for reading the forms a redaction follows through resources: their +//! operators are read one at a time, so a form of millions of them is never held whole. A test +//! binary of its own, so that the one test is all the process runs while its peak is measured. + +use std::sync::Arc; + +use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_redact::apply; +use pdfcraft_testkit::{Pdf, stream_obj}; + +const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + +/// 32 MiB of `n` operators: 16 million of them, eight times the walk's limit. Held as parsed +/// operators, at about 120 bytes each, nearly 2 GiB. +fn operators() -> Vec { + b"n\n".repeat(16 << 20) +} + +/// `form` with `content`, compressed. +fn compress(doc: &mut Document, form: ObjRef, content: &[u8]) { + let Object::Stream(s) = &*doc.get(form) else { panic!("{form:?} is a stream") }; + let dict = s.dict.clone(); + doc.set(form, Object::Stream(Stream::flate(dict, content))); +} + +fn mark(doc: &mut Document) { + let shape = Shape::Redact { quads: vec![rect_quad([0.0, 200.0, 300.0, 300.0])], overlay: String::new(), look: Default::default() }; + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "Synthetic test".into() }, &Meta::default()) + .unwrap(); +} + +/// How much the process's peak resident memory grows, in MiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before) >> 10)) +} + +/// Two reads of a form of 16 million operators: +/// - 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 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`. +/// +/// Each parsed every operator first: the peak grew by 4,415 and 2,527 MiB. Now the first holds at +/// most one operator past the walk's limit, and the second none. +#[test] +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. + 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 << /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"), + stream_obj(IMAGE, b"o"), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(6, 0), &operators()); + mark(&mut doc); + let Some((result, grew)) = peak_growth(|| apply(&mut doc, None)) else { return }; + assert!(result.is_err(), "the walk refuses past 2,000,000 operators: {result:?}"); + assert!(grew < 1024, "the page's check grew the peak by {grew} MiB"); + + // The unlinking check: two pages share the /Pages node's resources; the second draws the form. + let mut doc = Document::open(Arc::new( + Pdf::new() + .objs(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [6 0 R 7 0 R] /Count 2 /Resources << /XObject << /Im1 5 0 R /Fm 3 0 R >> >> >>".to_vec(), + stream_obj("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream_obj("", b"q 50 0 0 50 20 230 cm /Im1 Do Q"), + stream_obj(IMAGE, b"o"), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 8 0 R >>".to_vec(), + stream_obj("", b"/Fm Do"), + ]) + .build(), + )) + .unwrap(); + compress(&mut doc, ObjRef::new(3, 0), &operators()); + mark(&mut doc); + 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!(grew < 1024, "the unlinking check grew the peak by {grew} MiB"); +} From 8e593d33a6a2838109fc789a9677a07d828fd3b3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 17:25:17 +0100 Subject: [PATCH 09/16] M8: the walk reads the forms it enters with /DecodeParms by reference The walk read a form it enters, one whose box crosses a mark, with DecodeBudget::decode_strict, which reads /DecodeParms only when direct. Behind a predictor whose parameters are an indirect object, the form read as the predictor's tag bytes and row differences. The walk found nothing under the mark in it and left it as it was, still drawing what lay under the mark: on main, text there stayed, and so did an image only the form drew. Once the page's check read such a form with its parameters (f227d63ab), an image leaked too when the page drew it under the mark as well: the check saw the form draw it and kept the page's name for it, where before it took the name away. The walk now reads its forms through Document::with_direct_parms, as the cleanups after it do. The page's own streams, and the redaction proof's reads (verify/util.rs), still take /DecodeParms only when direct, as on main; #6289 changes the page's. Test what_a_form_with_parameters_by_reference_draws_under_the_mark_is_removed, with text, and with an image drawn under the mark by the page and the form and by the form alone, fails with the read reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/interp.rs | 6 ++++-- crates/redact/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index edb1a68708..04d6fe72c0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -983,9 +983,11 @@ fn xobject( out.doomed.push(r); return Some(None); } - // Strict, like page content: a Form read only in part counts as undecodable. + // Strict, like page content: a Form read only in part counts as undecodable. With its + // `/DecodeParms` given by reference too, as the cleanups after the walk read it: read + // without them, it looks empty and is left as it was, drawing whatever lies under a mark. let data = if scope.budget.enter_form() { - scope.budget.decode_strict(s) + scope.budget.decode_strict(&doc.with_direct_parms(s)) } else { Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) }; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index fd11b849fa..7469ff0433 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1849,6 +1849,45 @@ 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"); } +/// A form without resources of its own crosses the mark and draws an image, or text, under it, +/// its content behind a predictor whose `/DecodeParms` are given by reference. The walk reads it +/// through them and removes what lies under the mark: the image, whether the page draws it under +/// the mark too or only the form does, and the text's glyphs. Read without them, the form looked +/// empty and was left as it was, still drawing them, and the page's check, which reads it with +/// them, kept the image's name for it. +#[test] +fn what_a_form_with_parameters_by_reference_draws_under_the_mark_is_removed() { + let mut doc = one_page(b"/FmBare Do", "/XObject << /FmBare 7 0 R >>", vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b"")]); + let mut entries = form_entries([0, 0, 100, 100]); + // Its drawing moved up by 210: the text lands at y 250, under the mark. + entries.push(("Matrix", Object::Array([1, 0, 0, 1, 0, 210].into_iter().map(Object::Int).collect()))); + let form = behind_parms_by_reference(&mut doc, &entries, b"BT /F1 10 Tf 20 40 Td (SECRETVALUE) Tj ET"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (11, 1), "the text the form draws under the mark: {r:?}"); + for page in [&b"q 50 0 0 50 20 230 cm /Im1 Do Q /FmBare Do"[..], b"/FmBare Do"] { + let mut doc = one_page( + page, + "/XObject << /FmBare 7 0 R /Im1 8 0 R >>", + vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100]", b""), + stream("/Type /XObject /Subtype /Image /Width 16 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"REDIMGONECOVERED"), + ], + ); + let mut entries = form_entries([0, 0, 100, 100]); + // Its drawing moved up by 210: the image lands at y 230 to 280, under the mark. + entries.push(("Matrix", Object::Array([1, 0, 0, 1, 0, 210].into_iter().map(Object::Int).collect()))); + let form = behind_parms_by_reference(&mut doc, &entries, b"q 50 0 0 50 20 20 cm /Im1 Do Q"); + doc.set(ObjRef::new(7, 0), form); + mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.forms_rewritten, 1, "{}: {r:?}", String::from_utf8_lossy(page)); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(16).any(|w| w == b"REDIMGONECOVERED"), "{}: the image the form drew under the mark", String::from_utf8_lossy(page)); + } +} + /// A form without resources of its own that the redaction rewrites gets a new name. It keeps none /// of its own either when its copy of the page's loses nothing, which takes a form drawing itself /// (here off the page, so outside the mark), and then goes on drawing through the page's: itself From 8bf63c8792fbbf8c0121eedd41a6b669d8cec72d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 17:47:19 +0100 Subject: [PATCH 10/16] 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 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/type3.rs | 24 +++++++------ crates/redact/tests/operator_memory.rs | 50 +++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 80d913e6ad..1280cf774e 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -131,11 +131,10 @@ struct UsedInGlyphs { } fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Result<(), CosError> { - let parsed = pdfcraft_content::parse(data); - if parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); - } - for op in parsed.ops { + // 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. + let mut read = pdfcraft_content::ops(data); + for op in read.by_ref() { match op.op.as_slice() { b"Do" => { 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(()) } @@ -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() }); } let joined = Pieces::join(content); - let parsed = joined.parse(); - if parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect content stream syntax".into() }); - } + // One operator at a time, so content of millions of them is never held whole. The scan only + // reads, and a byte that can't be read anywhere still refuses, once the loop has read it all. + let mut read = pdfcraft_content::ops(joined.data()); let mut stack: Vec> = Vec::new(); let mut current_font = initial_font; - for op in parsed.ops { + for op in read.by_ref() { match op.op.as_slice() { b"q" => stack.push(current_font), b"Q" => { @@ -257,6 +258,9 @@ fn scan_content_stream( _ => 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(()) } diff --git a/crates/redact/tests/operator_memory.rs b/crates/redact/tests/operator_memory.rs index 925a091a0d..14e6a5d9f3 100644 --- a/crates/redact/tests/operator_memory.rs +++ b/crates/redact/tests/operator_memory.rs @@ -44,14 +44,44 @@ fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { 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 mark, and refuses at 2,000,001 operators; /// - 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 -/// most one operator past the walk's limit, and the second none. +/// Each parsed every operator first: the peak grew by 4,415 and 2,527 MiB in the first two. Now +/// the first holds at most one operator past the walk's limit, and the others none. #[test] 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. @@ -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 }; 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"); + + 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"); } From 20e138fbf52c983deec40abe88db2deb9a60ad30 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 17:52:58 +0100 Subject: [PATCH 11/16] M8: tests pin the Type 3 cleanup's refusal of bytes it can't read The Type 3 cleanup refuses a redaction when its scan of a page or form, or its read of a kept glyph's program, meets bytes the parser can't read. Nothing tested either refusal: cargo-mutants found both checks could be dropped with every test passing, once 8bf63c879 moved them after the loop. unreadable_bytes_in_a_form_the_type3_cleanup_scans_refuse_the_redaction leaves a string open in a form only that scan reads (resources of its own, outside the mark), hiding the glyph the form shows after it, and unreadable_bytes_in_a_kept_type3_glyph_program_refuse_the_redaction leaves one open in the program of the glyph the page keeps, hiding the image it draws. Each fails with its check disabled; with the checks off, the glyph loses its program and the font its image. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- .../redact/src/tests/surviving_references.rs | 49 +++++++++++++++++++ crates/redact/tests/operator_memory.rs | 9 ++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index db420ed952..417aa2ff60 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -382,3 +382,52 @@ fn an_image_a_type3_glyph_with_parameters_by_reference_draws_is_kept() { assert!(!full_save_holds(&doc, b"SECRET_IMG_A"), "the image only the redacted glyph drew survived"); assert!(full_save_holds(&doc, b"PUBLIC_IMG_B"), "glyph B still draws its image"); } + +/// Whether `out` is the redaction refused with a syntax error whose detail holds `detail`. +fn refused_for(out: &Result, detail: &str) -> bool { + matches!(out, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail: d, .. })) if d.contains(detail)) +} + +/// The font's cleanup scans every page and form for the glyphs they show. A string left open in a +/// form nothing else reads (resources of its own, outside the mark) runs to the end of the +/// stream, hiding the glyph the form shows after it, so the redaction is refused: with the check +/// off, that glyph loses its program. +#[test] +fn unreadable_bytes_in_a_form_the_type3_cleanup_scans_refuse_the_redaction() { + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj ET /Fm Do"), + b"<< /Font << /T3 6 0 R >> /XObject << /Fm 9 0 R >> >>".to_vec(), + TYPE3.as_bytes().to_vec(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"% PUBLIC_TYPE3_GLYPH_B"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 50] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 10 20 Td ( (B) Tj ET"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let out = apply(&mut doc, None); + assert!(refused_for(&out, "content stream syntax"), "{out:?}"); +} + +/// The font's cleanup reads the program of each glyph it keeps, for the resources it uses. A +/// string left open there runs to the end of the program, hiding the image it draws after it, so +/// the redaction is refused: with the check off, the font's resources lose that image. +#[test] +fn unreadable_bytes_in_a_kept_type3_glyph_program_refuse_the_redaction() { + let font = TYPE3.replace("/CharProcs", "/Resources << /XObject << /ImB 9 0 R >> >> /CharProcs"); + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (A) Tj 0 100 Td (B) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + font.into_bytes(), + stream("", b"% SECRET_TYPE3_GLYPH_A"), + stream("", b"500 0 d0 ( /ImB Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"o"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 16.0, 110.0]], ""); + let out = apply(&mut doc, None); + assert!(refused_for(&out, "glyph stream syntax"), "{out:?}"); +} diff --git a/crates/redact/tests/operator_memory.rs b/crates/redact/tests/operator_memory.rs index 14e6a5d9f3..f360f83cdd 100644 --- a/crates/redact/tests/operator_memory.rs +++ b/crates/redact/tests/operator_memory.rs @@ -1,6 +1,7 @@ -//! Memory regressions for reading the forms a redaction follows through resources: their -//! operators are read one at a time, so a form of millions of them is never held whole. A test -//! binary of its own, so that the one test is all the process runs while its peak is measured. +//! Memory regressions for the streams a redaction reads besides its walk: the forms it follows +//! through resources, and what the Type 3 cleanup scans and reads. Their operators are read one +//! at a time, so a stream of millions of them is never held whole. A test binary of its own, so +//! that the one test is all the process runs while its peak is measured. use std::sync::Arc; @@ -80,7 +81,7 @@ fn type3_page(form: &[u8], glyph: &[u8]) -> Document { /// 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 in the first two. Now +/// Each parsed every operator first: the peak grew by 4,415, 2,526, 2,526 and 2,494 MiB. Now /// the first holds at most one operator past the walk's limit, and the others none. #[test] fn the_forms_followed_through_resources_are_read_one_operator_at_a_time() { From 75f082a6c51f132c894d0b3284f036a747c2bd8c Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 03:21:58 +0100 Subject: [PATCH 12/16] M8: the unreadable-bytes tests name the check that refuses The two tests of a content stream the redaction leaves as it was, holding bytes the parser can't read, accepted any syntax error. They now take only the refusal of the check of what content still draws through its resources, so a refusal from another check, or with other wording, fails them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 7469ff0433..3b1f6e61c7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1670,6 +1670,12 @@ fn image_and_a_tail(catalog: &str, image: &str, image_dict: &str, tail: &[u8]) - ]) } +/// The refusal of the check of what content still draws through its resources (`resource_ops`). +fn unread_content_refusal(result: &Result) -> bool { + matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) + if detail == "redaction could not fully read the content that uses these resources") +} + /// What the page draws can't be told when a content stream the redaction leaves as it was holds /// bytes the parser can't read: the redaction of an image is refused, as it was already on a page /// with fonts or properties to clean up. @@ -1680,7 +1686,7 @@ fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_the_redaction_of_an_image( mark(&mut doc, 0, &[[0.0, 200.0, 300.0, 300.0]], ""); let result = apply(&mut doc, None); if refused { - assert!(matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { .. }))), "{result:?}"); + assert!(unread_content_refusal(&result), "{result:?}"); } else { assert_eq!(result.map(|r| r.images_removed).ok(), Some(1)); } @@ -1696,7 +1702,7 @@ fn unreadable_bytes_in_a_stream_left_as_it_was_refuse_removing_a_hidden_image() let mut doc = image_and_a_tail("/OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >>", "ImOC", "/OC 7 0 R", tail); let result = remove_hidden(&mut doc, &[Hidden::HiddenLayers]); if refused { - assert!(matches!(result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { .. }))), "{result:?}"); + assert!(unread_content_refusal(&result), "{result:?}"); } else { assert!(result.unwrap().iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0)); } From 4c4929028999ef1cbde2dcfd4ddbf2b614cee18f Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 8 Nov 2026 14:40:19 +0100 Subject: [PATCH 13/16] Refuse ruled-line extraction after incomplete page content --- crates/edit/src/rules.rs | 4 ++++ crates/edit/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/edit/src/rules.rs b/crates/edit/src/rules.rs index a5bcfebbf1..ef3f78f772 100644 --- a/crates/edit/src/rules.rs +++ b/crates/edit/src/rules.rs @@ -26,6 +26,10 @@ pub fn page_rules(doc: &Document, page: usize) -> Result, EditErro let pages = pdfcraft_model::pages(doc); let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; let streams = crate::text::content_streams(doc, &p.dict)?; + // Unread content can change the graphics state used by every later stream. + if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) { + return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into())); + } let pieces = Pieces::join(&streams.iter().map(|stream| stream.data.as_slice()).collect::>()); let ops = pieces.parse().ops; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7c8f65dbab..8bc7e9e437 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -11202,3 +11202,21 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } } + +#[test] +fn page_rules_refuse_unread_graphics_state() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("UnsupportedFilter")); + let unread = Stream::from_raw(dict, b"2 0 0 2 0 0 cm".to_vec()); + let damaged = damaged_flate(b"0 g\n", b"2 0 0 2 0 0 cm"); + for stream in [unread, damaged] { + let mut doc = image_page_with("10 10 m 100 10 l S"); + let incomplete = doc.add(Object::Stream(stream)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(incomplete), Object::Ref(ObjRef::new(4, 0))])); + }) + .unwrap(); + assert!(page_rules(&doc, 0).is_err(), "later geometry cannot be trusted after unread graphics state"); + } + assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]); +} From 718a725812ad4930ad8afe1cb95effe7b9021c46 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 9 Nov 2026 12:25:04 +0100 Subject: [PATCH 14/16] M8: the Type 3 cleanup reads forms, appearances and soft masks with /DecodeParms by reference Three reads of the Type 3 cleanup came in from main in the merge, and took `/DecodeParms` only when direct: the forms a surviving glyph draws (`prune_font_forms`), a page's appearances without resources of their own, and the soft mask groups without them (`add_soft_mask_ops`). Behind a predictor whose parameters are an indirect object, each read the predictor's tag bytes and row differences, which lex as operators that use no resource, so nothing refused. The form's resources then lost the image it draws, and the page lost the Type 3 font only the appearance or the soft mask selects, with its glyphs. Each is now read with them too, as this branch's other reads in the cleanup are; the appearances still against the page's allowance. Tests, each the by-reference form of one already on main: - `an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept` - `a_type3_font_only_an_appearance_with_parameters_by_reference_selects_stays_on_the_page` - `a_type3_font_only_a_soft_mask_with_parameters_by_reference_selects_stays_on_the_page` Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- .../redact/src/tests/surviving_references.rs | 100 ++++++++++++++++++ crates/redact/src/type3.rs | 6 +- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 1f58eb901f..28097725c0 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -185,6 +185,41 @@ fn a_redacted_type3_glyphs_image_does_not_survive_in_a_surviving_glyphs_form() { assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); } +/// The same Form, its content behind a predictor whose `/DecodeParms` are given by reference: the +/// cleanup of its resources reads it through them, and keeps the image it draws. Read without +/// them, this content's bytes are all operators that use no resource, so nothing refuses and +/// the image would go. +#[test] +fn an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept() { + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /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 >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000]", b""), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + let mut entries = super::form_entries([0, 0, 1000, 1000]); + entries.push(( + "Resources", + Object::Dict(Dict::from_iter([( + b"XObject".to_vec(), + Object::Dict(Dict::from_iter([(b"ImA".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(11, 0)))])), + )])), + )); + let form = super::behind_parms_by_reference(&mut doc, &entries, b"q 1000 0 0 1000 0 0 cm /ImB Do Q"); + doc.set(ObjRef::new(10, 0), form); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "the removed glyph's image survives through the surviving glyph's form"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { @@ -563,6 +598,36 @@ fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); } +/// The same appearance, its content behind a predictor whose `/DecodeParms` are given by +/// reference: the page's cleanup reads it through them, and keeps the font it selects. +#[test] +fn a_type3_font_only_an_appearance_with_parameters_by_reference_selects_stays_on_the_page() { + 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 [8 0 R] >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> >>".to_vec(), + type3_abc(10), + type3_abc(13), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T4 10 Tf 0 g) /AP << /N 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]", b""), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% APPEARANCE_GLYPH_B"), + stream("", b"% UNUSED_T4_GLYPH_C"), + ]); + let appearance = super::behind_parms_by_reference(&mut doc, &super::form_entries([0, 0, 100, 20]), b"BT /T4 10 Tf 2 5 Td (B) Tj ET"); + doc.set(ObjRef::new(9, 0), appearance); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the appearance shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); +} + /// The operators of a page's appearances count toward one total, as the content's do. Two /// appearances under the cap alone pass it together, and the redaction is refused rather than /// judged on part of what draws through the page's fonts. @@ -686,6 +751,41 @@ fn a_type3_font_only_a_soft_mask_without_resources_selects_stays_on_the_page() { assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); } +/// The same group, its content behind a predictor whose `/DecodeParms` are given by reference: +/// the page's cleanup reads it through them, and keeps the font it selects. +#[test] +fn a_type3_font_only_a_soft_mask_with_parameters_by_reference_selects_stays_on_the_page() { + 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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET /GS1 gs 150 150 100 100 re f"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> /ExtGState << /GS1 << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 8 0 R >> >> >> >>" + .to_vec(), + type3_abc(9), + type3_abc(12), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b""), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% MASK_GLYPH_B"), + stream("", b"% UNUSED_T4_GLYPH_C"), + ]); + let mut entries = super::form_entries([0, 0, 300, 300]); + entries.push(( + "Group", + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name("Transparency")), (b"CS".to_vec(), Object::name("DeviceGray"))])), + )); + let group = super::behind_parms_by_reference(&mut doc, &entries, b"1 g BT /T4 40 Tf 160 180 Td (B) Tj ET"); + doc.set(ObjRef::new(8, 0), group); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived"); + assert!(full_save_holds(&doc, b"MASK_GLYPH_B"), "the glyph the soft mask shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); +} + /// The cell of a tiling pattern another page fills with shows glyph B: its program is kept. (On /// the redacted page itself, a pattern that shows text refuses the redaction: `pattern_guard`.) #[test] diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 29acb07ce9..4c3f2211e8 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -220,7 +220,7 @@ fn prune_font_forms(doc: &mut Document, font_res: &Dict) -> Result<(), CosError> } }, }; - let data = form.decoded()?; + let data = doc.with_direct_parms(form).decoded()?; let mut used = UsedInGlyphs::default(); // Each Form's walk has the allowance of a page, as `prune_retired_properties` gives each // content it reads. @@ -498,7 +498,7 @@ fn add_soft_mask_ops(doc: &Document, res: &Dict, ops: &mut Vec 0 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect soft mask content".into() }); } @@ -569,7 +569,7 @@ pub(crate) fn prune_type3_from_resources( // only in its turn, so they are never all held at once. for r in resourceless_appearances(doc, page) { let Object::Stream(s) = &*doc.get(r) else { continue }; - let data = budget.decode(s)?; + let data = budget.decode(&doc.with_direct_parms(s))?; let more = crate::interp::resource_ops(doc, budget, 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() }); From 4cc0abbe62245634bf98d1fec08bbf77e6d7c7ef Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 9 Nov 2026 13:03:50 +0100 Subject: [PATCH 15/16] M8: a test that each form a kept Type 3 glyph draws is walked within an allowance of a page The merge gave `prune_font_forms`' walk of each Form a kept glyph draws the allowance of a page, which its call of `resource_ops` now needs; no test pinned it. In `the_form_a_kept_type3_glyph_draws_is_walked_within_the_allowance_of_a_page` such a Form draws two forms without resources of their own, each past half of the allowance, and the redaction is refused with the decoder's limit; with two small ones it goes on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- .../redact/src/tests/surviving_references.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 28097725c0..0d171b4008 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -220,6 +220,38 @@ fn an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept() { assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); } +/// The Form a kept glyph draws is walked, for the names its content uses, within the allowance of +/// a page: two forms without resources of their own that it draws, each past half of it, refuse +/// the redaction rather than being decoded in full. Two small ones are the control. +#[test] +fn the_form_a_kept_type3_glyph_draws_is_walked_within_the_allowance_of_a_page() { + for (size, refused) in [(pdfcraft_cos::MAX_PAGE_DECODED / 2 + 1, true), (1, false)] { + let packed = pdfcraft_filters::encode_flate(&vec![b' '; size]); + let blank = || stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /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 >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /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 >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources << /XObject << /Sub1 11 0 R /Sub2 12 0 R >> >>", b"/Sub1 Do /Sub2 Do"), + blank(), + blank(), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let out = apply(&mut doc, None); + if refused { + assert!(matches!(out, Err(RedactError::Cos(pdfcraft_cos::CosError::Filter(_)))), "forms of {size} bytes each: {out:?}"); + } else { + assert_eq!(out.unwrap().glyphs, 1, "forms of {size} bytes each"); + } + } +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { From ce2db2a319e06bb448acbd347518aba7208bdeea Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 9 Nov 2026 16:55:36 +0100 Subject: [PATCH 16/16] M8: the redaction README says a name a form without resources draws stays The Consumers review of 64dfe1fd4. The README said a surviving form loses the resource entries for removed XObjects its own content does not draw. It also keeps those a form without resources of its own, drawn through them, still draws, and since this PR a redacted page keeps them the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 51ac11f086..3537bfc547 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -148,8 +148,10 @@ used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own -content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects that +neither their own content nor a form without resources of its own drawn through them still draws, +for the same reason, and a redacted page keeps a name under that rule too. Pages that aren't +redacted are left untouched. ## Remove hidden information / Sanitize