From e3f86940a3bf9badfba49bd55b3b763009e7246a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 21:32:20 +0000 Subject: [PATCH] M7: redaction audit skips covered-text assembly when copying is withheld --- crates/audit/src/lib.rs | 19 +++++++++++-------- crates/audit/src/tests.rs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 509ae977e..5cbcb75bf 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -142,12 +142,17 @@ impl Budget { self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); } - /// The text of the `lines` that `hides`, joined with single spaces. - fn hidden_text(&mut self, lines: &[TextLine], hides: impl Fn(&TextLine) -> bool) -> Result { + /// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show + /// no text. Without `copying` the text is empty and never assembled: one long line under many + /// covers would otherwise be copied once per cover, outside both bounds. + fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result, AuditError> { self.comparisons = self.comparisons.saturating_add(self.page_cost); if self.comparisons > self.limits.comparisons { return Err(AuditError::TooManyComparisons); } + if !copying { + return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { if !covered.is_empty() { @@ -155,7 +160,7 @@ impl Budget { } covered.push_str(line.text.trim()); } - Ok(covered) + Ok((!covered.trim().is_empty()).then_some(covered)) } /// The text a finding reports for `covered`: none when copying is withheld. Charges what it @@ -179,19 +184,17 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - let covered = budget.hidden_text(&lines, |line| is_cover(&r, line))?; - if !covered.trim().is_empty() { + if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? { let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - let covered = budget.hidden_text(&lines, |line| match &annot { + if let Some(covered) = budget.hidden_text(&lines, copying, |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), AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 659f8de60..ac02251c7 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -709,3 +709,18 @@ fn copy_restricted_documents_withhold_covered_text() { let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); } + +/// Withheld text is never assembled: covers over one long line would otherwise copy the line once +/// per cover, a volume neither the comparison nor the output bound charges. +#[test] +fn withheld_covered_text_is_not_assembled() { + let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 }; + budget.start_page(&lines); + let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget"); + assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it"); + assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None); + let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget"); + assert_eq!(copied.as_deref(), Some("SECRET DATA 123")); +}