//! 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 { 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, 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[..]); }