//! Compaction, verified on the saved file. //! //! Two claims matter and both are checked here rather than inferred: //! //! 1. **The document still works.** Compaction rewrites every object with //! a new number; if the renumbering is wrong the file still parses and //! points at the wrong things. //! 2. **The discarded revisions are really gone.** This is the claim that //! makes compaction the finishing step for a redaction, and the only //! honest way to test it is to search the output bytes for the secret. use nigig_pdf_document::compact::{compact, CompactError, CompactOptions}; use nigig_pdf_document::redact::{redact_page, RedactionRect}; use nigig_pdf_document::PdfDocument; /// A document with `dead` unreferenced objects appended, so compaction has /// something to drop. fn document_with_dead_objects(dead: usize, text: &str) -> Vec { let content = format!("BT /F1 12 Tf 1 0 0 1 100 700 Tm ({text}) Tj ET"); 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}\nendstream\nendobj\n", content.len() ), ); push( &mut out, &mut offsets, "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", ); for i in 0..dead { let num = 6 + i; push( &mut out, &mut offsets, &format!( "{num} 0 obj\n<< /Type /Unreferenced /Index {i} \ /Payload (DEADOBJECT{i}) >>\nendobj\n" ), ); } let startxref = out.len(); let total = 6 + dead; out.push_str(&format!("xref\n0 {total}\n0000000000 65535 f \n")); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); out.into_bytes() } fn parse(bytes: &[u8]) -> PdfDocument<'_> { PdfDocument::parse(bytes).expect("parses") } 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() } fn file_contains(bytes: &[u8], needle: &str) -> bool { bytes.windows(needle.len()).any(|w| w == needle.as_bytes()) } #[test] fn the_fixture_contains_its_dead_objects() { let bytes = document_with_dead_objects(3, "KEEP"); assert!(file_contains(&bytes, "DEADOBJECT0")); assert!(file_contains(&bytes, "DEADOBJECT2")); assert!(page_text(&bytes).contains("KEEP")); } /// Unreferenced objects are dropped and the document still works. #[test] fn compaction_drops_unreachable_objects_and_keeps_the_document_readable() { let bytes = document_with_dead_objects(4, "KEEP"); let mut doc = parse(&bytes); let (saved, report) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts"); assert!( report.objects_dropped >= 4, "the four dead objects should have been dropped, report: {report:?}" ); for i in 0..4 { assert!( !file_contains(&saved, &format!("DEADOBJECT{i}")), "dead object {i} survived compaction" ); } // And the live document is intact. let mut saved_doc = parse(&saved); assert_eq!(saved_doc.page_count(), 1); let page = saved_doc.page(0).expect("the page must still parse"); assert_eq!(page.media_box[2], 600.0, "the page size changed"); assert!( page.fonts.contains_key("F1"), "the font resource was lost: {:?}", page.fonts.keys().collect::>() ); assert!(page_text(&saved).contains("KEEP"), "the content was lost"); } /// Renumbering must be consistent: every surviving reference must resolve /// to the object it named before. /// /// This is the assertion that catches a renumbering that is internally /// tidy and points everything at the wrong objects — the file parses, the /// page count is right, and the fonts are somebody else's. #[test] fn every_reference_still_resolves_after_renumbering() { let bytes = document_with_dead_objects(2, "KEEP"); let mut doc = parse(&bytes); let (saved, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts"); let mut saved_doc = parse(&saved); let page_ref = saved_doc.page_object_ref(0).expect("page ref"); let page = saved_doc.resolve_ref(page_ref).expect("page resolves"); let dict = page.as_dict().cloned().expect("page is a dict"); // /Parent must reach a /Pages node, not whatever object took its number. let parent = dict .get("Parent") .and_then(|o| o.as_ref().copied()) .expect("the page must have a /Parent"); let parent_obj = saved_doc.resolve_ref(parent).expect("parent resolves"); assert_eq!( parent_obj.as_dict().and_then(|d| d.get_name("Type")), Some("Pages"), "/Parent points at the wrong object after renumbering" ); // /Contents must reach a stream. let contents = dict .get("Contents") .and_then(|o| o.as_ref().copied()) .expect("the page must have /Contents"); let stream = saved_doc.resolve_ref(contents).expect("contents resolve"); assert!( matches!(stream, nigig_pdf_cos::object::PdfObj::Stream(_)), "/Contents points at a {stream:?} after renumbering" ); } /// **The claim that matters for redaction.** Compacting a redacted file /// removes the original content from the bytes entirely. /// /// An incremental redaction leaves the original text in the earlier /// revision — `redact.rs` says so in its report rather than pretending /// otherwise. Compaction is what actually finishes the job, because the /// output is built from the object graph rather than copied from the /// input. #[test] fn compacting_a_redacted_file_removes_the_original_text_from_the_bytes() { let bytes = document_with_dead_objects(0, "TOPSECRET"); assert!(file_contains(&bytes, "TOPSECRET")); let mut doc = parse(&bytes); let rect = RedactionRect::new(0.0, 600.0, 600.0, 800.0); let (redacted, report) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts"); assert_eq!(report.text_runs_removed, 1); // Incremental: the original bytes are still there. This is the honest // intermediate state. assert!( file_contains(&redacted, "TOPSECRET"), "an incremental redaction should still contain the original bytes" ); let mut redacted_doc = parse(&redacted); let (compacted, _) = compact(&mut redacted_doc, &redacted, CompactOptions::default()).expect("compacts"); assert!( !file_contains(&compacted, "TOPSECRET"), "the redacted text survived compaction — the redaction is not real" ); // And the document still opens. let mut final_doc = parse(&compacted); assert_eq!(final_doc.page_count(), 1); assert!(final_doc.page(0).is_ok()); } /// Compaction collapses every revision into one. #[test] fn compaction_produces_a_single_revision_file() { let bytes = document_with_dead_objects(0, "KEEP"); let mut doc = parse(&bytes); let rect = RedactionRect::new(0.0, 600.0, 600.0, 800.0); let (redacted, _) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts"); let revisions = |b: &[u8]| b.windows(9).filter(|w| *w == b"startxref").count(); assert_eq!(revisions(&redacted), 2, "the redaction appended a revision"); let mut redacted_doc = parse(&redacted); let (compacted, report) = compact(&mut redacted_doc, &redacted, CompactOptions::default()).expect("compacts"); assert_eq!( revisions(&compacted), 1, "compaction must leave exactly one revision" ); assert_eq!(report.revisions_discarded, 1); } /// A signed document is refused by default. /// /// A signature covers a byte range in a revision compaction destroys, so /// proceeding silently would leave every signature unverifiable with no /// warning at all. #[test] fn a_signed_document_is_refused_unless_explicitly_allowed() { // A minimal document carrying a signature field with a /ByteRange. 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 /AcroForm << /Fields [5 0 R] \ /SigFlags 3 >> >>\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 /Annots [5 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n", ); push( &mut out, &mut offsets, "5 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig1) \ /Rect [0 0 100 50] /V 6 0 R >>\nendobj\n", ); push( &mut out, &mut offsets, "6 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached \ /ByteRange [0 100 200 300] /Contents <00> >>\nendobj\n", ); let startxref = out.len(); out.push_str("xref\n0 7\n0000000000 65535 f \n"); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); let bytes = out.into_bytes(); let mut doc = parse(&bytes); let signature_count = doc.signatures().map(|r| r.signatures.len()).unwrap_or(0); assert!( signature_count > 0, "the fixture must actually carry a signature for this test to mean anything" ); drop(doc); let mut doc = parse(&bytes); match compact(&mut doc, &bytes, CompactOptions::default()) { Err(CompactError::WouldBreakSignatures { count }) => { assert_eq!(count, signature_count); } Err(other) => panic!("wrong error: {other}"), Ok(_) => panic!("compaction silently invalidated a signature"), } // With the flag, it proceeds. let mut doc = parse(&bytes); let allowed = CompactOptions { allow_signed: true, ..Default::default() }; assert!( compact(&mut doc, &bytes, allowed).is_ok(), "an explicit allow_signed must be honoured" ); } /// Dropping `/Info` removes the metadata, which is often wanted when /// finishing a redaction. #[test] fn info_can_be_dropped() { let bytes = document_with_dead_objects(0, "KEEP"); let mut doc = parse(&bytes); let options = CompactOptions { keep_info: false, ..Default::default() }; let (saved, _) = compact(&mut doc, &bytes, options).expect("compacts"); let saved_doc = parse(&saved); assert!( saved_doc.trailer().get("Info").is_none(), "/Info survived a keep_info: false compaction" ); } /// Compacting an already-compact document must be stable: it does not /// keep shrinking or keep renumbering. #[test] fn compacting_twice_is_stable() { let bytes = document_with_dead_objects(3, "KEEP"); let mut doc = parse(&bytes); let (once, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("first"); let mut doc2 = parse(&once); let (twice, report) = compact(&mut doc2, &once, CompactOptions::default()).expect("second"); assert_eq!( report.objects_dropped, 0, "a compact file has nothing left to drop" ); assert_eq!(twice, once, "compaction is not idempotent"); } /// The report's arithmetic must match the file. #[test] fn the_report_describes_what_happened() { let bytes = document_with_dead_objects(5, "KEEP"); let mut doc = parse(&bytes); let (saved, report) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts"); assert_eq!(report.bytes_before, bytes.len()); assert_eq!(report.bytes_after, saved.len()); assert!( report.objects_kept >= 5, "catalog, pages, page, contents and font must all be kept" ); assert!( report.bytes_saved() > 0, "dropping five objects should shrink the file: {} -> {}", report.bytes_before, report.bytes_after ); } /// A reference to a dropped object must become null, not stay dangling. /// /// A dangling reference is legal — readers treat it as null — but it says /// nothing, and a later tool that renumbers again may bind it to an /// unrelated object. The fixture points the page at an object that is /// deliberately never written, which is what a partly-repaired file looks /// like. #[test] fn a_reference_to_a_missing_object_is_written_as_null() { 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", ); // /Missing points at object 99, which does not exist. push( &mut out, &mut offsets, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \ /Contents 4 0 R /Missing 99 0 R >>\nendobj\n", ); push( &mut out, &mut offsets, "4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n", ); let startxref = out.len(); out.push_str("xref\n0 5\n0000000000 65535 f \n"); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); let bytes = out.into_bytes(); let mut doc = parse(&bytes); let (saved, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts"); let mut saved_doc = parse(&saved); let page_ref = saved_doc.page_object_ref(0).expect("page ref"); let page = saved_doc.resolve_ref(page_ref).expect("page"); let missing = page .as_dict() .and_then(|d| d.get("Missing").cloned()) .expect("/Missing must still be present as a key"); assert_eq!( missing, nigig_pdf_cos::object::PdfObj::Null, "a reference to a dropped object must be written as null, got {missing:?}" ); } /// A stale `/Length` in the source must be recomputed, not copied. /// /// A `/Length` larger than the data runs the reader past the end of the /// stream; smaller and it truncates the content. Either way the file /// parses and the page is wrong. #[test] fn a_stale_stream_length_is_recomputed() { let content = "BT /F1 12 Tf 1 0 0 1 100 700 Tm (KEEP) Tj ET"; 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", ); // /Length deliberately wrong: shorter than the real content. push( &mut out, &mut offsets, &format!("4 0 obj\n<< /Length 5 >>\nstream\n{content}\nendstream\nendobj\n"), ); 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" )); let bytes = out.into_bytes(); let mut doc = parse(&bytes); let (saved, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts"); let mut saved_doc = parse(&saved); let page_ref = saved_doc.page_object_ref(0).expect("page ref"); let page = saved_doc.resolve_ref(page_ref).expect("page"); let contents = page .as_dict() .and_then(|d| d.get("Contents")) .and_then(|o| o.as_ref().copied()) .expect("/Contents"); let stream = saved_doc.resolve_ref(contents).expect("contents resolve"); match stream { nigig_pdf_cos::object::PdfObj::Stream(s) => { let declared = s.dict.get_int("Length").expect("/Length must be written"); assert_eq!( declared as usize, s.data.len(), "the written /Length does not match the data it describes" ); assert!( declared > 5, "the stale /Length of 5 was copied instead of recomputed" ); } other => panic!("/Contents is a {other:?}"), } }