//! `cargo xtask fuzz`: a mutation fuzzer that runs on stable Rust (M1.10). //! //! Seeds are small PDFs (synthetic ones, plus pdf.js corpus files under 64 KB when `corpus/` is //! present). Each iteration mutates a seed, writes it to `fuzz-out/work/`, and runs //! `pdfcraft-cli check-one --edit` in a child process with a timeout. That opens, //! inspects, renders and extracts text from every page (the bootstrap renderer), then runs our //! object layer end to end: decode streams, edit, save incrementally and in full, reopen. //! //! A child that dies (panic outside a safety net, abort, stack overflow, signal) is a **crash**, //! and one that runs past the timeout is a **hang**. Each finding is minimized (chunks are //! removed while it still fails the same way) and kept in `fuzz-out/findings/` with a JSON note. //! `fuzz-out/` is git-ignored; findings derived from corpus files must not be committed. Turn //! each real bug into a synthetic regression test instead. //! //! ```text //! cargo xtask fuzz [--time 300] [--iterations N] [--seed 1] [--jobs 8] [--timeout 10] //! ``` //! It exits non-zero when it finds a crash (hangs are reported but, like the corpus sweep's //! known renderer hangs, do not fail the run). use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use anyhow::{Result, bail}; use crate::gates::{release_cli, root}; fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) } /// xorshift64*: small, deterministic, good enough for mutation choices. #[derive(Clone)] struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { self.0 ^= self.0 >> 12; self.0 ^= self.0 << 25; self.0 ^= self.0 >> 27; self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) } fn below(&mut self, n: usize) -> usize { if n == 0 { 0 } else { (self.next() % n as u64) as usize } } } /// Bytes that matter to PDF syntax, and tokens worth splicing in. const SYNTAX: &[u8] = b"0123456789 /<>[]()R\n\r%-.+#\\"; const TOKENS: &[&[u8]] = &[ b" 0 R", b" 99999 0 R", b" -1 ", b" 2147483648 ", b" 1e308 ", b"<<", b">>", b"[", b"]", b"(", b")", b"stream\n", b"\nendstream", b"endobj", b" obj ", b"/Length 0", b"/Length 99999999", b"/Filter /FlateDecode", b"/Filter [/ASCIIHexDecode /LZWDecode]", b"/DecodeParms << /Predictor 15 /Columns 99999 >>", b"/Type /ObjStm /N 99999 /First 0", b"/Type /XRef /W [1 9 1]", b"/Kids [1 0 R]", b"/Parent 1 0 R", b"/Prev 0", b"xref\n0 1\n", b"trailer << /Root 1 0 R >>", b"startxref\n0\n%%EOF", b"/Encrypt << /Filter /Standard /V 5 /R 6 >>", b"/Count -5", b"/MediaBox [0 0 0 0]", b"/Rotate 45", ]; fn mutate(rng: &mut Rng, seed: &[u8], other: &[u8]) -> Vec { let mut d = seed.to_vec(); let rounds = 1 + rng.below(4); for _ in 0..rounds { if d.is_empty() { d.extend_from_slice(b"%PDF-1.7\n"); } let at = rng.below(d.len()); match rng.below(9) { 0 => d[at] ^= 1 << rng.below(8), 1 => d[at] = SYNTAX[rng.below(SYNTAX.len())], 2 => { let n = 1 + rng.below(64.min(d.len() - at)); d.drain(at..at + n); } 3 => { let n = 1 + rng.below(256.min(d.len() - at)); let chunk = d[at..at + n].to_vec(); let to = rng.below(d.len()); d.splice(to..to, chunk); } 4 => { let t = TOKENS[rng.below(TOKENS.len())]; d.splice(at..at, t.iter().copied()); } 5 => d.truncate(at.max(1)), 6 if !other.is_empty() => { let from = rng.below(other.len()); let n = 1 + rng.below(512.min(other.len() - from)); d.splice(at..at, other[from..from + n].iter().copied()); } 7 => { // Replace a run of digits with an extreme number. if let Some(start) = d[at..].iter().position(u8::is_ascii_digit).map(|p| p + at) { let end = d[start..].iter().position(|c| !c.is_ascii_digit()).map_or(d.len(), |p| p + start); let v: &[u8] = [&b"0"[..], b"4294967295", b"9223372036854775807", b"65535", b"1"][rng.below(5)]; d.splice(start..end, v.iter().copied()); } } _ => { // Corrupt compressed data: flip bytes inside a stream body. if let Some(s) = find(&d[at..], b"stream").map(|p| p + at + 7) { for _ in 0..1 + rng.below(8) { if s < d.len() { let i = s + rng.below((d.len() - s).min(512)); d[i] = rng.next() as u8; } } } } } } d } fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } /// A classic-xref PDF whose objects are numbered 1.. in order. ASCII object text only, so the /// byte offsets are the string offsets. fn build(objs: &[&str], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { offs.push(out.len()); out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); } let x = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); for o in offs { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); out } /// An uncompressed stream object body (`Filter`-free, so the seed stays plain text). fn stream(dict: &str, data: &str) -> String { format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) } /// Every feature the engine reads on a page: fonts (Type 1 and Type 3), form fields with /// actions, the annotation kinds, page labels, names, optional content, a structure tree, /// shading, a form XObject, an image with a soft mask, a colour space and a graphics state. fn feature_rich_seed() -> Vec { let content = stream( "", "q /G1 gs BT /F1 18 Tf 10 250 Td (Hello) Tj /T3 12 Tf 0 -20 Td (aaa) Tj ET Q /Sh1 sh \ q 50 0 0 50 100 20 cm /Fm1 Do Q q 40 0 0 40 20 20 cm /Im1 Do Q \ /OC /OC1 BDC 1 0 0 rg 0 0 20 20 re f EMC /CS1 cs 1 sc 150 10 30 30 re f", ); let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 50 50]", "0 1 0 rg 0 0 50 50 re f"); let image = stream( "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 24 0 R", "\u{0}\u{40}\u{7f}\u{20}", ); let charproc = stream("", "10 0 0 0 10 10 d1 0 0 10 10 re f"); let appearance = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", "0 0 1 1 re f"); let mask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{7f}\u{7f}\u{20}\u{0}"); build( &[ "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> /StructTreeRoot 13 0 R \ /AcroForm << /Fields [8 0 R 9 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> \ /PageLabels << /Nums [0 << /S /D /St 1 >>] >> /Names << /JavaScript << /Names [(init) 11 0 R] >> >> \ /OCProperties << /OCGs [12 0 R] /D << /Order [12 0 R] /ON [12 0 R] >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /StructParents 0 /Annots [8 0 R 9 0 R 10 0 R 17 0 R 18 0 R 19 0 R 20 0 R] \ /Resources << /Font << /F1 5 0 R /T3 7 0 R >> /Shading << /Sh1 14 0 R >> /XObject << /Fm1 15 0 R /Im1 16 0 R >> \ /ExtGState << /G1 << /ca 0.5 /BM /Multiply >> >> /Properties << /OC1 12 0 R >> \ /ColorSpace << /CS1 [/Indexed /DeviceRGB 1 ] >> >> >>", &content, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /sq 21 0 R >> \ /Encoding << /Type /Encoding /Differences [97 /sq] >> /FirstChar 97 /LastChar 97 /Widths [10] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (t) /V (v) /Rect [10 10 90 30] /P 3 0 R \ /AA << /K << /S /JavaScript /JS (AFNumber_Keystroke\\(2,0,0,0,\"\",true\\);) >> >> >>", "<< /Type /Annot /Subtype /Widget /FT /Btn /T (c) /V /Yes /AS /Yes /Rect [10 40 30 60] /P 3 0 R \ /AP << /N << /Yes 22 0 R /Off 22 0 R >> >> >>", "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (d) /Opt [(a) (b)] /V (a) /Rect [10 70 90 90] /P 3 0 R >>", "<< /S /JavaScript /JS (var x = 1;) >>", "<< /Type /OCG /Name (Layer) >>", "<< /Type /StructTreeRoot /K 23 0 R /ParentTree << /Nums [0 [23 0 R]] >> >>", "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] \ /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >>", &form, &image, "<< /Type /Annot /Subtype /Link /Rect [10 120 90 140] /Border [0 0 0] /A << /S /URI /URI (http://example.com) >> >>", "<< /Type /Annot /Subtype /Highlight /Rect [10 80 90 100] /QuadPoints [10 100 90 100 10 80 90 80] /C [1 1 0] \ /Contents (note) /Popup 25 0 R >>", "<< /Type /Annot /Subtype /Ink /Rect [10 10 40 30] /InkList [[10 10 20 30 40 20]] /C [1 0 0] >>", "<< /Type /Annot /Subtype /FreeText /Rect [100 200 190 240] /DA (/Helv 12 Tf 0 g) /Contents (text) >>", &charproc, &appearance, "<< /Type /StructElem /S /P /P 13 0 R /Pg 3 0 R /K 0 >>", &mask, "<< /Type /Annot /Subtype /Popup /Rect [100 100 150 150] /Parent 18 0 R >>", ], "/Root 1 0 R /Info << /Title (Rich) >>", ) } /// The same small document stored the modern way: an uncompressed object stream and a /// cross-reference stream, so the tolerant and the strict readers both walk the compressed-object /// and `/Index` paths. fn object_stream_seed() -> Vec { let bodies = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", ]; let nums = [1u32, 2, 3, 5]; let mut header = String::new(); let mut body = String::new(); for (n, b) in nums.iter().zip(bodies) { header.push_str(&format!("{n} {} ", body.len())); body.push_str(b); body.push('\n'); } let objstm_data = format!("{header}{body}"); let mut out = b"%PDF-1.7\n".to_vec(); let content_off = out.len(); out.extend_from_slice(format!("4 0 obj\n{}\nendobj\n", stream("", "BT /F1 14 Tf 10 100 Td (Object stream) Tj ET")).as_bytes()); let objstm_off = out.len(); let dict = format!("/Type /ObjStm /N {} /First {}", nums.len(), header.len()); out.extend_from_slice(format!("6 0 obj\n{}\nendobj\n", stream(&dict, &objstm_data)).as_bytes()); let xref_off = out.len(); // /W [1 3 1]: type, offset (or object-stream number), generation (or index in the stream). let entry = |t: u8, f: usize, g: u8| [t, (f >> 16) as u8, (f >> 8) as u8, f as u8, g]; let rows = [ entry(0, 0, 255), entry(2, 6, 0), entry(2, 6, 1), entry(2, 6, 2), entry(1, content_off, 0), entry(2, 6, 3), entry(1, objstm_off, 0), entry(1, xref_off, 0), ]; out.extend_from_slice( format!("7 0 obj\n<< /Type /XRef /Size 8 /W [1 3 1] /Index [0 8] /Root 1 0 R /Length {} >>\nstream\n", rows.len() * 5).as_bytes(), ); for r in rows { out.extend_from_slice(&r); } out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_off}\n%%EOF\n").as_bytes()); out } /// A tiny document plus an incremental update (a new annotation and a rewritten page) chained /// by `/Prev`, so mutations reach the revision walker and the incremental save path. fn incremental_update_seed() -> Vec { let mut out = build( &[ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", "<< /Type /Page /Parent 2 0 R >>", ], "/Root 1 0 R", ); let prev = out.windows(10).rposition(|w| w == b"startxref\n").and_then(|i| { let tail = String::from_utf8_lossy(&out[i + 10..]).into_owned(); tail.lines().next().and_then(|l| l.trim().parse::().ok()) }); let prev = prev.unwrap_or(0); let page_off = out.len(); out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>\nendobj\n"); let annot_off = out.len(); out.extend_from_slice(b"4 0 obj\n<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [0 0 1] /Contents (update) >>\nendobj\n"); let x = out.len(); out.extend_from_slice( format!( "xref\n3 2\n{page_off:010} 00000 n \n{annot_off:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Prev {prev} >>\nstartxref\n{x}\n%%EOF\n" ) .as_bytes(), ); out } /// A file that opens, whose graphs loop: a page whose annotation array holds the page, a form /// XObject that draws itself and an outline item that is its own sibling. Every walker needs a /// depth limit or a seen-set; these are what they meet. The page tree stays sound on purpose: a /// kid that lists its own parent makes the whole document refuse to open ("no pages"), and a seed /// refused at open only reaches the error path. fn cyclic_seed() -> Vec { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm1 4 0 R >> >>", "/Fm1 Do 0 0 10 10 re f"); build( &[ "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Fm1 4 0 R >> >> /Annots [3 0 R] >>", &form, "<< /Type /Outlines /First 6 0 R /Last 6 0 R /Count 1 >>", "<< /Title (Loop) /Parent 5 0 R /Next 6 0 R /Prev 6 0 R /Dest [3 0 R /Fit] >>", ], "/Root 1 0 R", ) } /// Synthetic seeds, so fuzzing works without the corpus: a small mixed document, a minimal one, /// a feature-rich page, an object-stream file with a cross-reference stream, an incremental /// update and looping object graphs. There is no encrypted seed yet: `xtask` does not depend on /// the crypt crate, and a hand-rolled encryptor here would be a second implementation to keep /// right. fn synthetic_seeds() -> Vec> { let content = "BT /F1 24 Tf 20 150 Td (Hello) Tj ET 0 0 1 rg 10 10 50 50 re f q 20 0 0 20 100 100 cm BI /W 2 /H 2 /BPC 8 /CS /G ID \u{0}\u{ff}\u{ff}\u{0} EI Q"; let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); vec![ build( &[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /Outlines 7 0 R >>", "<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 200 300] >>", "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", &stream, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f) /V (v) /Rect [10 10 90 30] /P 3 0 R >>", "<< /Type /Outlines /First 9 0 R /Last 9 0 R /Count 1 >>", "<< /Type /Pages /Parent 2 0 R /Kids [10 0 R] /Count 1 /Rotate 90 >>", "<< /Title (One) /Parent 7 0 R /Dest [3 0 R /Fit] >>", "<< /Type /Page /Parent 8 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", ], "/Root 1 0 R /Info << /Title (Fuzz) >>", ), build( &[ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>", ], "/Root 1 0 R", ), feature_rich_seed(), object_stream_seed(), incremental_update_seed(), cyclic_seed(), ] } fn seeds() -> Vec<(String, Vec)> { let mut out: Vec<(String, Vec)> = synthetic_seeds().into_iter().enumerate().map(|(i, b)| (format!("synthetic-{i}"), b)).collect(); // Seeds the corpus sweep already knows to hang only rediscover that hang. let known_hangs: std::collections::HashSet = std::fs::read_to_string(root().join("xtask/baselines/pdfjs.json")) .ok() .and_then(|t| serde_json::from_str::>(&t).ok()) .map(|m| m.into_iter().filter(|(_, v)| v == "timeout").map(|(k, _)| k).collect()) .unwrap_or_default(); let dir = root().join("corpus/pdfjs/test/pdfs"); if let Ok(rd) = std::fs::read_dir(&dir) { let mut files: Vec = rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect(); files.sort(); for f in files { let name = f.file_name().unwrap_or_default().to_string_lossy().into_owned(); if known_hangs.contains(&name) { continue; } if let Ok(b) = std::fs::read(&f) && b.len() <= 64 * 1024 { out.push((f.file_name().unwrap_or_default().to_string_lossy().into_owned(), b)); } } } out } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Outcome { Ok, Crash, Hang, } /// Address-space cap for each child on Linux and FreeBSD, in KiB (4 GiB). An input that makes us allocate /// without bound then fails its allocation in the child (an abort, kept as a crash finding) /// instead of exhausting the machine: on CI that killed the runner and lost the findings. const CHILD_ADDRESS_SPACE_KIB: u64 = 4 * 1024 * 1024; /// The child process, under the address-space cap where the shell can set one (Linux and /// FreeBSD; macOS does not support `ulimit -v` and Windows has no `sh`). fn child_command(exe: &Path) -> Command { if cfg!(any(target_os = "linux", target_os = "freebsd")) { let mut c = Command::new("sh"); c.args(["-c", &format!("ulimit -v {CHILD_ADDRESS_SPACE_KIB} && exec \"$0\" \"$@\"")]).arg(exe); c } else { Command::new(exe) } } fn run_one(exe: &Path, file: &Path, timeout: Duration) -> (Outcome, String) { let child = child_command(exe).args(["check-one", &file.to_string_lossy(), "--dpi", "18", "--edit"]).stdout(Stdio::null()).stderr(Stdio::piped()).spawn(); let Ok(mut child) = child else { return (Outcome::Ok, String::new()) }; let start = Instant::now(); loop { match child.try_wait() { Ok(Some(status)) => { if status.success() { return (Outcome::Ok, String::new()); } let mut err = String::new(); if let Some(mut e) = child.stderr.take() { use std::io::Read; let _ = e.read_to_string(&mut err); } // Keep the first panic location or abort message: it identifies the bug. let what = err .lines() .find(|l| l.contains("panicked at") || l.contains("overflow") || l.contains("fatal") || l.contains("memory allocation")) .unwrap_or("") .trim(); // Drop the per-process thread id ("thread 'main' (12345)") so runs compare equal. let what: String = what .split(" (") .map(|part| match part.split_once(')') { Some((id, rest)) if id.chars().all(|c| c.is_ascii_digit()) => rest.to_string(), _ => format!(" ({part}"), }) .collect::() .trim_start_matches(" (") .to_string(); return (Outcome::Crash, format!("{status}; {what}")); } Ok(None) if start.elapsed() > timeout => { let _ = child.kill(); let _ = child.wait(); return (Outcome::Hang, format!("over {} s", timeout.as_secs())); } Ok(None) => std::thread::sleep(Duration::from_millis(5)), Err(e) => return (Outcome::Ok, e.to_string()), } } } /// Remove chunks while the input still fails with the same outcome and message. fn minimize(exe: &Path, work: &Path, data: Vec, outcome: Outcome, what: &str, timeout: Duration) -> Vec { let mut best = data; let mut chunk = best.len() / 2; let mut tries = 0; let probe = work.join("minimize.pdf"); while chunk >= 1 && tries < 300 { let mut i = 0; let mut progressed = false; while i < best.len() && tries < 300 { let mut cand = best.clone(); cand.drain(i..(i + chunk).min(cand.len())); tries += 1; if std::fs::write(&probe, &cand).is_err() { break; } let (o, w) = run_one(exe, &probe, timeout); if o == outcome && (outcome == Outcome::Hang || w == what) { best = cand; progressed = true; } else { i += chunk; } } if !progressed { chunk /= 2; } } let _ = std::fs::remove_file(probe); best } pub fn run(args: &[String]) -> Result<()> { let time = Duration::from_secs(flag(args, "--time").unwrap_or("300").parse()?); let iterations: usize = flag(args, "--iterations").map(str::parse).transpose()?.unwrap_or(usize::MAX); let seed: u64 = flag(args, "--seed").unwrap_or("1").parse()?; // Half the cores by default: a saturated machine turns slow inputs into false hangs. let jobs: usize = flag(args, "--jobs").map(str::parse).transpose()?.unwrap_or_else(|| std::thread::available_parallelism().map_or(2, |n| (n.get() / 2).max(1))); let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("10").parse()?); let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) .current_dir(root()) .args(["build", "--release", "-q", "-p", "pdfcraft-cli"]) .status()?; if !status.success() { bail!("building pdfcraft-cli failed"); } let exe = release_cli(); if !exe.is_file() { // Every run would fail to start and count as a pass. bail!("{} not found after building it", exe.display()); } let out = root().join("fuzz-out"); let work = out.join("work"); let findings = out.join("findings"); std::fs::create_dir_all(&work)?; std::fs::create_dir_all(&findings)?; let seeds = Arc::new(seeds()); println!("fuzz: {} seeds, {jobs} jobs, {} s, timeout {} s, seed {seed}", seeds.len(), time.as_secs(), timeout.as_secs()); let done = Arc::new(AtomicUsize::new(0)); let found: Arc>> = Arc::default(); // Held while confirming a hang; the other jobs keep running, so this only serializes // confirmations with each other. let solo = Arc::new(Mutex::new(())); let start = Instant::now(); std::thread::scope(|s| { for job in 0..jobs { let (seeds, done, found, exe, work, findings, solo) = (seeds.clone(), done.clone(), found.clone(), exe.clone(), work.clone(), findings.clone(), solo.clone()); s.spawn(move || { let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(job as u64 + 1) | 1); let file = work.join(format!("job{job}.pdf")); while start.elapsed() < time { let n = done.fetch_add(1, Ordering::Relaxed); if n >= iterations { break; } let (name, base) = &seeds[rng.below(seeds.len())]; let other = &seeds[rng.below(seeds.len())].1; let data = mutate(&mut rng, base, other); if std::fs::write(&file, &data).is_err() { continue; } let (outcome, what) = run_one(&exe, &file, timeout); if outcome == Outcome::Ok { continue; } let key = format!("{outcome:?}:{what}"); { let f = found.lock().expect("lock"); // One finding per distinct failure (hangs: one per seed). let k = if outcome == Outcome::Hang { format!("Hang:{name}") } else { key.clone() }; if f.iter().any(|(o, w, s)| (if *o == Outcome::Hang { format!("Hang:{s}") } else { format!("{o:?}:{w}") }) == k) { continue; } } // A hang under full load may just be a slow machine: confirm it alone, with // three times the time, before recording it. Hangs are not minimized (each // probe costs a full timeout). let small = if outcome == Outcome::Hang { let _guard = solo.lock().expect("lock"); if run_one(&exe, &file, timeout * 3).0 != Outcome::Hang { continue; } data.clone() } else { minimize(&exe, &work, data.clone(), outcome, &what, timeout) }; let id = format!("{:?}-{:016x}", outcome, fxhash(&small)).to_lowercase(); let _ = std::fs::write(findings.join(format!("{id}.pdf")), &small); let note = serde_json::json!({ "outcome": format!("{outcome:?}"), "detail": what, "seed_file": name, "bytes": small.len(), "original_bytes": data.len() }); let _ = std::fs::write(findings.join(format!("{id}.json")), serde_json::to_string_pretty(¬e).unwrap_or_default()); println!(" {outcome:?} from {name}: {what} → fuzz-out/findings/{id}.pdf ({} bytes)", small.len()); found.lock().expect("lock").push((outcome, what, name.clone())); } }); } }); let n = done.load(Ordering::Relaxed).min(iterations); let found = found.lock().expect("lock"); let crashes = found.iter().filter(|f| f.0 == Outcome::Crash).count(); let hangs = found.len() - crashes; println!( "fuzz: {n} runs in {:.0} s ({:.1}/s): {crashes} distinct crash(es), {hangs} hang(s)", start.elapsed().as_secs_f64(), n as f64 / start.elapsed().as_secs_f64().max(0.001) ); if crashes > 0 { bail!("{crashes} crash(es): see fuzz-out/findings/"); } Ok(()) } fn fxhash(b: &[u8]) -> u64 { b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, x| (h ^ u64::from(*x)).wrapping_mul(0x100_0000_01b3)) } #[cfg(test)] mod tests { use super::*; /// The offset after the last `startxref`, if the file has a well-formed one. fn startxref(pdf: &[u8]) -> Option { let i = pdf.windows(10).rposition(|w| w == b"startxref\n")?; String::from_utf8_lossy(pdf.get(i + 10..)?).lines().next()?.trim().parse().ok() } /// A seed whose offsets are wrong would only exercise the reconstruction path, which hides the /// readers the seed is there to reach. #[test] fn every_synthetic_seed_points_startxref_at_its_cross_reference() { let seeds = synthetic_seeds(); assert_eq!(seeds.len(), 6, "update this count and the doc comment together with the seeds"); for (n, pdf) in seeds.iter().enumerate() { assert!(pdf.starts_with(b"%PDF-"), "seed {n}: header"); assert!(pdf.ends_with(b"%%EOF\n"), "seed {n}: trailer"); let at = startxref(pdf).unwrap_or_else(|| panic!("seed {n}: no startxref")); let target = pdf.get(at..).unwrap_or_default(); let table = target.starts_with(b"xref\n"); let xref_stream = find(target.get(..40).unwrap_or(target), b"0 obj").is_some() && find(target, b"/Type /XRef").is_some(); assert!(table || xref_stream, "seed {n}: startxref {at} does not reach a cross-reference"); } } /// The incremental-update seed must chain to the first section, or it is not an update. #[test] fn the_incremental_seed_chains_to_the_first_cross_reference() { let pdf = incremental_update_seed(); let at = startxref(&pdf).unwrap_or(0); let tail = String::from_utf8_lossy(pdf.get(at..).unwrap_or_default()).into_owned(); let prev: usize = tail.split("/Prev ").nth(1).and_then(|s| s.split_whitespace().next()).and_then(|s| s.parse().ok()).unwrap_or(0); assert!(prev > 0 && prev < at, "/Prev {prev} must point at the earlier section (startxref {at})"); assert!(pdf.get(prev..).unwrap_or_default().starts_with(b"xref\n")); } /// Every cross-reference entry of the classic seeds points at its own object. #[test] fn classic_seed_entries_point_at_their_objects() { for (n, pdf) in [synthetic_seeds().remove(0), feature_rich_seed(), cyclic_seed()].iter().enumerate() { let at = startxref(pdf).unwrap_or(0); let text = String::from_utf8_lossy(pdf.get(at..).unwrap_or_default()).into_owned(); for (num, line) in text.lines().skip(2).take_while(|l| !l.starts_with("trailer")).enumerate() { if line.ends_with(" n ") { let off: usize = line.get(..10).and_then(|o| o.parse().ok()).unwrap_or(usize::MAX); let want = format!("{num} 0 obj"); assert!(pdf.get(off..).unwrap_or_default().starts_with(want.as_bytes()), "seed {n}: entry {num} does not reach `{want}`"); } } } } }