fix(audit): read operands from the end and every rectangle a fill paints

The cover walk read the first operand of g, gs and Do, while the renderer
takes operands from the end, so surplus operands could make the audit see a
white cover, a translucent state or an empty form where the page paints an
opaque black rectangle. It also kept only the last subpath of a path, so a
cover followed by another rectangle in the same fill went unseen.

g now reads its last operand like rg and k, and gs and Do use last_name.
The path keeps every rectangular subpath with its winding; a fill paints
them all, and where the even-odd rule or opposite windings can cut holes
the painted region is worked out on the rectangles' edge grid, bounded per
page.
This commit is contained in:
Dylan M. Taylor
2026-11-02 21:29:16 -05:00
parent 3936f93a14
commit f03c0df00d
2 changed files with 263 additions and 54 deletions
+165 -54
View File
@@ -1,10 +1,13 @@
//! Walk page content streams and collect the opaque filled rectangles they draw.
//!
//! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque
//! color (`re` + `f`) is the classic way a "redaction" is faked. A single closed `m`/`l` subpath
//! whose four corners form an axis-aligned rectangle in user space is the same shape and counts
//! too (#1510). Graphics state tracked here is deliberately small: the CTM stack (`q`/`Q`/`cm`),
//! the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
//! color (`re` + `f`) is the classic way a "redaction" is faked. A closed `m`/`l` subpath whose
//! four corners form an axis-aligned rectangle in user space is the same shape and counts too
//! (#1510). A fill paints every subpath of its path, so each rectangular subpath counts, with
//! the holes the even-odd rule or opposite windings cut out of overlapping ones; other subpaths
//! in the same path are not read and cut no holes. Operands are taken from the end of the
//! operand list, as the renderer takes them. Graphics state tracked here is deliberately small:
//! the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and the non-stroking alpha from ExtGState (`gs` reading
//! `/ca`). Form XObjects are followed read-only with a depth cap. Anything fancier (other filled paths, shadings, patterns) is
//! out of scope and documented as such.
//!
@@ -46,32 +49,52 @@ struct Gs {
alpha: f64,
}
/// The path under construction, as far as the audit cares about it.
enum Path {
Empty,
/// A `re` rectangle, in user space.
Rect([f64; 4]),
/// One `m`/`l` subpath, corners already mapped to user space; `true` once closed by `h`.
Lines(Vec<[f64; 2]>, bool),
/// Anything else: curves, a second subpath, a CTM change mid-path.
Other,
/// Most subpaths in one path whose painted region is worked out exactly when the fill rule can
/// leave holes (even-odd, or windings that disagree). Past it every rectangular subpath counts
/// as painted: the audit may then flag text in a hole, but never misses a cover.
const MAX_EXACT_SUBPATHS: usize = 32;
/// Bound on the grid cells times subpaths [`painted`] examines per page.
const MAX_PATH_WORK: usize = 100_000_000;
/// The path under construction, as far as the audit cares about it: every closed rectangular
/// subpath it holds, so a fill paints all of them (ISO 32000-2 §8.5.3.3), not just the last.
#[derive(Default)]
struct Path {
/// Rectangular subpaths so far, in user space, each with its winding direction (+1
/// counterclockwise, -1 clockwise).
rects: Vec<([f64; 4], i8)>,
/// The `m`/`l` subpath being built, corners already mapped to user space; `true` once closed
/// by `h`. `None` when there is none, or when it can no longer be a rectangle.
lines: Option<(Vec<[f64; 2]>, bool)>,
/// A CTM change mid-path, or a clip: the path is not read at all.
unreadable: bool,
}
impl Path {
/// The rectangle a fill of this path paints, if the path is one.
fn rect(self) -> Option<[f64; 4]> {
match self {
Path::Rect(r) => Some(r),
Path::Lines(pts, _) => lines_rect(pts),
Path::Empty | Path::Other => None,
fn is_empty(&self) -> bool {
self.rects.is_empty() && self.lines.is_none() && !self.unreadable
}
/// Ends the `m`/`l` subpath being built, keeping it when it is a rectangle.
fn close_lines(&mut self) {
if let Some((pts, _)) = self.lines.take()
&& let Some(r) = lines_rect(pts)
{
self.rects.push(r);
}
}
/// The rectangular subpaths a fill of this path paints, in user space.
fn finish(mut self) -> Vec<([f64; 4], i8)> {
self.close_lines();
if self.unreadable { Vec::new() } else { self.rects }
}
}
/// The bounding box of a closed polygon that is an axis-aligned rectangle, or `None`. A repeated
/// start point is dropped first; the remaining four corners must alternate horizontal and
/// vertical edges.
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
/// The bounding box of a closed polygon that is an axis-aligned rectangle, with its winding
/// direction, or `None`. A repeated start point is dropped first; the remaining four corners
/// must alternate horizontal and vertical edges.
fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<([f64; 4], i8)> {
// CTM products leave rounding noise, so corners compare with a tolerance (in points).
const EPS: f64 = 1e-6;
let same = |a: f64, b: f64| (a - b).abs() <= EPS;
@@ -95,7 +118,81 @@ fn lines_rect(mut pts: Vec<[f64; 2]>) -> Option<[f64; 4]> {
xs.into_iter().fold(f64::NEG_INFINITY, f64::max),
ys.into_iter().fold(f64::NEG_INFINITY, f64::max),
];
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some(r)
// Shoelace: twice the signed area, positive when the corners run counterclockwise.
let twice_area: f64 = [(a, b), (b, c), (c, d), (d, a)].iter().map(|(p, q)| p[0] * q[1] - q[0] * p[1]).sum();
let winding = if twice_area < 0.0 { -1 } else { 1 };
(r[2] - r[0] > EPS && r[3] - r[1] > EPS).then_some((r, winding))
}
/// The region a fill paints from rectangular subpaths `rects`, as rectangles. Under the nonzero
/// rule, subpaths that all wind the same way paint their union, so each is painted whole.
/// Otherwise overlaps can cancel (an even-odd frame is an outer rectangle with an inner hole), so
/// the region is worked out on the grid the rectangles' edges make, charging the cells examined
/// to `work`.
pub(crate) fn painted(rects: Vec<([f64; 4], i8)>, even_odd: bool, work: &mut usize) -> Result<Vec<[f64; 4]>, AuditError> {
let same_winding = rects.windows(2).all(|w| w[0].1 == w[1].1);
if rects.len() <= 1 || rects.len() > MAX_EXACT_SUBPATHS {
return Ok(rects.into_iter().map(|(r, _)| r).collect());
}
if !even_odd && same_winding {
// The union: a rectangle inside another (or the same one again) adds nothing to it, and
// would only report the same cover twice.
let inside = |a: &[f64; 4], b: &[f64; 4]| b[0] <= a[0] && b[1] <= a[1] && a[2] <= b[2] && a[3] <= b[3];
let kept = rects
.iter()
.enumerate()
.filter(|(i, (r, _))| !rects.iter().enumerate().any(|(j, (o, _))| j != *i && inside(r, o) && (!inside(o, r) || j < *i)));
return Ok(kept.map(|(_, (r, _))| *r).collect());
}
let edges = |lo: usize, hi: usize| {
let mut v: Vec<f64> = rects.iter().flat_map(|(r, _)| [r[lo], r[hi]]).collect();
v.sort_by(f64::total_cmp);
v.dedup();
v
};
let (xs, ys) = (edges(0, 2), edges(1, 3));
let cells = xs.len().saturating_sub(1).saturating_mul(ys.len().saturating_sub(1));
*work = work.saturating_add(cells.saturating_mul(rects.len()));
if *work > MAX_PATH_WORK {
return Err(AuditError::TooManyOps);
}
let filled = |cx: f64, cy: f64| {
let inside = rects.iter().filter(|(r, _)| r[0] < cx && cx < r[2] && r[1] < cy && cy < r[3]);
if even_odd { inside.count() % 2 == 1 } else { inside.map(|(_, w)| i64::from(*w)).sum::<i64>() != 0 }
};
// Filled cells, merged into horizontal runs per grid row, and runs with the same span in
// adjacent rows merged into one rectangle.
let mut out: Vec<[f64; 4]> = Vec::new();
let mut open: Vec<usize> = Vec::new();
for row in ys.windows(2) {
let (y0, y1) = (row[0], row[1]);
let mut runs: Vec<(f64, f64)> = Vec::new();
for col in xs.windows(2) {
if !filled((col[0] + col[1]) / 2.0, (y0 + y1) / 2.0) {
continue;
}
match runs.last_mut() {
Some(run) if run.1 == col[0] => run.1 = col[1],
_ => runs.push((col[0], col[1])),
}
}
let mut next_open = Vec::with_capacity(runs.len());
for (x0, x1) in runs {
let grown = open.iter().copied().find(|&i| out.get(i).is_some_and(|r| r[0] == x0 && r[2] == x1 && r[3] == y0));
match grown.and_then(|i| out.get_mut(i).map(|r| (i, r))) {
Some((i, r)) => {
r[3] = y1;
next_open.push(i);
}
None => {
out.push([x0, y0, x1, y1]);
next_open.push(out.len().saturating_sub(1));
}
}
}
open = next_open;
}
Ok(out)
}
impl Default for Gs {
@@ -109,6 +206,8 @@ struct Walker<'a> {
pieces: &'a Pieces,
out: Vec<FilledRect>,
ops_seen: usize,
/// Grid cells times subpaths examined working out what compound paths paint ([`painted`]).
path_work: usize,
/// Decoded bytes and form entries left for the page, its forms included: spending either
/// fails the audit.
budget: DecodeBudget,
@@ -135,7 +234,7 @@ impl<'a> Walker<'a> {
depth: usize,
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
let mut path = Path::Empty;
let mut path = Path::default();
for (i, op) in ops.iter().enumerate() {
self.bump()?;
// Paint position of this operator: its own `(piece, joined index)` at page level,
@@ -162,12 +261,14 @@ impl<'a> Walker<'a> {
}
// A `cm` that leaves the CTM unchanged (an identity matrix) cannot move the
// pending path, so keep it for the fill (#1333).
if gs.ctm != before && !matches!(path, Path::Empty) {
path = Path::Other;
if gs.ctm != before && !path.is_empty() {
path.unreadable = true;
}
}
b"g" => {
if let Some(v) = op.num(0).filter(|v| v.is_finite()) {
// The renderer takes operands from the end and ignores any surplus before
// them, so the gray level is the last operand, as for `rg` and `k`.
if let Some([v]) = op.nums::<1>().filter(|v| v[0].is_finite()) {
let v = v.clamp(0.0, 1.0);
gs.fill = [v, v, v];
}
@@ -189,63 +290,73 @@ impl<'a> Walker<'a> {
}
}
b"gs" => {
if let Some(name) = op.name(0) {
if let Some(name) = op.last_name() {
gs.alpha = ext_alpha(self.doc, resources, name);
}
}
b"re" => {
// `re` is a complete subpath of its own: it ends any `m`/`l` subpath and
// joins the rectangles already in the path, which one fill paints together.
path.close_lines();
let rect = op.nums::<4>().and_then(|[x, y, w, h]| {
if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 {
let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
(r[2] > r[0] && r[3] > r[1]).then_some(r)
let [a, b, c, d, ..] = gs.ctm.0;
// Counterclockwise in pattern space when `w` and `h` share a sign;
// a mirroring CTM turns it around.
let winding = if (w * h).signum() * (a * d - b * c).signum() < 0.0 { -1 } else { 1 };
(r[2] > r[0] && r[3] > r[1]).then_some((r, winding))
} else {
None
}
});
path = rect.map_or(Path::Other, Path::Rect);
if let Some(r) = rect {
path.rects.push(r);
}
}
b"m" => {
path = match (path, user_point(gs, op)) {
(Path::Empty, Some(p)) => Path::Lines(vec![p], false),
_ => Path::Other,
};
path.close_lines();
path.lines = user_point(gs, op).map(|p| (vec![p], false));
}
b"l" => {
path = match (path, user_point(gs, op)) {
path.lines = match (path.lines.take(), user_point(gs, op)) {
// Past a repeated start point it can no longer be a rectangle.
(Path::Lines(mut pts, false), Some(p)) if pts.len() < 5 => {
(Some((mut pts, false)), Some(p)) if pts.len() < 5 => {
pts.push(p);
Path::Lines(pts, false)
Some((pts, false))
}
_ => Path::Other,
_ => None,
};
}
b"h" => {
path = match path {
Path::Lines(pts, _) => Path::Lines(pts, true),
// `re` already closed its subpath, so a further `h` changes nothing.
Path::Rect(r) => Path::Rect(r),
_ => Path::Other,
};
// `re` already closed its subpath, so a further `h` changes nothing.
if let Some((_, closed)) = &mut path.lines {
*closed = true;
}
}
// `b`/`b*` close the path before filling it, which the fill does anyway.
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
&& gs.alpha >= 1.0
{
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
let even_odd = op.op.ends_with(b"*");
let rects = std::mem::take(&mut path).finish();
if gs.alpha >= 1.0 {
for r in painted(rects, even_odd, &mut self.path_work)? {
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
}
}
}
b"S" | b"s" | b"n" => path = Path::Empty,
b"c" | b"v" | b"y" | b"W" | b"W*" => path = Path::Other,
b"S" | b"s" | b"n" => path = Path::default(),
// A curve leaves the subpath being built no rectangle; other subpaths stand.
b"c" | b"v" | b"y" => path.lines = None,
// A clipping path is not read: what it lets through is out of scope.
b"W" | b"W*" => path.unreadable = true,
b"Do" => {
if let Some(name) = op.name(0) {
if let Some(name) = op.last_name() {
if depth >= MAX_DEPTH {
return Err(AuditError::TooDeep);
}
self.walk_xobject(name, gs, resources, depth, pos)?;
}
path = Path::Empty;
path = Path::default();
}
_ => {}
}
@@ -331,7 +442,7 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
}
let pieces = Pieces::join(&streams);
let parsed = pieces.parse();
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, path_work: 0, budget };
let mut gs = Gs::default();
let mut stack = Vec::new();
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
+98
View File
@@ -820,3 +820,101 @@ fn form_entries_past_the_page_allowance_fail_the_audit() {
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
}
/// The renderer takes an operator's operands from the end and ignores surplus ones before them:
/// `0 1 g` paints white, `1 0 g` black. The audit reads the same operand, so surplus operands do
/// not turn a black cover white, or a white one black.
#[test]
fn surplus_gray_operands_are_read_from_the_end() {
for (fill, expected) in [("1 0 g", 1), ("0 1 g", 0)] {
let content = format!("BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n{fill}\n100 688 220 32 re f\n");
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
assert_eq!(findings.len(), expected, "{fill}: {findings:?}");
}
}
/// `Do` and `gs` act on their last operand: a surplus name before it must not make the audit
/// inspect another form or graphics state than the one the page draws with.
#[test]
fn surplus_name_operands_are_read_from_the_end() {
let page = |content: &[u8]| {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> \
/XObject << /Empty 6 0 R /Cover 7 0 R >> /ExtGState << /Clear << /ca 0.2 >> /Solid << /ca 1 >> >> >> >>",
);
b.add_stream(content);
b.add(FONT);
b.add_stream_with(b"", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
b.build()
};
let text = "BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
for (draw, expected) in [
("/Empty /Cover Do", 1),
("/Cover /Empty Do", 0),
("/Clear /Solid gs 0 g 100 688 220 32 re f", 1),
("/Solid /Clear gs 0 g 100 688 220 32 re f", 0),
] {
let findings = audit_redactions(&open(page(format!("{text}{draw}\n").as_bytes()))).expect("audit must run");
assert_eq!(findings.len(), expected, "{draw}: {findings:?}");
}
}
/// One fill paints every subpath of its path: a cover drawn before another rectangle in the
/// same path is still a cover, however the rectangles are drawn.
#[test]
fn every_rectangle_in_a_compound_path_is_a_cover() {
let covers: [&[u8]; 4] = [
b"0 g\n100 688 220 32 re 400 100 10 10 re f\n",
b"0 g\n100 688 220 32 re 400 100 10 10 re f*\n",
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h 400 100 10 10 re f\n",
b"0 g\n100 688 220 32 re 400 100 m 410 100 l 410 110 l 400 110 l h f\n",
];
for cover in covers {
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
content.extend_from_slice(cover);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0], "{:?}", String::from_utf8_lossy(cover));
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
}
/// Where compound subpaths overlap, the fill rule decides what is painted: a hole the even-odd
/// rule, or a subpath winding the other way under the nonzero rule, cuts out of a cover leaves the
/// text in it visible, while the same rectangles winding one way under the nonzero rule paint
/// their union.
#[test]
fn holes_in_a_compound_path_are_not_covers() {
let outer = "90 680 240 50 re";
let cases: [(String, usize); 4] = [
// Even-odd: the inner rectangle is a hole.
(format!("{outer} 100 688 220 32 re f*"), 0),
// Nonzero, inner drawn clockwise (negative width): a hole.
(format!("{outer} 320 688 -220 32 re f"), 0),
// Nonzero, both counterclockwise: the union, the outer frame and all, is painted.
(format!("{outer} 100 688 220 32 re f"), 1),
// A cover beside the hole is still found.
(format!("{outer} 100 688 220 32 re 100 600 220 32 re f*"), 1),
];
for (path, expected) in cases {
let content = format!("BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nBT /F1 24 Tf 100 610 Td (OTHER) Tj ET\n0 g\n{path}\n");
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
assert_eq!(findings.len(), expected, "{path}: {findings:?}");
}
}
/// The painted region of a compound path, rectangle by rectangle: an even-odd frame is four
/// strips around its hole.
#[test]
fn an_even_odd_frame_paints_four_strips() {
let mut work = 0;
let frame = vec![([0.0, 0.0, 10.0, 10.0], 1), ([2.0, 2.0, 8.0, 8.0], 1)];
let mut strips = crate::cover::painted(frame, true, &mut work).expect("within the bound");
strips.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
assert_eq!(strips, [[0.0, 0.0, 10.0, 2.0], [0.0, 2.0, 2.0, 8.0], [0.0, 8.0, 10.0, 10.0], [8.0, 2.0, 10.0, 8.0]]);
assert!(work > 0);
}