//! Cross-reference chain regression tests. //! //! Incremental save (Phase 8) made `/Prev` chain walking load-bearing for //! every document, not just saved ones. Phase 6 established that code //! consuming untrusted input needs corpus coverage; this is that coverage //! for the chain. //! //! Two of the bugs these guard against were real and shipped: //! `find_xref_start` never matched its own keyword, so multi-revision files //! were read at their *oldest* revision; and `/Prev` was ignored entirely, //! so earlier revisions' objects vanished. use std::path::PathBuf; use nigig_pdf_cos::XRefTable; use nigig_pdf_document::form::FieldValue; use nigig_pdf_document::PdfDocument; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// Read a form field's text value from a parsed document. fn field_text(data: &[u8], name: &str) -> Option { let mut doc = PdfDocument::parse(data).ok()?; let form = doc.acroform().ok()??; form.find_field(name) .and_then(|f| f.value.as_text().map(str::to_string)) } /// The newest revision must win. This is the exact bug that shipped: the /// parser returned the oldest value instead. #[test] fn a_two_revision_document_reports_the_newer_value() { let data = corpus("revisions/two.pdf"); assert_eq!( field_text(&data, "name").as_deref(), Some("second"), "the appended revision must override the original" ); } #[test] fn a_three_revision_chain_reports_the_newest_value() { let data = corpus("revisions/three.pdf"); assert_eq!(field_text(&data, "name").as_deref(), Some("v3")); } /// Objects only the *base* revision defines must still resolve through the /// chain, which is what ignoring `/Prev` broke. #[test] fn objects_from_earlier_revisions_are_still_reachable() { let data = corpus("revisions/two.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); // The page tree, contents and AcroForm all live in the base revision; // only the field object was rewritten. assert_eq!(doc.page_count(), 1); let page = doc.page(0).expect("page 0"); assert!( !page.content_data.is_empty(), "content stream from the base revision must resolve" ); assert!(doc.acroform().expect("acroform").is_some()); } #[test] fn a_revision_can_add_a_page() { // The revision rewrites /Pages with a new /Kids and /Count, so this // fails if the newer definition does not win. let data = corpus("revisions/added_page.pdf"); let mut doc = PdfDocument::parse(&data).expect("parses"); assert_eq!(doc.page_count(), 2); for index in 0..2 { assert!( !doc.page(index).expect("page").content_data.is_empty(), "page {index} must resolve" ); } } #[test] fn a_cleanly_chained_document_is_not_flagged_as_recovered() { // The recovered flag must mean something: if it were set for healthy // files, a caller could not use it to distinguish a salvaged document. let table = XRefTable::parse(&corpus("revisions/three.pdf")).expect("parses"); assert!( !table.recovered, "a well-formed chain must not report recovery" ); } // ------------------------------------------------------------- malformed /// A `/Prev` pointing past the end of the file orphans every object the /// unreachable sections defined. Those bytes are still in the file, so the /// document is recovered by scanning rather than lost. #[test] fn a_prev_pointing_out_of_range_recovers_by_scanning() { let data = corpus("malformed/prev_out_of_range.pdf"); let table = XRefTable::parse(&data).expect("the newest section still parses"); assert!(table.recovered, "a broken link must be reported"); let mut doc = PdfDocument::parse(&data).expect("document is recovered"); assert_eq!(doc.page_count(), 1, "the page tree is found by scanning"); assert!(!doc.page(0).expect("page 0").content_data.is_empty()); } /// A negative `/Prev` is a broken link, not an absent one. Treating it as /// absent silently ended the chain as though the file had no history. #[test] fn a_negative_prev_recovers_rather_than_silently_truncating() { let data = corpus("malformed/prev_negative.pdf"); let table = XRefTable::parse(&data).expect("parses"); assert!(table.recovered, "a negative /Prev must be reported broken"); let doc = PdfDocument::parse(&data).expect("document is recovered"); assert_eq!(doc.page_count(), 1); } /// A `/Prev` cycle must terminate. Without a visited set this hangs. #[test] fn a_prev_loop_terminates() { let data = corpus("malformed/prev_loop.pdf"); // Whether it parses depends on where the loop corrupted the file; the // assertion is that it returns at all rather than spinning. let _ = XRefTable::parse(&data); let _ = PdfDocument::parse(&data); } /// A file with hundreds of revisions must not make the parser walk them all. #[test] fn a_long_revision_chain_is_bounded() { let data = corpus("malformed/prev_chain_bomb.pdf"); let start = std::time::Instant::now(); let table = XRefTable::parse(&data).expect("parses"); let elapsed = start.elapsed(); assert!( table.recovered, "a chain longer than the revision cap must be reported" ); // Generous: the point is to catch unbounded walking, not to benchmark. assert!( elapsed.as_millis() < 500, "parsing took {}ms; the chain should be capped", elapsed.as_millis() ); let doc = PdfDocument::parse(&data).expect("still usable"); assert_eq!(doc.page_count(), 1); } /// Recovery must not resurrect a superseded value. #[test] fn recovery_never_overrides_a_value_the_chain_provided() { // three.pdf chains cleanly, so nothing is recovered and the newest // value stands. If scanning ever ran here it would find all three // definitions of object 5 and could pick the wrong one. let data = corpus("revisions/three.pdf"); assert_eq!(field_text(&data, "name").as_deref(), Some("v3")); let mut doc = PdfDocument::parse(&data).expect("parses"); let form = doc.acroform().expect("acroform").expect("present"); assert_eq!( form.find_field("name").expect("field").value, FieldValue::Text("v3".into()) ); } /// Every revision fixture survives the normal document walk without panic. #[test] fn revision_fixtures_walk_cleanly() { for fixture in [ "revisions/two.pdf", "revisions/three.pdf", "revisions/added_page.pdf", ] { let data = corpus(fixture); let mut doc = PdfDocument::parse(&data).unwrap_or_else(|e| panic!("{fixture} should parse: {e}")); for index in 0..doc.page_count() { let _ = doc.page(index); let _ = doc.page_annotations(index); } let _ = doc.acroform(); } }