From 4f107a6f4065e0d2fabbf4ea8318ec60f2f20dee Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 10 Nov 2026 16:45:34 +0100 Subject: [PATCH] M8: a form /Matrix through a reference to a reference is refused Review of the walk's read of a form's `/Matrix`. Through `Document::resolve` it followed a reference to a reference, for the matrix and for each entry, as the app's renderer does; Poppler doesn't, and reads such a matrix as the identity and such an entry as 0. With `/Matrix [1 0 0 1 0 8 0 R]`, `8 0 obj 9 0 R` and `9 0 obj 200`, a mark where Poppler shows the text applied with success and left it in the file, where `main`, reading any entry by reference as the identity, removed it. `matrix_of` now follows each reference one hop, and a reference to a reference fails as a malformed matrix: where the content draws can't be told, so Apply refuses the page, as for a matrix that isn't six numbers. The refusal now says "isn't six numbers given directly or through one reference", and `matrix_of`'s doc names the appearances it reads, not patterns. The proof, which reads the matrix of forms and appearances with it, treats such a matrix as malformed too. Test: `a_form_whose_matrix_goes_through_a_reference_to_a_reference_is_refused` (an entry and the whole matrix, with a mark where either reader draws the text). 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/form_matrix.rs | 24 ++++++++++++++++++++++++ crates/redact/src/verify/extract.rs | 26 ++++++++++++++++++-------- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index f08ab2da8d..9cfc981381 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1075,7 +1075,7 @@ fn xobject( // form moved onto a mark would be tested against the wrong place and never walked. let m = match crate::verify::matrix_of(doc, &s.dict) { Ok(m) => m, - // Not six numbers. Removing hidden layers alone never asks where content lands, so + // Not six numbers given directly or through one reference. Removing hidden layers alone never asks where content lands, so // the form is walked as it stands. Err(()) if scope.everywhere() && scope.hidden_text.is_none() => Matrix::IDENTITY, // Otherwise where the form draws, and so whether it reaches a mark or leaves the @@ -1085,7 +1085,9 @@ fn xobject( if scope.mode == Mode::Apply { out.error = Some(CosError::Syntax { offset: 0, - detail: "a form XObject's /Matrix is not six numbers, so where it draws can't be told".into(), + detail: + "a form XObject's /Matrix isn't six numbers given directly or through one reference, so where it draws can't be told" + .into(), }); } else if scope.everywhere() { scope.layer_blocks += 1; diff --git a/crates/redact/src/tests/form_matrix.rs b/crates/redact/src/tests/form_matrix.rs index 300c2933a1..e4bb33608e 100644 --- a/crates/redact/src/tests/form_matrix.rs +++ b/crates/redact/src/tests/form_matrix.rs @@ -72,6 +72,30 @@ fn a_form_whose_matrix_is_not_six_numbers_is_refused() { } } +/// A reference to a reference, for the matrix or one of its entries: Poppler doesn't follow it (it +/// reads such a matrix as the identity, such an entry as 0), the app's renderer follows it and +/// reads the move, so where the form draws can't be told, and the page is refused, as for a matrix that isn't six numbers. Read as either, +/// a mark where the other reader shows the text would leave it in the file. +#[test] +fn a_form_whose_matrix_goes_through_a_reference_to_a_reference_is_refused() { + for (matrix, target) in [("/Matrix [1 0 0 1 0 8 0 R]", &b"200"[..]), ("/Matrix 8 0 R", b"[1 0 0 1 0 200]")] { + for mark_at in [MARK, [0.0, 0.0, 300.0, 100.0]] { + let form = stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 100] {matrix} /Resources << /Font << /F1 5 0 R >> >>"), + b"BT /F1 10 Tf 20 60 Td (SECRET) Tj ET", + ); + let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form, b"9 0 R".to_vec(), target.to_vec()]); + mark(&mut doc, 0, &[mark_at], ""); + let result = apply(&mut doc, None); + assert!( + matches!(&result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) if detail.contains("/Matrix")), + "{matrix}, 8 0 obj 9 0 R, 9 0 obj {}, mark at {mark_at:?}: {result:?}", + String::from_utf8_lossy(target) + ); + } + } +} + /// The scans count such a form as content that may remain: the verify pass under a mark, and /// Remove Hidden Information's scan, whose removal then refuses the page. #[test] diff --git a/crates/redact/src/verify/extract.rs b/crates/redact/src/verify/extract.rs index bc46348715..63dbeff342 100644 --- a/crates/redact/src/verify/extract.rs +++ b/crates/redact/src/verify/extract.rs @@ -396,17 +396,20 @@ pub(super) fn push_squashed(into: &mut String, s: &str) { into.extend(s.chars().filter(|c| !c.is_whitespace() && *c != '\u{feff}')); } -/// A form's or pattern's `/Matrix`, the array and each entry direct or by reference: absent or null +/// A form's or an annotation appearance's `/Matrix`, the array and each entry direct or by reference: absent or null /// is the identity, present but malformed is an error (guessing the identity would put its content in the -/// wrong place). The redaction walk reads a form's with it too. +/// wrong place). A reference to a reference is malformed here: readers disagree on following it +/// (Poppler doesn't, the app's renderer does), so where the content draws can't be told. The +/// redaction walk reads a form's with it too. pub(crate) fn matrix_of(doc: &Document, dict: &Dict) -> Result { let Some(m) = dict.get(b"Matrix") else { return Ok(Matrix::IDENTITY) }; - // Read in place when written directly, and the length checked before any entry is resolved: - // the walk reads it at every `Do`, and an array of any length would be copied each time. + // Each reference is followed one hop. The array is read in place when written directly, and + // its length checked before any entry is read: the walk reads it at every `Do`, and an array of + // any length would be copied each time. let held; let m = match m { - Object::Ref(_) => { - held = doc.resolve(m); + Object::Ref(r) => { + held = doc.get(*r); &*held } direct => direct, @@ -416,8 +419,15 @@ pub(crate) fn matrix_of(doc: &Document, dict: &Dict) -> Result { return Ok(Matrix::IDENTITY); } let items = m.as_array().filter(|a| a.len() == 6).ok_or(())?; - let items: Vec = items.iter().map(|x| (*doc.resolve(x)).clone()).collect(); - Matrix::from_operands(&items).filter(|m| m.0.iter().all(|v| v.is_finite())).ok_or(()) + let mut values = [0.0; 6]; + for (value, item) in values.iter_mut().zip(items) { + let n = match item { + Object::Ref(r) => doc.get(*r).as_f64(), + direct => direct.as_f64(), + }; + *value = n.filter(|n| n.is_finite()).ok_or(())?; + } + Ok(Matrix(values)) } /// A form's `/BBox` as `[x0 y0 x1 y1]`: absent is `None`, present but malformed is an error.