//! 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 { 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 { 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![ 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); } }