From 65ee18d470acf96dc25acef5f2f024312b03b0e3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:46:49 +0000 Subject: [PATCH] fix(audit): bound cover-line comparisons and covered text Every cover was compared with every text line of its page, and each finding kept its own copy of the text it covered. Both grow with the square of the content: a few megabytes of covers over one long line meant hours of comparisons and gigabytes of report. The audit now counts comparisons per page and covered-text bytes across the document and fails with an error past either bound, never with a partial report. --- crates/audit/src/lib.rs | 86 ++++++++++++++++++++++++++++----------- crates/audit/src/tests.rs | 31 ++++++++++++++ 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 6dc329d9ae..d827159570 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -79,6 +79,10 @@ pub enum AuditError { TooDeep, #[error("page has a malformed /Annots entry")] BadAnnots, + #[error("page has more covers and text lines than the audit compares")] + TooManyComparisons, + #[error("covered text exceeds the audit's report budget")] + TooMuchOutput, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error(transparent)] @@ -95,41 +99,77 @@ const WORD_FRACTION: f64 = 0.5; const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any -/// content that cannot be decoded is returned as an error, never as an empty finding list. +/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is +/// returned as an error, never as an empty finding list. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { + audit(doc, Limits::DEFAULT) +} + +/// Work and output bounds for one audit. +#[derive(Clone, Copy)] +struct Limits { + /// Cover-against-line comparisons per page. + comparisons: usize, + /// Bytes of covered text across all findings. + covered_text: usize, +} + +impl Limits { + /// Every cover is compared with every line of its page, and each finding repeats the text + /// it covers, so both grow with the square of the content: a few megabytes of covers over + /// one long line would otherwise take hours and gigabytes. + const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 }; +} + +/// The work and report the audit has spent against its [`Limits`]. +struct Budget { + limits: Limits, + comparisons: usize, + covered_text: usize, +} + +impl Budget { + /// The text of the `lines` that `hides`, joined with single spaces. + fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result { + self.comparisons = self.comparisons.saturating_add(lines.len()); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + let mut covered = String::new(); + for line in lines.iter().filter(|l| hides(l)) { + let text = line.text.trim(); + self.covered_text = self.covered_text.saturating_add(text.len() + 1); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + if !covered.is_empty() { + covered.push(' '); + } + covered.push_str(text); + } + Ok(covered) + } +} + +fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let mut budget = Budget { limits, comparisons: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; + budget.comparisons = 0; // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - if is_cover(&r, line) { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } + let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; if !covered.trim().is_empty() { out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - let hidden = match &annot { - AnnotationCover::Redact(rect) => covers(rect, line), - AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, - }; - if hidden { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } + let covered = budget.hidden_text(&lines, |line| match &annot { + AnnotationCover::Redact(rect) => covers(rect, line), + AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, + })?; if !covered.trim().is_empty() { let (kind, rect) = match annot { AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 9d5a1726d2..ed809bc633 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -644,3 +644,34 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { assert_eq!(findings.len(), 1, "{findings:?}"); assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } + +/// Many covers over the same long line repeat its text in every finding: a small file must not +/// expand into an unbounded report. Past the budget the audit fails rather than truncate. +#[test] +fn covered_text_across_findings_is_bounded() { + let text = "X".repeat(60_000); + let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes(); + for i in 0..1_200 { + content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes()); + } + let result = audit_redactions(&open(one_page(&content, ""))); + assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len())); +} + +/// Covers compared against lines are bounded per page, so content that is quadratic in its +/// covers and lines fails the audit instead of running for hours. +#[test] +fn cover_and_line_comparisons_are_bounded() { + let mut content = Vec::new(); + for i in 0..20 { + content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes()); + } + for _ in 0..20 { + content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); + } + let doc = open(one_page(&content, "")); + let limits = crate::Limits { comparisons: 399, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX }; + assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); +}