mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
* fix(audit): an applied redaction at the end of a line is not a faux redaction After Apply Redactions removes text at the end of a line, the line keeps a TJ adjustment where the text was, and the box of the line still spans it. The audit compared the redaction box with that box: at the end of a line it covered more than 40% of it and the words left beside it were reported as covered text, on a correctly redacted file. In the middle of a line the gap was usually too small to cross the threshold. Text lines now also give the boxes of the words they draw (`ink`: glyphs, without spaces and TJ adjustments), and the audit measures how much of those words a cover hides. A real cover over text that is still there is found as before. doc_audit_redactions also reports its rectangles in top-left-origin points like comment_list and doc_info; they were in PDF user space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(audit): leave the rectangles of doc_audit_redactions in user space here Reporting doc_audit_redactions rectangles in top-left-origin points is a separate subject from the false alarm this branch fixes, and on its own it made the tool and `pdfcraft-cli audit` disagree on the same file: the CLI still prints PDF user space. Take it out of this change; the tool and the CLI can move together in their own change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(edit): word boxes span whole words when glyphs run backwards With a negative font size or a negative horizontal scale (Tz), each glyph advances backwards along the text matrix, and the box of a word kept only its first glyph: the end of the word was compared with a start that had already moved past it. A word box is now the union of its glyphs whichever way they go, and a TJ adjustment ends a word past 0.2 em of the scaled size, whatever its sign. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(audit): a cover over a short value after a long label is still found Measuring a cover on the words of a line alone missed faux redactions main found: a long label followed by a short value hidden under the box is less than 40% of the line's words. A cover now also counts, as before, when it hides 40% of the line's box and at least half of one word still drawn; a cover over the gap an applied redaction left, with no word under it, still does not. The audit README states both rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(audit): retain partial-value cover detections Co-Authored-By: OpenAI Codex <noreply@openai.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
460 lines
21 KiB
Rust
460 lines
21 KiB
Rust
//! Tests build their own tiny PDFs in code (contributor-original fixtures): no binary files,
|
|
//! no Adobe material, exact xref offsets computed by the builder below.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use pdfcraft_cos::Document;
|
|
|
|
use crate::{AuditFinding, FindingKind, audit_redactions};
|
|
|
|
/// Minimal PDF assembler: objects are numbered 1..=n in order.
|
|
struct Builder {
|
|
objects: Vec<Vec<u8>>,
|
|
}
|
|
|
|
impl Builder {
|
|
fn new() -> Self {
|
|
Builder { objects: Vec::new() }
|
|
}
|
|
|
|
fn add(&mut self, body: &str) -> usize {
|
|
self.objects.push(body.as_bytes().to_vec());
|
|
self.objects.len()
|
|
}
|
|
|
|
fn add_stream(&mut self, data: &[u8]) -> usize {
|
|
self.add_stream_with(data, "")
|
|
}
|
|
|
|
fn add_stream_with(&mut self, data: &[u8], dict_extra: &str) -> usize {
|
|
let mut body = format!("<< /Length {} {dict_extra} >>\nstream\n", data.len()).into_bytes();
|
|
body.extend_from_slice(data);
|
|
body.extend_from_slice(b"\nendstream");
|
|
self.objects.push(body);
|
|
self.objects.len()
|
|
}
|
|
|
|
fn build(self) -> Vec<u8> {
|
|
let mut out = b"%PDF-1.7\n".to_vec();
|
|
let mut offsets = Vec::new();
|
|
for (i, obj) in self.objects.iter().enumerate() {
|
|
offsets.push(out.len());
|
|
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
|
out.extend_from_slice(obj);
|
|
out.extend_from_slice(b"\nendobj\n");
|
|
}
|
|
let xref_at = out.len();
|
|
let n = self.objects.len() + 1;
|
|
out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
|
|
out.extend_from_slice(b"0000000000 65535 f \n");
|
|
for off in offsets {
|
|
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
|
}
|
|
out.extend_from_slice(b"trailer\n<< ");
|
|
out.extend_from_slice(format!("/Size {n} /Root 1 0 R").as_bytes());
|
|
out.extend_from_slice(b" >>\nstartxref\n");
|
|
out.extend_from_slice(format!("{xref_at}\n").as_bytes());
|
|
out.extend_from_slice(b"%%EOF\n");
|
|
out
|
|
}
|
|
}
|
|
|
|
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
|
|
|
/// One page with `content` as its stream; `page_extra` is appended inside the page dict.
|
|
fn one_page(content: &[u8], page_extra: &str) -> Vec<u8> {
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
let stream_num = 4;
|
|
b.add(&format!(
|
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"
|
|
));
|
|
b.add_stream(content);
|
|
b.add(FONT);
|
|
b.build()
|
|
}
|
|
|
|
/// One page with two content streams (`/Contents [4 0 R 5 0 R]`).
|
|
fn two_streams(s1: &[u8], s2: &[u8]) -> Vec<u8> {
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>");
|
|
b.add_stream(s1);
|
|
b.add_stream(s2);
|
|
b.add(FONT);
|
|
b.build()
|
|
}
|
|
|
|
/// One page whose content stream claims `/FlateDecode` but holds garbage: decoding must fail.
|
|
fn bad_filter_page() -> Vec<u8> {
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>");
|
|
b.add_stream_with(b"this is not deflated data", "/Filter /FlateDecode");
|
|
b.add(FONT);
|
|
b.build()
|
|
}
|
|
|
|
fn open(bytes: Vec<u8>) -> Document {
|
|
Document::open(Arc::new(bytes)).expect("test pdf must open")
|
|
}
|
|
|
|
#[test]
|
|
fn faux_redaction_is_found() {
|
|
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 findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
let f = &findings[0];
|
|
assert_eq!(f.page, 0);
|
|
assert_eq!(f.kind, FindingKind::CoveredText);
|
|
assert!(f.covered_text.contains("SECRET"), "covered_text was {:?}", f.covered_text);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_matrix_between_rectangle_and_fill_still_covers_text() {
|
|
// A no-op `cm` inside the path must not drop the pending rectangle (#1333).
|
|
for cover in [&b"0 g\n100 688 220 32 re 1 0 0 1 0 0 cm f\n"[..], b"0 g\n1 0 0 1 0 0 cm 100 688 220 32 re f\n"] {
|
|
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
|
|
content.extend_from_slice(cover);
|
|
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
|
|
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
|
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
|
|
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clean_document_has_no_findings() {
|
|
let content = b"BT /F1 24 Tf 100 700 Td (nothing hidden here) Tj ET\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn black_box_without_text_is_not_a_finding() {
|
|
// A filled rectangle with no text under it is decoration, not a faux redaction.
|
|
let content = b"BT /F1 24 Tf 100 100 Td (far away) Tj ET\n0 g\n400 600 100 40 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn translucent_cover_is_not_flagged() {
|
|
// A rectangle drawn with a translucent ExtGState does not hide the text.
|
|
let content = b"/GS1 gs\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
|
|
let page_extra = "/Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R >> >>";
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R {page_extra} >>"));
|
|
b.add_stream(content);
|
|
b.add(FONT);
|
|
b.add("<< /Type /ExtGState /ca 0.5 >>");
|
|
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn unapplied_redact_mark_is_found() {
|
|
let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n";
|
|
let page_extra = "/Annots [6 0 R]";
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"));
|
|
b.add_stream(content);
|
|
b.add(FONT);
|
|
b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>");
|
|
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
|
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
|
|
}
|
|
|
|
#[test]
|
|
fn background_before_text_is_not_flagged() {
|
|
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
|
let content = b"0 g\n100 688 220 32 re f\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn light_shading_after_text_is_not_flagged() {
|
|
// Light table-style shading painted after the text leaves it readable: not a cover.
|
|
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0.9 g\n100 688 220 32 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn graphics_state_carries_across_streams() {
|
|
// The first stream sets a white fill after the text; the second stream's rectangle over
|
|
// the text is therefore white, not the default black: no finding. Without the carry fix
|
|
// the rectangle would default to black and flag.
|
|
let s1 = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n1 g\n";
|
|
let s2 = b"100 688 220 32 re f\n";
|
|
let findings = audit_redactions(&open(two_streams(s1, s2))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn audit_fails_closed_on_undecodable_stream() {
|
|
// A content stream that cannot be decoded is an audit error, never an empty finding list.
|
|
let err = audit_redactions(&open(bad_filter_page())).expect_err("audit must fail closed");
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("decoded"), "unexpected error: {msg}");
|
|
}
|
|
|
|
/// A form XObject that draws itself nests without end. The walk can't follow it to the bottom, so
|
|
/// the audit fails rather than report the page clean with a possible cover left unseen.
|
|
#[test]
|
|
fn self_drawing_form_fails_the_audit() {
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>");
|
|
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
|
|
b.add_stream_with(
|
|
b"0 g\n100 688 220 32 re f\n/X Do\n",
|
|
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /X 5 0 R >> >>",
|
|
);
|
|
b.add(FONT);
|
|
let result = audit_redactions(&open(b.build()));
|
|
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
|
}
|
|
|
|
/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the
|
|
/// text was, then the redaction box. At the end of a line, the box covers most of the line's box
|
|
/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged.
|
|
#[test]
|
|
fn applied_redaction_at_the_end_of_a_line_is_not_flagged() {
|
|
let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
// The same gap between two words, wider than the words beside it.
|
|
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
/// The control: the same box over text still drawn under it is a faux redaction, at the end of a
|
|
/// line as in the middle of a short one.
|
|
#[test]
|
|
fn faux_redaction_at_the_end_of_a_line_is_found() {
|
|
let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
|
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
}
|
|
|
|
/// A cover over a short value after a long label hides less than 40% of the line's words, but the
|
|
/// whole of the words it covers, and most of the line's box: still a faux redaction. The value
|
|
/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`.
|
|
#[test]
|
|
fn faux_redaction_over_a_short_value_after_a_long_label_is_found() {
|
|
let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789");
|
|
let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
assert!(findings[0].covered_text.contains("4111"), "{findings:?}");
|
|
}
|
|
|
|
/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a
|
|
/// cover over the address it draws is still found.
|
|
#[test]
|
|
fn faux_redaction_over_mirrored_text_is_found() {
|
|
let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n";
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{findings:?}");
|
|
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
|
}
|
|
|
|
/// A cover over less than half a value must retain the historical line-box detection.
|
|
#[test]
|
|
fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() {
|
|
partial_value_is_found(false);
|
|
}
|
|
|
|
#[test]
|
|
fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() {
|
|
partial_value_is_found(true);
|
|
}
|
|
|
|
fn partial_value_is_found(rotated: bool) {
|
|
let bytes = one_page(
|
|
b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n",
|
|
if rotated { "/Rotate 90" } else { "" },
|
|
);
|
|
let findings = audit_redactions(&open(bytes)).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "partial value, rotated={rotated}");
|
|
}
|
|
|
|
/// Keep the historic detections with several covers, lines and text-spacing states.
|
|
#[test]
|
|
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
|
let cases: [&[u8]; 4] = [
|
|
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
|
|
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
|
|
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
|
|
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
|
|
];
|
|
for content in cases {
|
|
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rectangle_drawn_as_a_path_covers_text_like_re() {
|
|
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
|
|
// only by the fill itself), or in a scaled user space: all are faux redactions (#1510).
|
|
// So are `re` with a redundant `h`, and either path painted with `b`/`b*` (#1817).
|
|
let covers: [&[u8]; 10] = [
|
|
b"0 g\n100 688 220 32 re f\n",
|
|
b"0 g\n100 688 220 32 re h f\n",
|
|
b"0 g\n100 688 220 32 re b\n",
|
|
b"0 g\n100 688 220 32 re h b*\n",
|
|
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l b\n",
|
|
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h b*\n",
|
|
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n",
|
|
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n",
|
|
b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n",
|
|
b"0 g\nq 2 0 0 2 0 0 cm 50 344 m 160 344 l 160 360 l 50 360 l h f Q\n",
|
|
];
|
|
for cover in covers {
|
|
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
|
|
content.extend_from_slice(cover);
|
|
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
|
assert_eq!(findings.len(), 1, "{:?}: {findings:?}", String::from_utf8_lossy(cover));
|
|
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
|
assert_eq!(findings[0].rect, [100.0, 688.0, 320.0, 720.0]);
|
|
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn non_rectangular_path_is_not_a_cover() {
|
|
// Each path's bounding box covers the text, but the shape itself does not hide it all.
|
|
let covers: [&[u8]; 7] = [
|
|
// A triangle.
|
|
b"0 g\n100 688 m 320 688 l 320 720 l h f\n",
|
|
// A triangle painted with `b`, and a trapezoid with `b*`.
|
|
b"0 g\n100 688 m 320 688 l 320 720 l b\n",
|
|
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l b*\n",
|
|
// A triangle, then a line from its start point after `h`: not a fourth corner.
|
|
b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n",
|
|
// A trapezoid: four corners, one slanted side.
|
|
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l h f\n",
|
|
// A rectangle rotated 5 degrees about the text by the CTM.
|
|
b"0 g\nq 0.9962 0.0872 -0.0872 0.9962 62.15 -15.63 cm 100 688 m 320 688 l 320 720 l 100 720 l h f Q\n",
|
|
// Two subpaths filled together: even-odd leaves the inner one as a hole.
|
|
b"0 g\n90 680 m 330 680 l 330 730 l 90 730 l h 100 688 m 320 688 l 320 720 l 100 720 l h f*\n",
|
|
];
|
|
for cover in covers {
|
|
let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n".to_vec();
|
|
content.extend_from_slice(cover);
|
|
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn finding_kind_ids_are_stable() {
|
|
// The string IDs are the crate's public vocabulary (CLI output, future machine-readable
|
|
// formats). They must not change; add a variant instead of renaming.
|
|
assert_eq!(FindingKind::CoveredText.id(), "covered-text");
|
|
assert_eq!(FindingKind::UnappliedMark.id(), "unapplied-mark");
|
|
}
|
|
|
|
#[test]
|
|
fn covered_text_finding_is_fully_determined() {
|
|
// Golden test: the whole finding, not just its presence, is pinned.
|
|
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 findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
|
assert_eq!(
|
|
findings,
|
|
vec![AuditFinding {
|
|
page: 0,
|
|
kind: FindingKind::CoveredText,
|
|
rect: [100.0, 688.0, 320.0, 720.0],
|
|
covered_text: "SECRET DATA 123".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unapplied_mark_finding_is_fully_determined() {
|
|
// Golden test for the second finding class, with its exact mark rectangle.
|
|
let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n";
|
|
let page_extra = "/Annots [6 0 R]";
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"));
|
|
b.add_stream(content);
|
|
b.add(FONT);
|
|
b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>");
|
|
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
|
assert_eq!(
|
|
findings,
|
|
vec![AuditFinding { page: 0, kind: FindingKind::UnappliedMark, rect: [95.0, 685.0, 305.0, 725.0], covered_text: "DO NOT SHARE".to_string() }]
|
|
);
|
|
}
|
|
|
|
/// A page whose content draws a chain of `n` nested form XObjects. The innermost form draws
|
|
/// nothing, so a successful audit reports no findings.
|
|
fn nested_forms(n: usize) -> Vec<u8> {
|
|
let mut b = Builder::new();
|
|
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
|
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
|
// Object 3: page; object 4: content; objects 5..5+n: the form chain.
|
|
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /XObject << /F1 5 0 R >> >> >>");
|
|
b.add_stream(b"/F1 Do");
|
|
for i in 1..=n {
|
|
let obj = 4 + i;
|
|
if i < n {
|
|
let next = obj + 1;
|
|
let name = format!("/F{}", i + 1);
|
|
b.add_stream_with(
|
|
format!("/{name} Do", name = &name[1..]).as_bytes(),
|
|
&format!(
|
|
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /F{i} {next} 0 R >> >>",
|
|
i = i + 1,
|
|
next = next
|
|
),
|
|
);
|
|
} else {
|
|
b.add_stream_with(b"q Q", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
|
|
}
|
|
debug_assert_eq!(b.objects.len(), obj);
|
|
}
|
|
b.build()
|
|
}
|
|
|
|
#[test]
|
|
fn forms_nested_at_depth_limit_audit_clean() {
|
|
// 32 nested forms is the deepest walk the audit follows: it must succeed and report
|
|
// the page clean rather than fail.
|
|
let findings = audit_redactions(&open(nested_forms(32))).expect("audit must run");
|
|
assert!(findings.is_empty(), "{findings:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn forms_nested_past_depth_limit_fail_closed() {
|
|
// The 33rd nesting level cannot be followed: the audit fails rather than report the
|
|
// page clean with a possible cover left unseen.
|
|
let result = audit_redactions(&open(nested_forms(33)));
|
|
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
|
}
|