Fuzz: run the CLI cargo built, refuse to run without it, and cap child memory on Linux

- xtask looked for target/release/printcraft-cli even under CARGO_TARGET_DIR
  (which parallel agents use), and a child that failed to spawn counted as a
  pass: such runs fuzzed nothing and reported 0 crashes. The binary now comes
  from CARGO_TARGET_DIR (gates' corpus check and text oracle too), and fuzz
  stops if it is missing.
- On Linux each child runs under a 4 GiB address-space limit, so an input that
  allocates without bound becomes a crash finding instead of exhausting the
  machine: the nightly fuzz job's runner was killed this way every night,
  losing the findings. Allocation failures are kept as the finding's message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-04 17:59:25 -04:00
1 parent 1ca576309e
commit 330a4f59cf
2 files changed
+38 -6

No files matched your search

+29 -4
View File
@@ -26,7 +26,7 @@ use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use crate::gates::root;
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)
@@ -225,9 +225,26 @@ enum Outcome {
Hang,
}
/// Address-space cap for each child on Linux, 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; macOS
/// does not support `ulimit -v` and Windows has no `sh`).
fn child_command(exe: &Path) -> Command {
if cfg!(target_os = "linux") {
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 =
Command::new(exe).args(["check-one", &file.to_string_lossy(), "--dpi", "18", "--edit"]).stdout(Stdio::null()).stderr(Stdio::piped()).spawn();
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 {
@@ -242,7 +259,11 @@ fn run_one(exe: &Path, file: &Path, timeout: Duration) -> (Outcome, String) {
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();
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(" (")
@@ -314,7 +335,11 @@ pub fn run(args: &[String]) -> Result<()> {
if !status.success() {
bail!("building printcraft-cli failed");
}
let exe = root().join("target/release/printcraft-cli");
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");
+9 -2
View File
@@ -12,6 +12,13 @@ pub fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
}
/// The release `printcraft-cli` that `cargo build --release -p printcraft-cli` produced: under
/// `CARGO_TARGET_DIR` when it is set (parallel agents use their own), else `target/`.
pub fn release_cli() -> PathBuf {
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root().join("target"), |d| root().join(d));
target.join("release").join(format!("printcraft-cli{}", std::env::consts::EXE_SUFFIX))
}
fn cargo() -> Command {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.current_dir(root());
@@ -157,7 +164,7 @@ pub fn check(args: &[String]) -> anyhow::Result<()> {
}
run_args(&["build", "--release", "-p", "printcraft-cli"])?;
let report = root().join("target/check-pdfjs.json");
let mut c = Command::new(root().join("target/release/printcraft-cli"));
let mut c = Command::new(release_cli());
c.args(["check"]).arg(&corpus).args(["--timeout", "20", "--dpi", "36", "--json"]).arg(&report);
run(c, "printcraft-cli check corpus/pdfjs")?;
let results: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(&report)?)?;
@@ -210,7 +217,7 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> {
let mut files: Vec<PathBuf> =
std::fs::read_dir(&corpus)?.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
files.sort();
let cli = root().join("target/release/printcraft-cli");
let cli = release_cli();
let mut scores = Vec::new();
let mut worst: Vec<(f64, String)> = Vec::new();
for f in files.iter().take(limit) {