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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-06 15:50:40 +01:00
1 parent 8c06f7a3eb
commit ea51451efc
2 files changed
+102 -2

No files matched your search

+4 -2
View File
@@ -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<u8>], content: &[Vec<u8>]) -> Result<(), CosError> {
if replaced.is_empty() {
return Ok(());
+98
View File
@@ -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]