Some checks failed
email.yml / feat(pdf): redaction and object compaction — and three reader defects (push) Failing after 0s
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 5's last two functional items. Together they are what makes a redaction real, which is why they are one commit: redaction removes the content, compaction removes the revision that still holds it. **Redaction removes operators; it does not draw rectangles.** The famous failure is painting black over text and shipping it — the text is still there, and `pdftotext` prints it. This module draws nothing. It removes the text-showing operators whose position falls inside a rectangle, and the test that matters is that the text can no longer be extracted. Positioning needs the text matrix, so the module tracks `Tm`/`Td`/`TD`/`T*` and the CTM through `q`/`Q`/`cm`. It cannot reach the graphics layer — the crate boundary again — so it treats a showing operator's origin as its position and removes the whole run. That is coarse in the *safe* direction: removing more than asked loses content the user can see is missing; removing less leaves the secret in the file. What it refuses to claim is as important. Images are removed entirely rather than cropped. Metadata and attachments are untouched. And an incremental redaction leaves the original text in the earlier revision — the report says so via `earlier_revisions_retain_content` rather than implying the job is done. **Compaction finishes it.** The output is built from the object graph reachable from `/Root`, so dead objects, superseded revisions and the bytes behind a redaction are not copied — they are simply never written. A signed document is refused unless `allow_signed` is set, because compaction destroys the revision a signature covers and would leave every signature unverifiable with no warning. The end-to-end test is the point: redact, compact, then search the output bytes for the secret. It is gone. **Three reader defects, all found by writing the tests.** - **`PdfWriter` wrote dictionary keys unordered.** `PdfDict` is a HashMap and Rust seeds its hasher per process, so *every generated PDF differed run to run*. Found by compaction's idempotence test — compacting an already-compact file produced the same objects at the same offsets with their keys shuffled. Verified fixed by running four separate processes and getting a byte-identical file. Same defect as the one fixed in `content_edit::write_dict`; this one affected every file this codebase has ever written. - **A short `/Length` silently truncated a stream.** The reader guarded against a `/Length` running past the buffer but trusted one that was too small, cutting the stream at the wrong place and losing the rest with no error. Short lengths are common in hand-edited files. `endstream` is now the authority when the two disagree — but only when it is *further* on, so binary data containing the word `endstream` is still bounded by its declared length. - **Two stream readers disagreed by one byte.** `read_object_at` did not trim the EOL before `endstream` while `find_endstream` did, so a write-read-write cycle grew every stream by a newline. A test fixture had encoded the bug: it declared `/Length 9` for eight bytes of content and asserted the newline came back as data. Both corrected — the newline is syntax (§7.3.8.1), not content. Verified by mutation. Ten defects across the two modules, all caught: redaction covers instead of removes 16 fail CTM ignored 1 fail Q does not restore the CTM 1 fail operands kept when operator removed 12 fail revision warning always false 1 fail signature guard removed 1 fail reachability keeps everything 2 fail dropped reference left dangling 1 fail unresolvable object kept as reachable 1 fail writer dictionary order unsorted 1 fail short-/Length fix reverted 1 fail /Length not rewritten on compaction 1 fail One mutation survived and deleted code rather than adding a test: a `continue` skipping `/Length` in the compaction loop was dead, because the `set` after the loop overwrites it either way. Removed rather than left as untested defence with a reassuring comment — the same call ADR 0017 made about the visited-set guard. A second mutation moved a test rather than a fixture: a stale `/Length` can no longer reach `renumber` through a file, because the reader now repairs it first, so that branch is tested directly instead. Engine suite 1108 -> 1158. External readers still pass. Phase 5 remaining: outline, page label and struct-tree editing.
315 lines
11 KiB
Rust
315 lines
11 KiB
Rust
//! Redaction, verified by trying to extract what was redacted.
|
|
//!
|
|
//! The unit tests in `redact.rs` check the operator removal. These check
|
|
//! the only thing that actually matters: that the text **cannot be read
|
|
//! back out of the saved file**.
|
|
//!
|
|
//! That is the whole point of the module. The famous redaction failure is
|
|
//! drawing a black rectangle over text and shipping it — the text is still
|
|
//! in the file, and `pdftotext` prints it. So every test here saves a real
|
|
//! document, re-parses it, and searches the resulting content for the
|
|
//! secret. A test that only checked "an operator was removed" would pass
|
|
//! against an implementation that removed the wrong one.
|
|
|
|
use nigig_pdf_document::redact::{redact_page, RedactionError, RedactionRect};
|
|
use nigig_pdf_document::PdfDocument;
|
|
|
|
/// A one-page document with text at known positions.
|
|
///
|
|
/// Each run is placed with an absolute `Tm`, so a test can target one by
|
|
/// coordinate without depending on how the previous run advanced.
|
|
fn document_with_text(runs: &[(f64, f64, &str)]) -> Vec<u8> {
|
|
let mut content = String::from("BT /F1 12 Tf\n");
|
|
for (x, y, text) in runs {
|
|
content.push_str(&format!("1 0 0 1 {x} {y} Tm ({text}) Tj\n"));
|
|
}
|
|
content.push_str("ET\n");
|
|
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
|
|
offsets.push(out.len());
|
|
out.push_str(s);
|
|
};
|
|
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
|
|
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
&format!(
|
|
"4 0 obj\n<< /Length {} >>\nstream\n{content}endstream\nendobj\n",
|
|
content.len()
|
|
),
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
|
|
);
|
|
|
|
let startxref = out.len();
|
|
out.push_str("xref\n0 6\n0000000000 65535 f \n");
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
out.into_bytes()
|
|
}
|
|
|
|
fn parse(bytes: &[u8]) -> PdfDocument<'_> {
|
|
PdfDocument::parse(bytes).expect("parses")
|
|
}
|
|
|
|
/// The page's content as a reader would decode it.
|
|
fn page_text(bytes: &[u8]) -> String {
|
|
let mut doc = parse(bytes);
|
|
let page = doc.page(0).expect("page 0");
|
|
String::from_utf8_lossy(&page.content_data).to_string()
|
|
}
|
|
|
|
/// Search the **whole file** for a string, not just the current page.
|
|
///
|
|
/// This is the stronger check: a redaction that rewrote the page but left
|
|
/// the original content stream reachable elsewhere in the file has not
|
|
/// redacted anything.
|
|
fn file_contains(bytes: &[u8], needle: &str) -> bool {
|
|
bytes.windows(needle.len()).any(|w| w == needle.as_bytes())
|
|
}
|
|
|
|
#[test]
|
|
fn the_fixture_contains_its_text() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET"), (100.0, 100.0, "PUBLIC")]);
|
|
let text = page_text(&bytes);
|
|
assert!(text.contains("SECRET") && text.contains("PUBLIC"));
|
|
}
|
|
|
|
/// The headline property: after redaction the secret is not in the page.
|
|
#[test]
|
|
fn redacted_text_is_gone_from_the_page_content() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET"), (100.0, 100.0, "PUBLIC")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(50.0, 650.0, 400.0, 750.0);
|
|
let (saved, report) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
assert_eq!(report.text_runs_removed, 1);
|
|
assert_eq!(report.removed_text, vec!["SECRET".to_string()]);
|
|
|
|
let text = page_text(&saved);
|
|
assert!(
|
|
!text.contains("SECRET"),
|
|
"the redacted text is still in the page: {text}"
|
|
);
|
|
assert!(text.contains("PUBLIC"), "unredacted text was lost: {text}");
|
|
}
|
|
|
|
/// Redaction must be irreversible *within the current revision*.
|
|
///
|
|
/// The saved file still holds the original revision — an incremental save
|
|
/// appends — and the report says so. This test asserts the honest version
|
|
/// of the claim rather than a false one: the *new* content stream does not
|
|
/// contain the secret, and the caller has been told the old bytes remain.
|
|
#[test]
|
|
fn the_report_admits_that_earlier_revisions_retain_the_original() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(50.0, 650.0, 400.0, 750.0);
|
|
let (saved, report) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
// A single-revision source becomes two-revision after an incremental
|
|
// save, and the original text is still findable in the file.
|
|
assert!(
|
|
file_contains(&saved, "SECRET"),
|
|
"the test's own premise is wrong: an incremental save should keep \
|
|
the original bytes"
|
|
);
|
|
// The *current* page no longer shows it, which is what a reader sees.
|
|
assert!(!page_text(&saved).contains("SECRET"));
|
|
|
|
// And a second redaction pass on the already-incremental file must
|
|
// report that earlier revisions retain content.
|
|
let mut doc2 = parse(&saved);
|
|
let (_, report2) = redact_page(
|
|
&mut doc2,
|
|
&saved,
|
|
0,
|
|
&[RedactionRect::new(0.0, 0.0, 600.0, 800.0)],
|
|
)
|
|
.unwrap_or_else(|e| panic!("second pass: {e}"));
|
|
assert!(
|
|
report2.earlier_revisions_retain_content,
|
|
"a multi-revision file must warn that the original bytes remain"
|
|
);
|
|
let _ = report;
|
|
}
|
|
|
|
#[test]
|
|
fn text_outside_every_rectangle_survives() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "ALPHA"), (100.0, 400.0, "BETA")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(0.0, 0.0, 50.0, 50.0);
|
|
let (saved, report) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("no-op");
|
|
|
|
assert!(!report.removed_anything());
|
|
assert_eq!(saved, bytes, "a no-op redaction must not rewrite the file");
|
|
let text = page_text(&saved);
|
|
assert!(text.contains("ALPHA") && text.contains("BETA"));
|
|
}
|
|
|
|
#[test]
|
|
fn several_rectangles_all_apply() {
|
|
let bytes = document_with_text(&[
|
|
(100.0, 700.0, "ONE"),
|
|
(100.0, 400.0, "TWO"),
|
|
(100.0, 100.0, "THREE"),
|
|
]);
|
|
let mut doc = parse(&bytes);
|
|
let rects = [
|
|
RedactionRect::new(50.0, 650.0, 400.0, 750.0),
|
|
RedactionRect::new(50.0, 50.0, 400.0, 150.0),
|
|
];
|
|
let (saved, report) = redact_page(&mut doc, &bytes, 0, &rects).expect("redacts");
|
|
|
|
assert_eq!(report.text_runs_removed, 2);
|
|
let text = page_text(&saved);
|
|
assert!(!text.contains("ONE"));
|
|
assert!(
|
|
text.contains("TWO"),
|
|
"the middle run should survive: {text}"
|
|
);
|
|
assert!(!text.contains("THREE"));
|
|
}
|
|
|
|
/// A redacted page must still be a valid page a reader can open.
|
|
#[test]
|
|
fn the_redacted_page_still_parses_and_keeps_its_size() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(50.0, 650.0, 400.0, 750.0);
|
|
let (saved, _) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
assert_eq!(saved_doc.page_count(), 1);
|
|
let page = saved_doc.page(0).expect("the redacted page must parse");
|
|
assert_eq!(page.media_box[2], 600.0, "the page size changed");
|
|
assert!(
|
|
page.fonts.contains_key("F1"),
|
|
"the page's font resource was lost"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_rectangle_is_refused() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET")]);
|
|
let mut doc = parse(&bytes);
|
|
let empty = RedactionRect::new(10.0, 10.0, 10.0, 50.0);
|
|
assert_eq!(
|
|
redact_page(&mut doc, &bytes, 0, &[empty]).unwrap_err(),
|
|
RedactionError::NoRectangles
|
|
);
|
|
assert_eq!(
|
|
redact_page(&mut doc, &bytes, 0, &[]).unwrap_err(),
|
|
RedactionError::NoRectangles
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redacting_a_missing_page_is_refused() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(0.0, 0.0, 600.0, 800.0);
|
|
assert_eq!(
|
|
redact_page(&mut doc, &bytes, 9, &[rect]).unwrap_err(),
|
|
RedactionError::NoSuchPage(9)
|
|
);
|
|
}
|
|
|
|
/// Redacting everything must leave a page that still opens — empty, but
|
|
/// valid.
|
|
#[test]
|
|
fn redacting_the_whole_page_leaves_a_valid_empty_page() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "ONE"), (100.0, 100.0, "TWO")]);
|
|
let mut doc = parse(&bytes);
|
|
let everything = RedactionRect::new(0.0, 0.0, 600.0, 800.0);
|
|
let (saved, report) = redact_page(&mut doc, &bytes, 0, &[everything]).expect("redacts");
|
|
|
|
assert_eq!(report.text_runs_removed, 2);
|
|
let text = page_text(&saved);
|
|
assert!(!text.contains("ONE") && !text.contains("TWO"));
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
assert_eq!(saved_doc.page_count(), 1);
|
|
assert!(
|
|
saved_doc.page(0).is_ok(),
|
|
"the emptied page must still parse"
|
|
);
|
|
}
|
|
|
|
/// The surviving stream must not have an orphaned operand where an
|
|
/// operator was removed. A dangling `(SECRET)` would both leak the text
|
|
/// and corrupt the next operator.
|
|
#[test]
|
|
fn no_orphaned_operand_is_left_behind() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET"), (100.0, 100.0, "PUBLIC")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(50.0, 650.0, 400.0, 750.0);
|
|
let (saved, _) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
let text = page_text(&saved);
|
|
assert!(!text.contains("SECRET"));
|
|
// The surviving run's own parentheses are expected; the removed one's
|
|
// must be gone. Count them: one run left means one pair.
|
|
assert_eq!(
|
|
text.matches('(').count(),
|
|
1,
|
|
"an operand was orphaned by the removal: {text}"
|
|
);
|
|
assert_eq!(text.matches("Tj").count(), 1, "operator count: {text}");
|
|
}
|
|
|
|
/// Redaction is idempotent: running it again removes nothing more.
|
|
#[test]
|
|
fn redacting_twice_removes_nothing_the_second_time() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET"), (100.0, 100.0, "PUBLIC")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(50.0, 650.0, 400.0, 750.0);
|
|
let (once, _) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
let mut doc2 = parse(&once);
|
|
let (twice, report) = redact_page(&mut doc2, &once, 0, &[rect]).expect("second pass");
|
|
assert!(
|
|
!report.removed_anything(),
|
|
"the second pass found something to remove: {:?}",
|
|
report.removed_text
|
|
);
|
|
assert_eq!(twice, once, "a no-op second pass must not rewrite the file");
|
|
}
|
|
|
|
/// The original revision must be byte-identical: an incremental save
|
|
/// appends.
|
|
#[test]
|
|
fn the_original_revision_is_appended_to_not_rewritten() {
|
|
let bytes = document_with_text(&[(100.0, 700.0, "SECRET")]);
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(50.0, 650.0, 400.0, 750.0);
|
|
let (saved, _) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
assert!(saved.len() > bytes.len());
|
|
assert_eq!(&saved[..bytes.len()], &bytes[..]);
|
|
}
|