diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 041ba9ddd8..b3c67886e3 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -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, 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 = 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::() != 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 = 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, 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, 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)?; diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e5fb9ab683..733091cfcb 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -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); +}