Merge pull request #6886 from Piohrun/fix/redact-form-matrix-by-reference

M8: the redaction walk reads a form's /Matrix entries by reference
This commit is contained in:
FredApps authored and GitHub committed 2026-11-11 14:37:09 +01:00
commit 209bb40d88
6 files changed
+248 -10

No files matched your search

+26 -1
View File
@@ -1071,7 +1071,32 @@ fn xobject(
}
}
Some(b"Form") => {
let m = s.dict.get(b"Matrix").and_then(|m| Matrix::from_operands(doc.resolve(m).as_array()?)).unwrap_or_default();
// Read as the proof reads it, entries by reference included: read as the identity, a
// 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 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
// page, can't be told. Apply refuses the page, and the scans count it, as for a
// form past the page's budget below.
Err(()) => {
if scope.mode == Mode::Apply {
out.error = Some(CosError::Syntax {
offset: 0,
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;
} else {
out.residue += 1;
}
return None;
}
};
let fctm = m.then(&gs.ctm);
let bbox = s.dict.get(b"BBox").and_then(|b| {
let v: Vec<f64> = doc.resolve(b).as_array()?.iter().filter_map(Object::as_f64).collect();
+1
View File
@@ -6231,6 +6231,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
mod descriptions;
mod elsewhere;
mod form_matrix;
mod hidden_scope;
mod identification;
mod inline_images;
+131
View File
@@ -0,0 +1,131 @@
//! A form's `/Matrix` places what it draws. The redaction walk reads it as the proof does, the
//! array and each entry direct or by reference: read as the identity, a form moved onto a mark
//! was tested against the wrong place, never walked, and its text stayed.
use super::*;
/// One page drawing a form with resources of its own, whose content shows SECRET at (20, 60) in a
/// `/BBox` of [0 0 300 100], with `matrix` in its dictionary; object 8 is `extra`.
fn moved_form(matrix: &str, extra: &[u8]) -> Document {
one_page(
b"/Fm Do",
"/XObject << /Fm 7 0 R >>",
vec![
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",
),
extra.to_vec(),
],
)
}
/// Over the top third of the 300 × 300 page, where a move of 200 puts the text.
const MARK: [f64; 4] = [0.0, 200.0, 300.0, 300.0];
#[test]
fn a_form_moved_onto_a_mark_is_redacted_however_its_matrix_is_given() {
for (matrix, extra) in [
("/Matrix [1 0 0 1 0 200]", &b"null"[..]),
("/Matrix 8 0 R", b"[1 0 0 1 0 200]"),
("/Matrix [1 0 0 1 0 8 0 R]", b"200"),
("/Matrix [8 0 R 0 0 1 0 200]", b"1"),
] {
let mut doc = moved_form(matrix, extra);
mark(&mut doc, 0, &[MARK], "");
assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(6), "{matrix} with 8 0 obj {}", String::from_utf8_lossy(extra));
}
}
/// A `/Matrix` of null, given directly or by a reference to null, is no `/Matrix` (ISO 32000-2
/// §7.3.9): the identity, under which the text lies below the mark and stays.
#[test]
fn a_form_whose_matrix_is_null_draws_where_it_stands() {
for (matrix, extra) in [("/Matrix null", &b"null"[..]), ("/Matrix 8 0 R", b"null")] {
let mut doc = moved_form(matrix, extra);
mark(&mut doc, 0, &[MARK], "");
assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(0), "{matrix}");
let mut doc = moved_form(matrix, extra);
mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 100.0]], "");
assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(6), "{matrix}, marked where it stands");
}
}
/// A `/Matrix` that isn't six numbers: where the form draws can't be told, so neither can whether
/// it reaches the mark, and the page is refused.
#[test]
fn a_form_whose_matrix_is_not_six_numbers_is_refused() {
for (matrix, extra) in [
("/Matrix [1 0 0 1 0 (up)]", &b"null"[..]),
("/Matrix [1 0 0 1 0]", b"null"),
("/Matrix [1 0 0 1 0 8 0 R]", b"(up)"),
("/Matrix 8 0 R", b"(up)"),
] {
let mut doc = moved_form(matrix, extra);
mark(&mut doc, 0, &[MARK], "");
let result = apply(&mut doc, None);
assert!(
matches!(&result, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) if detail.contains("/Matrix")),
"{matrix} with 8 0 obj {}: {result:?}",
String::from_utf8_lossy(extra)
);
}
}
/// 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]
fn a_form_whose_matrix_is_not_six_numbers_counts_in_the_scans() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = moved_form("/Matrix [1 0 0 1 0 (up)]", b"null");
assert_eq!(under(&mut doc, 0, &[MARK]), 1);
let counts: std::collections::HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert_eq!(counts[&Hidden::HiddenText], 1);
let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]);
assert!(matches!(&removed, Err(RedactError::Cos(pdfcraft_cos::CosError::Syntax { detail, .. })) if detail.contains("/Matrix")), "{removed:?}");
}
/// Removing hidden layers alone never asks where content lands, so such a form is walked as it
/// stands: nothing is counted or refused over it in a document without layers.
#[test]
fn a_form_whose_matrix_is_not_six_numbers_leaves_removing_hidden_layers_alone() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = moved_form("/Matrix [1 0 0 1 0 (up)]", b"null");
let counts: std::collections::HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert_eq!(counts[&Hidden::HiddenLayers], 0);
assert_eq!(remove_hidden(&mut doc, &[Hidden::HiddenLayers]), Err(RedactError::NothingToApply));
}
/// Remove Hidden Information reads it the same way: text a matrix entry by reference moves off the
/// page is hidden text, and goes.
#[test]
fn text_a_matrix_entry_by_reference_moves_off_the_page_is_hidden_text() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = moved_form("/Matrix [1 0 0 1 0 8 0 R]", b"1000");
let removed = remove_hidden(&mut doc, &[Hidden::HiddenText]);
assert!(removed.as_ref().is_ok_and(|r| r.iter().any(|(h, n)| *h == Hidden::HiddenText && *n > 0)), "{removed:?}");
}
+30 -9
View File
@@ -396,17 +396,38 @@ 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`: absent is the identity, present but malformed is an error
/// (guessing the identity would put its content in the wrong place).
pub(super) fn matrix_of(doc: &Document, dict: &Dict) -> Result<Matrix, ()> {
match dict.get(b"Matrix") {
None => Ok(Matrix::IDENTITY),
Some(m) => {
let m = doc.resolve(m);
let items: Vec<Object> = m.as_array().ok_or(())?.iter().map(|x| (*doc.resolve(x)).clone()).collect();
Matrix::from_operands(&items).filter(|m| m.0.iter().all(|v| v.is_finite())).ok_or(())
/// 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). 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) };
// 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(r) => {
held = doc.get(*r);
&*held
}
direct => direct,
};
// A null value is no entry (ISO 32000-2 §7.3.9), whether given directly or by reference.
if matches!(m, Object::Null) {
return Ok(Matrix::IDENTITY);
}
let items = m.as_array().filter(|a| a.len() == 6).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.
+1
View File
@@ -70,6 +70,7 @@ mod snapshot;
mod sweep;
mod util;
pub(crate) use extract::matrix_of;
pub use snapshot::Snapshot;
/// What the proof established for one region.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+59
View File
@@ -0,0 +1,59 @@
//! Work regression for a form's `/Matrix`, which the redaction walk reads at every `Do` of the
//! form: an array of any length costs no more than one of seven entries, written directly or by
//! reference. A test binary of its own, so that none of the crate's other tests shares its process.
//! The test compares two timings of its own, the least of three runs each, not a fixed time.
use std::sync::Arc;
use std::time::{Duration, Instant};
use pdfcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad};
use pdfcraft_cos::Document;
use pdfcraft_redact::apply;
use pdfcraft_testkit::{Pdf, stream_obj};
/// One page drawing a form `draws` times under a mark, the form's `/Matrix` an array of `entries`
/// zeros, written in its dictionary or, `by_reference`, as object 6.
fn document(draws: usize, entries: usize, by_reference: bool) -> Document {
let zeros = format!("[{}]", vec!["0"; entries].join(" "));
let matrix = if by_reference { "6 0 R".to_string() } else { zeros.clone() };
let 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 << /Fm 5 0 R >> >> >>".to_vec(),
stream_obj("", "/Fm Do ".repeat(draws).as_bytes()),
stream_obj(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Matrix {matrix}"), b"0 0 10 10 re f"),
zeros.into_bytes(),
];
let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap();
let shape = Shape::Redact { quads: vec![rect_quad([0.0, 0.0, 300.0, 300.0])], overlay: String::new(), look: Default::default() };
let style = Style::default_for(&shape);
add_annotation(&mut doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "Synthetic test".into() }, &Meta::default())
.unwrap();
doc
}
/// How long redacting `document(draws, entries, by_reference)` takes, the least of three runs. A
/// matrix that isn't six numbers is refused.
fn redaction(draws: usize, entries: usize, by_reference: bool) -> Duration {
(0..3)
.map(|_| {
let mut doc = document(draws, entries, by_reference);
let start = Instant::now();
let refused = apply(&mut doc, None).is_err();
let took = start.elapsed();
assert!(refused, "a /Matrix of {entries} entries is refused");
took
})
.min()
.unwrap_or_default()
}
/// Written when the walk resolved and copied every entry before checking there were six: 4,000
/// draws of a form whose `/Matrix` is 4,000 entries long read sixteen million entries.
#[test]
fn a_long_form_matrix_costs_no_more_than_a_short_one() {
for by_reference in [false, true] {
let (short, long) = (redaction(4_000, 7, by_reference), redaction(4_000, 4_000, by_reference));
assert!(long < short * 3 + Duration::from_millis(50), "by reference: {by_reference}: long {long:?}, short {short:?}");
}
}