nigig-org/crates/apps/pdf/pdf-document/tests/redact_roundtrip.rs
andodeki 41c43df0e5
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
feat(pdf): redaction and object compaction — and three reader defects
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.
2026-08-17 12:20:25 +00:00

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[..]);
}