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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-10 16:45:34 +01:00
1 parent cf26980471
commit 4f107a6f40
3 files changed
+46 -10

No files matched your search

+4 -2
View File
@@ -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;
+24
View File
@@ -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]
+18 -8
View File
@@ -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<Matrix, ()> {
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<Matrix, ()> {
return Ok(Matrix::IDENTITY);
}
let items = m.as_array().filter(|a| a.len() == 6).ok_or(())?;
let items: Vec<Object> = 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.