diff --git a/crates/audit/README.md b/crates/audit/README.md index 37771a535..ce2c47915 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -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 diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index d82715957..4f1877ef6 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -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, AuditError> { audit(doc, Limits::DEFAULT) } @@ -152,6 +156,7 @@ impl Budget { } fn audit(doc: &Document, limits: Limits) -> Result, 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, 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, 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 }); } } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ed809bc63..de501d01a 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -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"); +}