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.
250 lines
7.8 KiB
Rust
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);
|
|
}
|
|
}
|