#![cfg(unix)]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
const FAKE_LIBFUZZER: &str = r#"#!/bin/sh
echo "$*" >> "$FAKE_ARGS_LOG"
merge=0; replay=0; dirs=""; prefix=""
for a in "$@"; do
case "$a" in
-merge=1) merge=1 ;;
-runs=1) replay=1 ;;
-artifact_prefix=*) prefix="${a#-artifact_prefix=}" ;;
-*) ;;
*) dirs="$dirs $a" ;;
esac
done
set -- $dirs
if [ "$replay" = 1 ]; then
if [ -n "${FAKE_REPLAY_TIMEOUT:-}" ]; then
echo slow > "${prefix}timeout-replay"
exit 70
fi
exit 0
fi
out="$1"; shift
if [ "$merge" = 1 ]; then
[ -n "${FAKE_MERGE_FAIL:-}" ] && exit 1
for d in "$@"; do
for f in "$d"/*; do
[ -f "$f" ] && cp "$f" "$out/h$(cksum < "$f" | cut -d' ' -f1)"
done
done
exit 0
fi
echo "find from $(basename "$out")" > "$out/find"
if [ -n "${FAKE_CRASH:-}" ]; then
echo boom > "${prefix}crash-fake"
exit 1
fi
unit="${prefix}timeout-0123abcd"
written="artifact_prefix='$prefix'; Test unit written to $unit"
if [ -n "${FAKE_TIMEOUT:-}" ]; then
echo slow > "$unit"
printf '%s\n' "$written" "==1== ERROR: libFuzzer: timeout after 26 seconds" >&2
exit 70
fi
export unit written
exec perl -e '
$SIG{INT} = sub {
exit 72 unless $ENV{FAKE_INTERRUPT_STALLS};
open my $f, ">", $ENV{unit} or die; print $f "fast\n"; close $f;
print STDERR "==1== libFuzzer: run interrupted; exiting\n$ENV{written}\n==1== ERROR: libFuzzer: timeout after 33 seconds\n";
exit 70;
};
print STDERR "INFO: INITED\n"; sleep 1 while 1'
"#;
fn run_bounded(mut cmd: Command) -> i32 {
let mut child = cmd.process_group(0).stdout(Stdio::null()).stderr(Stdio::null()).spawn().expect("run fuzz/burst.sh");
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = child.try_wait().expect("wait for fuzz/burst.sh") {
return status.code().unwrap_or(-1);
}
if Instant::now() > deadline {
let _ = Command::new("kill").args(["-9", &format!("-{}", child.id())]).status();
panic!("fuzz/burst.sh did not finish within 60s");
}
std::thread::sleep(Duration::from_millis(50));
}
}
struct Repo {
root: PathBuf,
}
impl Drop for Repo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
impl Repo {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let root =
Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("fuzz-burst-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst)));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("fuzz")).expect("burst test fixture");
let bin = root.join("fake-libfuzzer");
fs::write(&bin, FAKE_LIBFUZZER).expect("burst test fixture");
fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).expect("burst test fixture");
Repo { root }
}
fn write(&self, rel: &str, contents: &str) {
let p = self.root.join(rel);
fs::create_dir_all(p.parent().expect("burst test fixture")).expect("burst test fixture");
fs::write(p, contents).expect("burst test fixture");
}
fn burst(&self, target: &str, budget: &str, env: &[(&str, &str)]) -> (i32, String) {
let output = self.root.join("github-output");
let _ = fs::remove_file(&output);
let mut cmd = Command::new("bash");
cmd.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("fuzz/burst.sh"))
.args(["./fake-libfuzzer", target, budget])
.current_dir(&self.root)
.env("GITHUB_OUTPUT", &output)
.env("FAKE_ARGS_LOG", self.root.join("args.log"))
.env_remove("FAKE_CRASH")
.env_remove("FAKE_MERGE_FAIL")
.env_remove("FAKE_TIMEOUT")
.env_remove("FAKE_INTERRUPT_STALLS")
.env_remove("FAKE_REPLAY_TIMEOUT");
for (k, v) in env {
cmd.env(k, v);
}
(run_bounded(cmd), fs::read_to_string(&output).unwrap_or_default())
}
fn corpus(&self, target: &str) -> Vec<String> {
let mut out: Vec<String> = fs::read_dir(self.root.join("fuzz/corpus").join(target))
.expect("burst test fixture")
.map(|e| fs::read_to_string(e.expect("burst test fixture").path()).expect("burst test fixture"))
.collect();
out.sort();
out
}
fn corpus_names(&self, target: &str) -> Vec<String> {
fs::read_dir(self.root.join("fuzz/corpus").join(target))
.expect("burst test fixture")
.map(|e| e.expect("burst test fixture").file_name().to_string_lossy().into_owned())
.collect()
}
}
#[test]
fn sequential_targets_keep_their_finds_apart() {
let repo = Repo::new();
assert_eq!(repo.burst("alpha", "0", &[]), (0, "merged=true\n".to_string()));
assert_eq!(repo.burst("beta", "0", &[]), (0, "merged=true\n".to_string()));
assert_eq!(repo.corpus("alpha"), ["find from alpha\n"]);
assert_eq!(repo.corpus("beta"), ["find from beta\n"], "alpha's find leaked into beta's corpus");
assert!(!repo.root.join("fuzz/new/alpha").exists() && !repo.root.join("fuzz/new/beta").exists());
}
#[test]
fn committed_seeds_keep_their_names_through_the_merge() {
let repo = Repo::new();
repo.write("fuzz/corpus/parse/seed-ls", "ls -la");
repo.write("fuzz/corpus/parse/0123abcd", "git status");
assert_eq!(repo.burst("parse", "0", &[]).0, 0);
let names = repo.corpus_names("parse");
assert!(names.contains(&"seed-ls".to_string()), "seed-ls was renamed or dropped: {names:?}");
assert!(!names.contains(&"0123abcd".to_string()), "the corpus was not replaced by the merge: {names:?}");
assert_eq!(fs::read_to_string(repo.root.join("fuzz/corpus/parse/seed-ls")).expect("burst test fixture"), "ls -la");
}
#[test]
fn a_crash_still_merges_and_keeps_the_fuzzer_status() {
let repo = Repo::new();
repo.write("fuzz/corpus/t/a", "prior");
let (rc, output) = repo.burst("t", "0", &[("FAKE_CRASH", "1")]);
assert_eq!(rc, 1);
assert_eq!(output, "merged=true\n");
assert!(repo.root.join("fuzz/artifacts/t/crash-fake").exists());
assert_eq!(repo.corpus("t"), ["find from t\n", "prior"]);
}
fn replays(repo: &Repo) -> usize {
let log = fs::read_to_string(repo.root.join("args.log")).expect("burst test fixture");
log.lines().filter(|l| l.contains("-runs=1")).count()
}
#[test]
fn an_interrupt_that_stalls_into_a_timeout_is_dropped_when_the_unit_replays_in_time() {
let repo = Repo::new();
repo.write("fuzz/corpus/t/a", "prior");
let (rc, output) = repo.burst("t", "0", &[("FAKE_INTERRUPT_STALLS", "1")]);
assert_eq!(rc, 0);
assert_eq!(output, "merged=true\n");
assert!(!repo.root.join("fuzz/artifacts/t/timeout-0123abcd").exists(), "the stalled exit's unit was kept as a find");
assert_eq!(replays(&repo), 1);
assert_eq!(repo.corpus("t"), ["find from t\n", "prior"]);
}
#[test]
fn an_interrupt_timeout_whose_unit_times_out_again_is_kept() {
let repo = Repo::new();
let (rc, output) = repo.burst("t", "0", &[("FAKE_INTERRUPT_STALLS", "1"), ("FAKE_REPLAY_TIMEOUT", "1")]);
assert_eq!(rc, 70);
assert_eq!(output, "merged=true\n");
assert!(repo.root.join("fuzz/artifacts/t/timeout-0123abcd").exists());
assert_eq!(replays(&repo), 1);
assert!(
!repo.root.join("timeout-replay").exists() && !repo.root.join("fuzz/artifacts/t/timeout-replay").exists(),
"the replay left its own artifact in the checkout"
);
}
#[test]
fn a_timeout_before_any_interrupt_is_kept_without_a_replay() {
let repo = Repo::new();
let (rc, _) = repo.burst("t", "0", &[("FAKE_TIMEOUT", "1")]);
assert_eq!(rc, 70);
assert!(repo.root.join("fuzz/artifacts/t/timeout-0123abcd").exists());
assert_eq!(replays(&repo), 0);
}
#[test]
fn a_failed_merge_leaves_the_corpus_and_is_not_marked_for_saving() {
let repo = Repo::new();
repo.write("fuzz/corpus/t/a", "prior");
let (rc, output) = repo.burst("t", "0", &[("FAKE_MERGE_FAIL", "1")]);
assert_eq!(rc, 2);
assert_eq!(output, "", "a failed merge must not set merged=true");
assert_eq!(repo.corpus("t"), ["prior"]);
}
#[test]
fn the_target_dictionary_is_used_when_present() {
let repo = Repo::new();
repo.write("fuzz/dict/parse.dict", "\"git\"\n");
repo.burst("parse", "0", &[]);
repo.burst("other", "0", &[]);
let log = fs::read_to_string(repo.root.join("args.log")).expect("burst test fixture");
let fuzz_runs: Vec<&str> = log.lines().filter(|l| !l.contains("-merge=1")).collect();
assert_eq!(fuzz_runs.len(), 2);
assert!(fuzz_runs[0].contains("-dict=fuzz/dict/parse.dict"), "{}", fuzz_runs[0]);
assert!(!fuzz_runs[1].contains("-dict="), "{}", fuzz_runs[1]);
}
#[test]
fn a_bad_budget_or_arity_is_a_usage_error() {
let repo = Repo::new();
assert_eq!(repo.burst("t", "3m", &[]).0, 64);
assert_eq!(repo.burst("t", "", &[]).0, 64);
let mut four = Command::new("bash");
four.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("fuzz/burst.sh"))
.args(["./fake-libfuzzer", "t", "60", "fuzz/dict/t.dict"])
.current_dir(&repo.root);
assert_eq!(run_bounded(four), 64, "the old four-argument form must be refused, not ignored");
assert!(!repo.root.join("args.log").exists(), "the fuzzer ran despite a usage error");
}
#[test]
fn a_zero_padded_budget_is_decimal_not_octal() {
let repo = Repo::new();
assert_eq!(repo.burst("t", "08", &[]).0, 0);
let log = fs::read_to_string(repo.root.join("args.log")).expect("burst test fixture");
assert!(log.contains("-max_total_time=3608"), "{log}");
}