nigig-org/crates/apps/pdf/pdf-document/tests/revisions.rs
andodeki dc2bf234c6
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
test(pdf): harden the xref revision chain
Incremental save made /Prev chain walking load-bearing for every document,
not only saved ones: XRefTable::parse now follows offsets taken straight from
the file on every parse. Phase 6 established that code consuming untrusted
input needs corpus and fuzz coverage. That path had neither, so this adds it
and fixes what it found.

Corpus (7 new fixtures, generated by the checked-in script as usual):

- revisions/two.pdf, three.pdf: chained revisions that override a form
  value. These are direct regression tests for the two bugs the previous
  commit fixed. Before it, two.pdf read back as "first" rather than
  "second", because find_xref_start never matched its own keyword and fell
  through to the oldest section in the file.
- revisions/added_page.pdf: a revision that rewrites /Pages, so the newer
  definition must win for structure as well as for values.
- malformed/prev_loop.pdf, prev_out_of_range.pdf, prev_negative.pdf and
  prev_chain_bomb.pdf: the hostile shapes.

Two robustness defects found by those fixtures:

- A broken /Prev orphaned every object the unreachable sections defined,
  even though the bytes were still in the file, so a document with one bad
  offset failed to open at all. The chain now sets a recovered flag and
  sweeps the file for object headers, filling only genuine gaps: entries a
  parsed section supplied always win, because those reflect the document's
  own view of which revision is current, and scanning cannot tell newer
  from older.
- A negative /Prev was filtered to None, which silently ended the chain as
  though the file had no history. It is now treated as a broken link and
  triggers the same recovery.

Also caps the chain at 64 revisions. A legitimate document has a handful; a
file with thousands is an attack, not a history. prev_chain_bomb.pdf asserts
the cap holds and that parsing stays fast.

The recovered flag is public so a caller can distinguish a cleanly parsed
document from a salvaged one rather than being handed a guess silently. A
test asserts it stays false for healthy files, or it would mean nothing.

Fuzzing: adds parse_revision_chain, which splices fuzzer input onto a valid
base document so the fuzzer spends its time on chain shapes rather than on
rediscovering PDF syntax. Run for real, not merely compile-checked:

  parse_revision_chain  1,926,164 runs
  parse_xref            1,387,713 runs
  parse_document        1,279,328 runs

No crashes. The two re-run targets cover the file this commit changes.

One fixture-generator bug fixed on the way: the helper that reads a file's
startxref took the first token after rfind without skipping the keyword,
producing a startxref that pointed at its own text.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (318 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (362 tests, 6 ignored)
Both rustfmt and clippy -D warnings clean.
2026-07-28 16:58:16 +00:00

190 lines
6.9 KiB
Rust

//! 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<u8> {
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<String> {
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();
}
}