nigig-org/crates/apps/pdf/pdf-document/tests/robustness.rs
andodeki b23df6a5cb
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
Payment domain and storage / isolated-payment-tests (push) Has been cancelled
feat(pdf): complete Phase 6 testing infrastructure
Phase 6 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md: "Real PDFs. Real
regressions. No test theater."

Step 6.1, corpus. 27 fixtures across basic, fonts, forms, annotations,
images, edge and malformed, in the layout the review specifies. They are
produced by tests/corpus/generate.py rather than committed as opaque blobs,
because a corpus you cannot read is a corpus you cannot trust; CI regenerates
them and fails if they differ. Hand-rolled rather than library-produced,
since fixtures for a parser must contain constructs a library refuses to
emit.

Step 6.2, corpus tests (pdf-document/tests/corpus.rs, 27 tests). Text,
vectors, Flate, multipage, CID fonts, every form field type, inherited field
keys, link actions, hidden annotations, XObjects, inline images with
embedded EI bytes, rotation, crop boxes, nested CTMs and content arrays.

Step 6.3, robustness (pdf-document/tests/robustness.rs, 3 tests) plus five
cargo-fuzz targets. cargo-fuzz needs nightly and libFuzzer so it cannot gate
a stable CI run; the harness covers the same ground deterministically by
mutating the real corpus with a fixed-seed PRNG, so a failure is reproducible
from the seed rather than only from a saved artefact. The fuzz targets remain
the deeper coverage-guided search and run on a schedule.

Three crashes on untrusted input, all found by this work and all previously
reachable from a malformed file:

- collect_pages_ref recursed forever on a /Kids cycle. Stack overflow aborts
  the process; it cannot be caught. Now tracks visited nodes and bounds depth.
- PdfDocument::resolve and the COS lexer recursed once per nesting level, so
  a file of 5000 open brackets overflowed the stack. Both are now bounded.
- decode_85_group multiplied an accumulator that a malformed group can
  overflow, and subtracted below zero on a digit outside the valid range.
  Both panic in a debug build. Now saturating.

Step 6.4, CI (.forgejo/workflows/pdf.yml). An engine job that runs the
corpus and robustness suites under rustfmt and clippy -D warnings; a separate
makepad-integration job so a missing system library is not reported as a PDF
regression; and a scheduled fuzz job. The engine job also enforces the two
architectural rules mechanically rather than in prose: no Makepad dependency
or import in the engine crates, and no process or URL launching anywhere in
them.

Also fixes .gitignore: the blanket *.pdf rule silently excluded all 27
fixtures, which would have left CI unable to run them on a fresh clone.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (233 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (268 tests)
Both rustfmt and clippy -D warnings clean; all five fuzz targets compile.
2026-07-27 17:08:23 +00:00

250 lines
7.8 KiB
Rust

//! Deterministic robustness sweep: the stable-toolchain counterpart to
//! `cargo fuzz`.
//!
//! Phase 6 step 6.3 asks for zero panics on arbitrary input. `cargo-fuzz`
//! needs a nightly toolchain and libFuzzer, so it cannot gate a stable CI
//! run. This harness covers the same ground deterministically: it mutates
//! the real corpus with a fixed-seed PRNG, so a failure is reproducible from
//! the seed printed in the output rather than only from a saved artefact.
//!
//! The fuzz targets in `pdf-cos/fuzz/` remain the deeper, coverage-guided
//! search; this is the gate that runs on every push.
use std::path::PathBuf;
use nigig_pdf_cos::{decode_stream, Lexer, PdfDict, PdfObj, PdfStream, XRefTable};
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::content::parse_content_stream;
use nigig_pdf_graphics::recording::RecordingDevice;
/// xorshift64*: tiny, deterministic, and good enough to shuffle bytes.
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
// A zero state would stay zero forever.
Self(seed | 1)
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn below(&mut self, n: usize) -> usize {
if n == 0 {
return 0;
}
(self.next() % n as u64) as usize
}
}
fn corpus_files() -> Vec<PathBuf> {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus");
let mut found = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "pdf") {
found.push(path);
}
}
}
found.sort();
found
}
/// Apply one of several mutation strategies, chosen by the PRNG.
fn mutate(seed: &[u8], rng: &mut Rng) -> Vec<u8> {
let mut out = seed.to_vec();
if out.is_empty() {
return out;
}
match rng.below(6) {
// Flip a byte.
0 => {
let at = rng.below(out.len());
out[at] ^= (rng.next() & 0xFF) as u8;
}
// Truncate: the single most productive PDF mutation.
1 => {
let keep = rng.below(out.len());
out.truncate(keep);
}
// Splice out a run.
2 => {
let start = rng.below(out.len());
let end = (start + 1 + rng.below(64)).min(out.len());
out.drain(start..end);
}
// Insert junk.
3 => {
let at = rng.below(out.len());
let byte = (rng.next() & 0xFF) as u8;
let count = 1 + rng.below(32);
for _ in 0..count {
out.insert(at, byte);
}
}
// Corrupt a number, which is what drives offsets and lengths.
4 => {
let at = rng.below(out.len());
for i in at..(at + 8).min(out.len()) {
if out[i].is_ascii_digit() {
out[i] = b'0' + (rng.next() % 10) as u8;
}
}
}
// Overwrite a structural keyword.
_ => {
for needle in [&b"obj"[..], b"endstream", b"xref", b"trailer"] {
if let Some(pos) = out.windows(needle.len()).position(|w| w == needle) {
let replacement = b"ZZZZZZZZZ";
for (i, b) in needle.iter().enumerate() {
let _ = b;
out[pos + i] = replacement[i % replacement.len()];
}
break;
}
}
}
}
out
}
/// Exercise every entry point that consumes untrusted bytes.
///
/// Nothing here asserts an outcome: the assertion is that the process
/// survives. A panic fails the test by aborting it.
fn exercise(data: &[u8]) {
let mut lexer = Lexer::new(data, 0);
for _ in 0..8 {
if lexer.read_object().is_err() {
break;
}
}
let _ = XRefTable::parse(data);
if let Ok(ops) = parse_content_stream(data) {
let _ = RecordingDevice::from_ops(&ops);
}
for filter in [
"FlateDecode",
"LZWDecode",
"ASCII85Decode",
"ASCIIHexDecode",
"RunLengthDecode",
] {
let mut dict = PdfDict::new();
dict.set("Filter", PdfObj::Name(filter.to_string()));
let _ = decode_stream(&PdfStream {
dict,
data: data.to_vec(),
});
}
if let Ok(mut doc) = PdfDocument::parse(data) {
let pages = doc.page_count().min(4);
for index in 0..pages {
if let Ok(page) = doc.page(index) {
let _ = parse_content_stream(&page.content_data);
}
let _ = doc.page_annotations(index);
}
let _ = doc.acroform();
}
}
#[test]
fn mutated_corpus_never_panics() {
let files = corpus_files();
assert!(!files.is_empty(), "corpus must be present");
// Fixed seed: a failure here is reproducible, unlike a random fuzz run.
let mut rng = Rng::new(0x5EED_1234_ABCD_0001);
let iterations_per_file = 200;
for path in &files {
let Ok(seed) = std::fs::read(path) else {
continue;
};
for round in 0..iterations_per_file {
let mutated = mutate(&seed, &mut rng);
// Printed so a crash names the exact input that produced it.
if std::env::var("VERBOSE_ROBUSTNESS").is_ok() {
println!("{} round {round}", path.display());
}
exercise(&mutated);
}
}
println!(
"exercised {} mutations across {} corpus files",
files.len() * iterations_per_file,
files.len()
);
}
#[test]
fn structured_edge_cases_never_panic() {
// Inputs that specifically target the recursion and arithmetic the
// corpus found bugs in.
let cases: Vec<Vec<u8>> = vec![
Vec::new(),
b"%PDF-1.7".to_vec(),
b"\x00".repeat(1024),
b"[".repeat(5000),
b"<<".repeat(5000),
b"((((((((((".repeat(500),
b"1 0 obj".to_vec(),
b"trailer << /Root 1 0 R >>".to_vec(),
b"xref\n0 99999999\n".to_vec(),
b"stream\n".to_vec(),
// A length claiming far more than exists.
b"<< /Length 4294967295 >>\nstream\nshort\nendstream".to_vec(),
// Self-referential object number.
b"1 0 obj\n1 0 R\nendobj".to_vec(),
// Enormous numbers, to catch overflow in offset arithmetic.
b"99999999999999999999 0 obj\n<< >>\nendobj".to_vec(),
b"-99999999999999999999".to_vec(),
// Content stream operators with missing operands.
b"m l c re f S W n Tj TJ Do BI ID EI".to_vec(),
b"BT ET Q Q Q Q q q q q".to_vec(),
b"1e400 1e400 m".to_vec(),
// Inline image that never terminates.
b"BI /W 99999 /H 99999 /BPC 8 ID ".to_vec(),
];
for case in &cases {
exercise(case);
}
println!("{} structured edge cases survived", cases.len());
}
#[test]
fn concatenated_corpus_pairs_never_panic() {
// Splicing two files together produces structurally confusing input:
// two headers, two trailers, offsets pointing into the wrong half.
let files = corpus_files();
let mut rng = Rng::new(0xC0FFEE);
for _ in 0..100 {
let a = &files[rng.below(files.len())];
let b = &files[rng.below(files.len())];
let (Ok(mut first), Ok(second)) = (std::fs::read(a), std::fs::read(b)) else {
continue;
};
first.extend_from_slice(&second);
exercise(&first);
}
}