mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
1ca576309e
commit
330a4f59cf
2 files changed
+38
-6
No files matched your search
+29
-4
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user