fix(audit): withhold covered text when copying is not permitted

The audit returned the text under each cover whatever the document's
permissions, and the CLI and the automation tool printed it. A user
whose password withholds copying could read through the audit the text
that text extraction refuses them. The audit now reports every finding
for such a document with empty covered text; the owner password lifts
the restriction as it does elsewhere.
This commit is contained in:
aiongg
2026-10-22 16:44:38 +00:00
parent 65ee18d470
commit 3fce90e94a
3 changed files with 42 additions and 4 deletions
+6 -1
View File
@@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden:
less than 40% of its words (a sliver of a long value) is not flagged.
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-line
comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no
findings".
A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with
an empty `covered_text`: the audit hands out no text that copying would refuse.
Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each
rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like
+10 -3
View File
@@ -65,7 +65,7 @@ pub struct AuditFinding {
/// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`.
pub rect: [f64; 4],
/// The extractable text the cover hides, lines joined with single spaces and trimmed.
/// Possibly empty when nothing decodable remains under the cover.
/// Empty when the document's permissions withhold copying (see [`audit_redactions`]).
pub covered_text: String,
}
@@ -101,6 +101,10 @@ const COVER_CONTRAST: f64 = 3.0;
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
/// 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.
///
/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are
/// still reported but their `covered_text` is empty: the audit hands out no text that copying
/// would refuse.
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
audit(doc, Limits::DEFAULT)
}
@@ -152,6 +156,7 @@ impl Budget {
}
fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError> {
let copying = doc.permissions().is_none_or(|p| p.copy());
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() {
@@ -161,7 +166,8 @@ fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError
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() {
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
let covered_text = if copying { covered } else { String::new() };
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text });
}
}
// 2. Annotation covers over extractable text.
@@ -175,7 +181,8 @@ fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
};
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
let covered_text = if copying { covered } else { String::new() };
out.push(AuditFinding { page: n, kind, rect, covered_text });
}
}
}
+26
View File
@@ -675,3 +675,29 @@ fn cover_and_line_comparisons_are_bounded() {
let limits = crate::Limits { comparisons: 400, covered_text: usize::MAX };
assert!(crate::audit(&doc, limits).expect("within the budget").is_empty());
}
/// Covered text is document text: a document whose permissions withhold copying reports where
/// its covers are but not what they hide, unless it was opened with the owner password.
#[test]
fn copy_restricted_documents_withhold_covered_text() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
let mut doc = open(one_page(content, ""));
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1 ^ 0b1_0000,
encrypt_metadata: true,
seed: [7; 32],
})
.expect("encrypt");
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save"));
let user = Document::open_with_password(bytes.clone(), None).expect("open");
let findings = audit_redactions(&user).expect("audit must run");
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
assert_eq!(findings[0].covered_text, "");
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
let findings = audit_redactions(&owner).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}