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.
This commit is contained in:
aiongg
2026-10-22 16:44:38 +00:00
parent 3e306c22ef
commit 65ee18d470
2 changed files with 94 additions and 23 deletions
+63 -23
View File
@@ -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<Vec<AuditFinding>, 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<String, AuditError> {
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<Vec<AuditFinding>, 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),
+31
View File
@@ -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());
}