This commit is contained in:
Brandon Thomas 2026-10-01 08:55:16 -04:00
parent 8426503edb
commit 6ea1402424
6 changed files with 394 additions and 0 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ plan/
/corpus
/log
.DS_Store
/fuzz-out

1
Cargo.lock generated
View File

@ -2904,6 +2904,7 @@ version = "0.1.0"
dependencies = [
"base64",
"printcraft-automation",
"printcraft-cos",
"printcraft-engine",
"printcraft-render",
"serde_json",

View File

@ -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"

View File

@ -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::<String>() });
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::<Vec<_>>().join("-").to_lowercase()
}

361
xtask/src/fuzz.rs Normal file
View File

@ -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 <file> --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<u8> {
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<usize> {
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<Vec<u8>> {
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<u8>)> {
let mut out: Vec<(String, Vec<u8>)> = 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<PathBuf> = 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<u8>, outcome: Outcome, what: &str, timeout: Duration) -> Vec<u8> {
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<Mutex<Vec<(Outcome, String, String)>>> = 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(&note).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))
}

View File

@ -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),