mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
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:
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user