diff --git a/.gitignore b/.gitignore index 0d78b09de6..68105186b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ plan/ /corpus /log .DS_Store +/fuzz-out diff --git a/Cargo.lock b/Cargo.lock index 48cc658443..c65a3782ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,6 +2904,7 @@ version = "0.1.0" dependencies = [ "base64", "printcraft-automation", + "printcraft-cos", "printcraft-engine", "printcraft-render", "serde_json", diff --git a/apps/printcraft-cli/Cargo.toml b/apps/printcraft-cli/Cargo.toml index bf103f2582..371c132833 100644 --- a/apps/printcraft-cli/Cargo.toml +++ b/apps/printcraft-cli/Cargo.toml @@ -16,6 +16,7 @@ workspace = true printcraft-render = { workspace = true } serde_json = { workspace = true } printcraft-engine = { workspace = true } +printcraft-cos = { workspace = true } printcraft-automation = { workspace = true } base64 = "0.22" diff --git a/apps/printcraft-cli/src/main.rs b/apps/printcraft-cli/src/main.rs index 74b6659d3a..583f73d740 100644 --- a/apps/printcraft-cli/src/main.rs +++ b/apps/printcraft-cli/src/main.rs @@ -268,12 +268,40 @@ fn check_one(args: &[String]) -> Result<(), String> { (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) } }; + // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, + // so a panic in cos shows up as a crash of this process. + if args.iter().any(|a| a == "--edit") { + edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + } let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); println!("{line}"); Ok(()) } +/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both +/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. +fn edit_round_trip(bytes: &[u8]) { + use printcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + for num in doc.object_numbers().into_iter().take(20_000) { + if let Ok(o) = doc.try_get(num) + && let Object::Stream(s) = &*o + { + let _ = s.decoded(); + } + } + if let Some(root) = doc.root() { + let _ = doc.update_dict(root, |d| d.set(b"PrintCraftFuzz".to_vec(), Object::Bool(true))); + } + let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; + for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)].into_iter().flatten() { + if let Ok(again) = Document::open(Arc::new(out)) { + let _ = again.root().map(|r| again.get(r)); + } + } +} + fn short(s: &str) -> String { s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() } diff --git a/xtask/src/fuzz.rs b/xtask/src/fuzz.rs new file mode 100644 index 0000000000..71eeb22f7a --- /dev/null +++ b/xtask/src/fuzz.rs @@ -0,0 +1,361 @@ +//! `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 +//! `printcraft-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::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) +} + +/// Synthetic seeds, so fuzzing works without the corpus: plain, object-stream and encrypted +/// shapes, nested page trees, annotations, forms and inline images. +fn synthetic_seeds() -> Vec> { + let build = |objs: &[&str], trailer: &str| { + 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 + }; + 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"), + ] +} + +fn seeds() -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = synthetic_seeds().into_iter().enumerate().map(|(i, b)| (format!("synthetic-{i}"), b)).collect(); + 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 { + 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, +} + +fn run_one(exe: &Path, file: &Path, timeout: Duration) -> (Outcome, String) { + let child = Command::new(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")).unwrap_or("").trim().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()?; + let jobs: usize = flag(args, "--jobs").map(str::parse).transpose()?.unwrap_or_else(|| std::thread::available_parallelism().map_or(4, |n| n.get())); + 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", "printcraft-cli"]) + .status()?; + if !status.success() { + bail!("building printcraft-cli failed"); + } + let exe = root().join("target/release/printcraft-cli"); + 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(); + let start = Instant::now(); + std::thread::scope(|s| { + for job in 0..jobs { + let (seeds, done, found, exe, work, findings) = (seeds.clone(), done.clone(), found.clone(), exe.clone(), work.clone(), findings.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; + } + } + let small = 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)) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e42506ea37..cad041270e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,6 +4,7 @@ use std::process::ExitCode; mod assets; mod demo_pdf; +mod fuzz; mod gates; mod layers; mod screenshots; @@ -19,6 +20,7 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch test corpora into corpus/ (git-ignored): pdf.js test PDFs", gates::corpus), ("check", "Robustness sweep over corpus/ with printcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), + ("fuzz", "Mutation fuzzing of open/render/edit/save in child processes; findings in fuzz-out/ (--time 300)", fuzz::run), ("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle), ("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run), ("demo-pdf", "Build dist/demo/printcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),