use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[path = "support/mod.rs"]
mod support;
use djvu_rs::djvu_document::DjVuDocument;
use djvu_rs::djvu_render::render_pixmap;
use support::{diff_stats, native_opts, parse_ppm};
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E3779B97F4A7C15)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
fn next_u32(&mut self) -> u32 {
(self.next_u64() >> 32) as u32
}
fn gen_range(&mut self, n: usize) -> usize {
if n == 0 {
return 0;
}
(self.next_u64() % n as u64) as usize
}
}
#[derive(Clone, Copy)]
struct ChunkSpan {
id: [u8; 4],
len_field_offset: usize,
data_offset: usize,
data_len: usize,
end_offset: usize,
page_index: Option<usize>,
}
fn collect_chunks(data: &[u8]) -> Vec<ChunkSpan> {
let mut out = Vec::new();
if data.len() < 12 || &data[0..4] != b"AT&T" {
return out;
}
let mut page_counter = 0usize;
let top_end = data.len();
walk_chunks(data, 4, top_end, 0, None, &mut page_counter, &mut out);
out
}
fn walk_chunks(
data: &[u8],
mut offset: usize,
end_bound: usize,
depth: u32,
page_index: Option<usize>,
page_counter: &mut usize,
out: &mut Vec<ChunkSpan>,
) {
const MAX_DEPTH: u32 = 16;
if depth > MAX_DEPTH {
return;
}
let end_bound = end_bound.min(data.len());
while offset + 8 <= end_bound {
let id = [
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
];
let len_field_offset = offset + 4;
let length = u32::from_be_bytes([
data[offset + 4],
data[offset + 5],
data[offset + 6],
data[offset + 7],
]) as usize;
let data_offset = offset + 8;
if data_offset
.checked_add(length)
.is_none_or(|end| end > end_bound)
{
let clipped_len = end_bound.saturating_sub(data_offset);
out.push(ChunkSpan {
id,
len_field_offset,
data_offset,
data_len: clipped_len,
end_offset: end_bound,
page_index,
});
return;
}
let data_end = data_offset + length;
let padded_end = (data_end + (length % 2)).min(end_bound);
let is_form = &id == b"FORM";
let this_scope =
if is_form && length >= 4 && data.get(data_offset..data_offset + 4) == Some(b"DJVU") {
let p = *page_counter;
*page_counter += 1;
Some(p)
} else {
page_index
};
out.push(ChunkSpan {
id,
len_field_offset,
data_offset,
data_len: length,
end_offset: padded_end,
page_index: this_scope,
});
if is_form && length >= 4 {
walk_chunks(
data,
data_offset + 4,
data_end,
depth + 1,
this_scope,
page_counter,
out,
);
}
offset = padded_end;
}
}
fn apply_mutation(
base: &[u8],
chunks: &[ChunkSpan],
rng: &mut Rng,
) -> (Vec<u8>, String, Option<usize>) {
if chunks.is_empty() {
let mut v = base.to_vec();
if !v.is_empty() {
let i = rng.gen_range(v.len());
v[i] ^= 1 << rng.gen_range(8);
}
return (v, "generic-bitflip".to_string(), None);
}
let c = chunks[rng.gen_range(chunks.len())];
let id_str = String::from_utf8_lossy(&c.id).to_string();
let (mutant, desc) = match rng.gen_range(3) {
0 => {
let at_start = rng.gen_range(2) == 0;
let cut = if at_start {
c.data_offset
} else {
c.end_offset
}
.min(base.len());
(
base[..cut].to_vec(),
format!(
"truncate-{}-of-{id_str}@{cut}",
if at_start { "start" } else { "end" }
),
)
}
1 => {
let mut v = base.to_vec();
let span_len = c.data_len.min(v.len().saturating_sub(c.data_offset));
let mut n = 0;
if span_len > 0 {
let n_flips = 1 + rng.gen_range(3);
for _ in 0..n_flips {
let idx = c.data_offset + rng.gen_range(span_len);
let bit = rng.gen_range(8);
v[idx] ^= 1 << bit;
n += 1;
}
}
(v, format!("bitflip-{n}bits-in-{id_str}@{}", c.data_offset))
}
_ => {
let mut v = base.to_vec();
let mut new_len = 0u32;
if c.len_field_offset + 4 <= v.len() {
let cur = u32::from_be_bytes([
v[c.len_field_offset],
v[c.len_field_offset + 1],
v[c.len_field_offset + 2],
v[c.len_field_offset + 3],
]);
new_len = match rng.gen_range(3) {
0 => cur.wrapping_add(1 + rng.gen_range(64) as u32),
1 => cur.wrapping_sub(1 + rng.gen_range(64) as u32),
_ => rng.next_u32(),
};
v[c.len_field_offset..c.len_field_offset + 4]
.copy_from_slice(&new_len.to_be_bytes());
}
(v, format!("resize-length-of-{id_str}->{new_len}"))
}
};
(mutant, desc, c.page_index)
}
enum OurOutcome {
StructReject(String),
RenderFailed(String),
Rendered {
w: u32,
h: u32,
rgba: Vec<u8>,
},
Panicked(String),
Timeout,
}
fn panic_payload_to_string(p: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = p.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = p.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
fn our_attempt(mutant: Vec<u8>, page_idx: usize, timeout: Duration) -> OurOutcome {
let (tx, rx) = mpsc::channel();
let builder = std::thread::Builder::new()
.name("diff-fuzz-mutant".into())
.stack_size(8 * 1024 * 1024);
let spawned = builder.spawn(move || {
let result = panic::catch_unwind(AssertUnwindSafe(
|| -> Result<(u32, u32, Vec<u8>), String> {
let doc = DjVuDocument::parse(&mutant).map_err(|e| format!("parse: {e}"))?;
let page = doc.page(page_idx).map_err(|e| format!("page: {e}"))?;
let (pw, ph) = (page.width() as u32, page.height() as u32);
if pw == 0 || ph == 0 {
return Err("render: zero dimensions".to_string());
}
let opts = native_opts(pw, ph);
let pm = render_pixmap(page, &opts).map_err(|e| format!("render: {e}"))?;
Ok((pm.width, pm.height, pm.data))
},
));
let _ = tx.send(result);
});
if spawned.is_err() {
return OurOutcome::StructReject("failed to spawn worker thread".into());
}
match rx.recv_timeout(timeout) {
Ok(Ok(Ok((w, h, rgba)))) => OurOutcome::Rendered { w, h, rgba },
Ok(Ok(Err(e))) => {
if e.starts_with("render:") {
OurOutcome::RenderFailed(e)
} else {
OurOutcome::StructReject(e)
}
}
Ok(Err(payload)) => OurOutcome::Panicked(panic_payload_to_string(payload)),
Err(_) => OurOutcome::Timeout,
}
}
fn wait_with_timeout(mut child: Child, timeout: Duration) -> Option<ExitStatus> {
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status),
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => return None,
}
}
}
fn djvudump_accepts(djvudump_bin: &str, path: &Path, timeout: Duration) -> Option<bool> {
let child = Command::new(djvudump_bin)
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
wait_with_timeout(child, timeout).map(|s| s.success())
}
fn ddjvu_render(
ddjvu_bin: &str,
path: &Path,
page_no: usize,
out_ppm: &Path,
timeout: Duration,
) -> Option<bool> {
let child = Command::new(ddjvu_bin)
.args([
"-format=ppm",
&format!("-page={page_no}"),
&path.to_string_lossy(),
&out_ppm.to_string_lossy(),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
let ok = wait_with_timeout(child, timeout).map(|s| s.success());
Some(ok.unwrap_or(false) && out_ppm.exists())
}
fn command_stderr(bin: &str, args: &[&str], timeout: Duration) -> String {
let Ok(mut child) = Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
else {
return String::new();
};
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) | Err(_) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
break;
}
std::thread::sleep(Duration::from_millis(5));
}
}
}
let Ok(out) = child.wait_with_output() else {
return String::new();
};
String::from_utf8_lossy(&out.stderr).trim().to_string()
}
fn djvudump_stderr(djvudump_bin: &str, path: &Path, timeout: Duration) -> String {
command_stderr(djvudump_bin, &[&path.to_string_lossy()], timeout)
}
fn ddjvu_stderr(
ddjvu_bin: &str,
path: &Path,
page_no: usize,
out_ppm: &Path,
timeout: Duration,
) -> String {
command_stderr(
ddjvu_bin,
&[
"-format=ppm",
&format!("-page={page_no}"),
&path.to_string_lossy(),
&out_ppm.to_string_lossy(),
],
timeout,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum Class {
BothReject, BothRenderFail, OurLaxer, OurStricter, OurCrash, OurTimeout, OurRenderFail, OurRendersWhatTheyReject, DimMismatch, PixelMismatch, BothAcceptMatch, SoloAccept, SoloReject, }
impl Class {
fn label(self) -> &'static str {
match self {
Class::BothReject => "both-reject",
Class::BothRenderFail => "both-render-fail",
Class::OurLaxer => "our-laxer",
Class::OurStricter => "our-stricter",
Class::OurCrash => "our-crash",
Class::OurTimeout => "our-timeout",
Class::OurRenderFail => "our-render-fail",
Class::OurRendersWhatTheyReject => "our-renders-what-they-reject",
Class::DimMismatch => "dim-mismatch",
Class::PixelMismatch => "pixel-mismatch",
Class::BothAcceptMatch => "both-accept-match",
Class::SoloAccept => "solo-accept",
Class::SoloReject => "solo-reject",
}
}
fn is_finding(self) -> bool {
matches!(
self,
Class::OurLaxer
| Class::OurStricter
| Class::OurCrash
| Class::OurTimeout
| Class::OurRenderFail
| Class::OurRendersWhatTheyReject
| Class::DimMismatch
| Class::PixelMismatch
)
}
}
struct Finding {
class: Class,
file_stem: String,
mutant_idx: usize,
mutation_desc: String,
page_idx: usize,
detail: String,
}
fn save_finding(out_dir: &Path, mutant: &[u8], f: &Finding) {
let _ = std::fs::create_dir_all(out_dir);
let base = format!("{}_{:05}_{}", f.file_stem, f.mutant_idx, f.class.label());
let djvu_path = out_dir.join(format!("{base}.djvu"));
let txt_path = out_dir.join(format!("{base}.txt"));
if std::fs::write(&djvu_path, mutant).is_ok() {
let note = format!(
"class: {}\nsource file: {}\nmutant index: {}\nmutation: {}\ntarget page (0-based): {}\ndetail: {}\n",
f.class.label(),
f.file_stem,
f.mutant_idx,
f.mutation_desc,
f.page_idx,
f.detail,
);
let _ = std::fs::write(&txt_path, note);
}
}
struct Config {
files: Vec<PathBuf>,
seed: u64,
mutants_per_file: usize,
our_timeout: Duration,
ref_timeout: Duration,
max_seconds: u64,
out_dir: PathBuf,
ddjvu_bin: String,
djvudump_bin: String,
findings_cap: usize,
}
fn default_files(manifest: &Path) -> Vec<PathBuf> {
[
"tests/corpus/watchmaker.djvu",
"tests/corpus/cable_1973_100133.djvu",
"tests/fixtures/boy.djvu",
]
.iter()
.map(|p| manifest.join(p))
.collect()
}
fn parse_args() -> Config {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let args: Vec<String> = std::env::args().skip(1).collect();
let mut cfg = Config {
files: Vec::new(),
seed: 0xD1FF_F022_1234_5678,
mutants_per_file: 500,
our_timeout: Duration::from_millis(2000),
ref_timeout: Duration::from_millis(5000),
max_seconds: 1200,
out_dir: manifest.join("fuzz/corpus-regressions/diff_fuzz"),
ddjvu_bin: std::env::var("DIFF_FUZZ_DDJVU").unwrap_or_else(|_| "ddjvu".to_string()),
djvudump_bin: std::env::var("DIFF_FUZZ_DJVUDUMP")
.unwrap_or_else(|_| "djvudump".to_string()),
findings_cap: 5,
};
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--seed" => {
i += 1;
cfg.seed = args[i].parse().unwrap_or(cfg.seed);
}
"--mutants" => {
i += 1;
cfg.mutants_per_file = args[i].parse().unwrap_or(cfg.mutants_per_file);
}
"--timeout-ms" => {
i += 1;
cfg.our_timeout = Duration::from_millis(args[i].parse().unwrap_or(2000));
}
"--ref-timeout-ms" => {
i += 1;
cfg.ref_timeout = Duration::from_millis(args[i].parse().unwrap_or(5000));
}
"--max-seconds" => {
i += 1;
cfg.max_seconds = args[i].parse().unwrap_or(cfg.max_seconds);
}
"--out-dir" => {
i += 1;
cfg.out_dir = PathBuf::from(&args[i]);
}
"--ddjvu" => {
i += 1;
cfg.ddjvu_bin = args[i].clone();
}
"--djvudump" => {
i += 1;
cfg.djvudump_bin = args[i].clone();
}
other => cfg.files.push(PathBuf::from(other)),
}
i += 1;
}
if cfg.files.is_empty() {
cfg.files = default_files(&manifest);
}
cfg
}
const PIXEL_MISMATCH_MEAN_THRESHOLD: f64 = 4.0;
const PIXEL_MISMATCH_GT32_THRESHOLD: f64 = 2.0;
fn main() {
panic::set_hook(Box::new(|_info| {}));
let cfg = parse_args();
let have_djvudump = Command::new(&cfg.djvudump_bin)
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok();
let have_ddjvu = Command::new(&cfg.ddjvu_bin)
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok();
let solo_mode = !have_djvudump;
println!("DIFF_FUZZ — corpus-mutation differential fuzzer");
println!(
" seed={:#x} mutants/file={} our-timeout={:?} ref-timeout={:?} budget={}s",
cfg.seed, cfg.mutants_per_file, cfg.our_timeout, cfg.ref_timeout, cfg.max_seconds
);
if solo_mode {
println!(
" djvudump not found on PATH (or at --djvudump) — running SOLO MODE:\n\
mutation + our-side parse/render only, no djvudump/ddjvu comparison.\n\
Install DjVuLibre (`brew install djvulibre`) and re-run for full\n\
differential mode, or pass --djvudump/--ddjvu to point at them."
);
} else if !have_ddjvu {
println!(
" djvudump found but ddjvu not found — parse-divergence checks run,\n\
pixel comparison is skipped for mutants both sides accept."
);
}
let tmp_dir = std::env::temp_dir().join(format!("diff_fuzz_{}", std::process::id()));
let _ = std::fs::create_dir_all(&tmp_dir);
let overall_start = Instant::now();
let budget = Duration::from_secs(cfg.max_seconds);
let mut totals: std::collections::BTreeMap<Class, usize> = std::collections::BTreeMap::new();
let mut findings_saved: std::collections::BTreeMap<Class, usize> =
std::collections::BTreeMap::new();
let mut saved_paths: Vec<PathBuf> = Vec::new();
let mut ran_out_of_time = false;
for file in &cfg.files {
let Ok(base) = std::fs::read(file) else {
println!("SKIP (unreadable): {}", file.display());
continue;
};
let file_stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("input")
.to_string();
let chunks = collect_chunks(&base);
let n_pages = chunks
.iter()
.filter_map(|c| c.page_index)
.max()
.map(|m| m + 1)
.unwrap_or(0);
println!(
"--- {} ({} bytes, {} chunk spans, {} page scopes) ---",
file.display(),
base.len(),
chunks.len(),
n_pages,
);
for m in 0..cfg.mutants_per_file {
if overall_start.elapsed() > budget {
ran_out_of_time = true;
break;
}
let mut hasher_seed = cfg.seed
^ (m as u64).wrapping_mul(0x9E3779B97F4A7C15)
^ (file_stem
.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64)));
if hasher_seed == 0 {
hasher_seed = 1;
}
let mut rng = Rng::new(hasher_seed);
let (mutant, mutation_desc, target_page) = apply_mutation(&base, &chunks, &mut rng);
let page_idx = target_page.unwrap_or(0);
let mutant_path = tmp_dir.join(format!("{file_stem}_{m:05}.djvu"));
if std::fs::write(&mutant_path, &mutant).is_err() {
continue;
}
let our = our_attempt(mutant.clone(), page_idx, cfg.our_timeout);
let class = if let OurOutcome::Panicked(_) = &our {
Class::OurCrash
} else if let OurOutcome::Timeout = &our {
Class::OurTimeout
} else if solo_mode {
match &our {
OurOutcome::Rendered { .. } | OurOutcome::RenderFailed(_) => Class::SoloAccept,
OurOutcome::StructReject(_) => Class::SoloReject,
_ => unreachable!(),
}
} else {
let our_level = match &our {
OurOutcome::StructReject(_) => 0u8,
OurOutcome::RenderFailed(_) => 1,
OurOutcome::Rendered { .. } => 2,
_ => unreachable!(),
};
let their_struct_accept =
djvudump_accepts(&cfg.djvudump_bin, &mutant_path, cfg.ref_timeout);
match their_struct_accept {
None => {
if our_level == 0 {
Class::SoloReject
} else {
Class::SoloAccept
}
}
Some(false) => {
if our_level == 0 {
Class::BothReject
} else {
Class::OurLaxer
}
}
Some(true) if our_level == 0 => Class::OurStricter,
Some(true) => {
let their_level: u8;
let mut ppm_rgb: Option<(usize, usize, Vec<u8>)> = None;
if have_ddjvu {
let out_ppm = tmp_dir.join(format!("{file_stem}_{m:05}.ppm"));
let rendered = ddjvu_render(
&cfg.ddjvu_bin,
&mutant_path,
page_idx + 1,
&out_ppm,
cfg.ref_timeout,
)
.unwrap_or(false);
if rendered {
let ppm_bytes = std::fs::read(&out_ppm).unwrap_or_default();
let _ = std::fs::remove_file(&out_ppm);
ppm_rgb = parse_ppm(&ppm_bytes);
their_level = if ppm_rgb.is_some() { 2 } else { 1 };
} else {
their_level = 1;
}
} else {
their_level = our_level;
}
match our_level.cmp(&their_level) {
std::cmp::Ordering::Less => Class::OurRenderFail,
std::cmp::Ordering::Greater if our_level == 2 && their_level == 1 => {
Class::OurRendersWhatTheyReject
}
std::cmp::Ordering::Greater => Class::OurRenderFail, std::cmp::Ordering::Equal if our_level < 2 => Class::BothRenderFail,
std::cmp::Ordering::Equal => {
let OurOutcome::Rendered { w, h, rgba } = &our else {
unreachable!()
};
match ppm_rgb {
Some((rw, rh, ref_rgb)) => {
if rw != *w as usize || rh != *h as usize {
Class::DimMismatch
} else {
let d = diff_stats(rw, rh, rgba, &ref_rgb);
if d.mean_abs > PIXEL_MISMATCH_MEAN_THRESHOLD
|| d.pct_gt32 > PIXEL_MISMATCH_GT32_THRESHOLD
{
Class::PixelMismatch
} else {
Class::BothAcceptMatch
}
}
}
None => Class::BothAcceptMatch, }
}
}
}
}
};
*totals.entry(class).or_insert(0) += 1;
if class.is_finding() {
let saved_count = findings_saved.entry(class).or_insert(0);
if *saved_count < cfg.findings_cap || class == Class::OurCrash {
let detail = match &our {
OurOutcome::StructReject(e) => e.clone(),
OurOutcome::RenderFailed(e) => e.clone(),
OurOutcome::Panicked(e) => format!("panic: {e}"),
OurOutcome::Timeout => "our-side timed out".to_string(),
OurOutcome::Rendered { w, h, .. } => format!("rendered {w}x{h}"),
};
let detail = if !solo_mode
&& matches!(
class,
Class::OurStricter | Class::OurLaxer | Class::OurCrash
) {
let their_stderr =
djvudump_stderr(&cfg.djvudump_bin, &mutant_path, cfg.ref_timeout);
format!("{detail}\ndjvudump stderr: {their_stderr}")
} else if !solo_mode
&& have_ddjvu
&& matches!(
class,
Class::OurRendersWhatTheyReject
| Class::OurRenderFail
| Class::DimMismatch
| Class::PixelMismatch
)
{
let scratch_ppm = tmp_dir.join(format!("{file_stem}_{m:05}_finding.ppm"));
let their_stderr = ddjvu_stderr(
&cfg.ddjvu_bin,
&mutant_path,
page_idx + 1,
&scratch_ppm,
cfg.ref_timeout,
);
let _ = std::fs::remove_file(&scratch_ppm);
format!("{detail}\nddjvu stderr: {their_stderr}")
} else {
detail
};
let finding = Finding {
class,
file_stem: file_stem.clone(),
mutant_idx: m,
mutation_desc: mutation_desc.clone(),
page_idx,
detail,
};
save_finding(&cfg.out_dir, &mutant, &finding);
saved_paths.push(cfg.out_dir.join(format!(
"{}_{:05}_{}.djvu",
file_stem,
m,
class.label()
)));
*saved_count += 1;
}
}
let _ = std::fs::remove_file(&mutant_path);
if (m + 1) % 200 == 0 {
println!(" ... {} / {} mutants", m + 1, cfg.mutants_per_file);
}
}
if ran_out_of_time {
println!(" (time budget exhausted, stopping early)");
break;
}
}
let _ = std::fs::remove_dir_all(&tmp_dir);
let elapsed = overall_start.elapsed();
println!(
"\n=== summary ({:.1}s elapsed{}) ===",
elapsed.as_secs_f64(),
if ran_out_of_time {
", TIME BUDGET HIT"
} else {
""
}
);
let total: usize = totals.values().sum();
println!("total mutants classified: {total}");
for (class, count) in &totals {
println!(" {:<18} {:>6}", class.label(), count);
}
if saved_paths.is_empty() {
println!("no findings saved (out-dir: {})", cfg.out_dir.display());
} else {
println!("findings saved under {}:", cfg.out_dir.display());
for p in &saved_paths {
println!(" {}", p.display());
}
}
}