From fa4212f4a3b11fd81c8453b233daf4b4ca4ad3ad Mon Sep 17 00:00:00 2001 From: Shashank Sharma Date: Thu, 30 Jul 2026 08:45:43 -0700 Subject: [PATCH] cargo-fuzztest: support replaying corpus for a certain time - Add requires = "corpus_db" to replay_corpus_for in FuzzTestOptions. - Support ExecutionMode::ReplayCorpus in CargoFuzzTestOptions. - Pass FUZZTEST_REPLAY_CORPUS_FOR and FUZZTEST_TIME_BUDGET_TYPE in FuzztestRunner. - Add unit, runner, and end-to-end integration tests for corpus replay. PiperOrigin-RevId: 956547848 --- rust/cargo_fuzztest/BUILD | 9 +- rust/cargo_fuzztest/Cargo.toml | 17 +- rust/cargo_fuzztest/src/lib.rs | 334 +++++++++++++++++- .../another_sample_fuzz_crate/Cargo.toml | 11 + .../another_sample_fuzz_crate/src/lib.rs | 24 ++ .../test_crates/sample_fuzz_crate/src/lib.rs | 6 + rust/cargo_fuzztest/tests/common/mod.rs | 45 +++ rust/cargo_fuzztest/tests/e2e_cli_test.rs | 332 +++++++++++++++++ rust/cargo_fuzztest/tests/runner_test.rs | 252 ++++++++++--- rust/options/src/lib.rs | 199 ++++++++++- rust/src/options.rs | 27 ++ 11 files changed, 1184 insertions(+), 72 deletions(-) create mode 100644 rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/Cargo.toml create mode 100644 rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/src/lib.rs create mode 100644 rust/cargo_fuzztest/tests/common/mod.rs diff --git a/rust/cargo_fuzztest/BUILD b/rust/cargo_fuzztest/BUILD index 2b2fae5f1..d9ff6a5c5 100644 --- a/rust/cargo_fuzztest/BUILD +++ b/rust/cargo_fuzztest/BUILD @@ -62,7 +62,7 @@ rust_clippy( ) rust_test( - name = "sample_fuzz_test_bin", + name = "sample_fuzz_crate_bin", srcs = ["test_crates/sample_fuzz_crate/src/lib.rs"], edition = "2024", tags = [ @@ -77,9 +77,12 @@ rust_test( rust_test( name = "runner_test", - srcs = ["tests/runner_test.rs"], + srcs = [ + "tests/common/mod.rs", + "tests/runner_test.rs", + ], data = [ - ":sample_fuzz_test_bin", + ":sample_fuzz_crate_bin", ], edition = "2024", deps = [ diff --git a/rust/cargo_fuzztest/Cargo.toml b/rust/cargo_fuzztest/Cargo.toml index 990523cf0..0a663e155 100644 --- a/rust/cargo_fuzztest/Cargo.toml +++ b/rust/cargo_fuzztest/Cargo.toml @@ -30,11 +30,24 @@ fuzztest = { path = ".." } tempfile = "3.27.0" # Compiled as a [[test]] target so Cargo builds it with the test harness enabled -# (`rustc --test`) and exposes `CARGO_BIN_EXE_sample_fuzz_test_bin` to `runner_test`. +# (`rustc --test`) and exposes `CARGO_BIN_EXE_sample_fuzz_crate_bin` to `runner_test`. [[test]] -name = "sample_fuzz_test_bin" +name = "sample_fuzz_crate_bin" path = "test_crates/sample_fuzz_crate/src/lib.rs" +test = false + +# Compiled as a [[test]] target so Cargo builds it with the test harness enabled +# (`rustc --test`) and exposes `CARGO_BIN_EXE_another_sample_fuzz_crate_bin` to `runner_test`. +[[test]] +name = "another_sample_fuzz_crate_bin" +path = "test_crates/another_sample_fuzz_crate/src/lib.rs" +test = false [[test]] name = "runner_test" path = "tests/runner_test.rs" + +[[test]] +name = "e2e_cli_test" +path = "tests/e2e_cli_test.rs" + diff --git a/rust/cargo_fuzztest/src/lib.rs b/rust/cargo_fuzztest/src/lib.rs index daafbf8c3..241bf1fd3 100644 --- a/rust/cargo_fuzztest/src/lib.rs +++ b/rust/cargo_fuzztest/src/lib.rs @@ -17,7 +17,10 @@ use anyhow::{Context, Result}; use clap::Parser; -pub use fuzztest_options::{ExecutionMode, FuzzFor, FuzzOptions, FuzzTestOptions}; +pub use fuzztest_options::{ + ExecutionMode, FuzzFor, FuzzOptions, FuzzTestOptions, ReplayCorpusOptions, ReplayCrashOptions, + TimeBudgetType, +}; use std::env; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -54,7 +57,15 @@ impl CargoFuzzTestOptions { fn check_centipede_binary_path_is_set(&self) -> Result<()> { anyhow::ensure!( self.centipede_binary_path.is_some(), - "fuzzing mode requires `--centipede-binary-path` to be specified" + "`--centipede-binary-path` needs to be specified" + ); + Ok(()) + } + + fn check_corpus_db_is_set(&self) -> Result<()> { + anyhow::ensure!( + self.fuzztest_options.corpus_db.is_some(), + "`--corpus-db` needs to be specified" ); Ok(()) } @@ -72,14 +83,20 @@ impl CargoFuzzTestOptions { self.check_centipede_binary_path_is_set()?; mode } + ExecutionMode::ReplayCorpus(_) + | ExecutionMode::ReplayAllCrashes + | ExecutionMode::ReplayCrash(_) => { + self.check_centipede_binary_path_is_set()?; + self.check_corpus_db_is_set()?; + mode + } ExecutionMode::SmokeTest => { if self.test_path.is_some() { self.check_centipede_binary_path_is_set()?; - return Ok(ExecutionMode::Fuzz(FuzzOptions { - fuzz_for: FuzzFor::Indefinitely, - // TODO(the-shank): support parallel jobs - jobs: None, - })); + ExecutionMode::Fuzz(FuzzOptions { + fuzz_for: self.fuzztest_options.fuzz_for.unwrap_or(FuzzFor::Indefinitely), + jobs: self.fuzztest_options.jobs, + }) } else { mode } @@ -227,7 +244,7 @@ impl FuzztestRunner { } ExecutionMode::Fuzz(fuzz_options) => { - let FuzzOptions { fuzz_for, jobs: _ } = fuzz_options; + let FuzzOptions { fuzz_for, jobs } = fuzz_options; match fuzz_for { FuzzFor::Indefinitely => { cmd.env("FUZZTEST_FUZZ_FOR", "inf"); @@ -236,15 +253,42 @@ impl FuzztestRunner { cmd.env("FUZZTEST_FUZZ_FOR", duration.to_string()); } } - // TODO(the-shank): support parallel jobs + if let Some(jobs) = jobs { + cmd.env("FUZZTEST_JOBS", jobs.to_string()); + } + } + + ExecutionMode::ReplayCrash(replay_options) => { + cmd.env("FUZZTEST_REPLAY_ID", replay_options.replay_id); + } + + ExecutionMode::ReplayAllCrashes => { + cmd.env("FUZZTEST_REPLAY_FINDINGS", "true"); + } + + ExecutionMode::ReplayCorpus(replay_corpus_options) => { + cmd.env( + "FUZZTEST_REPLAY_CORPUS_FOR", + replay_corpus_options.replay_corpus_for.to_string(), + ); + let time_budget_str = match replay_corpus_options.time_budget_type { + TimeBudgetType::PerTest => "per-test", + TimeBudgetType::Total => "total", + }; + cmd.env("FUZZTEST_TIME_BUDGET_TYPE", time_budget_str); } ExecutionMode::SmokeTest => { // nothing to be done } - _ => { - // TODO(the-shank): add support for other modes. - } + } + + if let Some(corpus_db) = &self.options.fuzztest_options.corpus_db { + cmd.env("FUZZTEST_CORPUS_DB", corpus_db); + } + + if let Some(workdir_root) = &self.options.fuzztest_options.workdir_root { + cmd.env("FUZZTEST_WORKDIR_ROOT", workdir_root); } // If `--centipede-binary-path` was passed to `cargo-fuzztest`, forward it to the @@ -254,6 +298,11 @@ impl FuzztestRunner { cmd.env("FUZZTEST_CENTIPEDE_BINARY_PATH", centipede_binary_path); } + if self.options.fuzztest_options.print_subprocess_log { + cmd.env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true"); + cmd.arg("--nocapture"); + } + Ok(cmd) } @@ -295,6 +344,7 @@ impl FuzztestRunner { mod tests { use super::*; use googletest::prelude::*; + use std::collections::HashMap; #[gtest] fn test_parse_host_triple_valid() { @@ -377,4 +427,264 @@ mod tests { ExecutionMode::SmokeTest ); } + + #[gtest] + fn test_cli_option_parsing_replay_id_success() { + let parsed = CargoFuzzTestOptions::try_parse_from([ + "cargo-fuzztest", + "--replay-id", + "crash_12345", + "--corpus-db", + "/tmp/corpus_db", + "--centipede-binary-path", + "/custom/centipede", + ]) + .expect("valid replay options should parse successfully"); + + assert_eq!(parsed.fuzztest_options.replay_id.as_deref(), Some("crash_12345")); + assert_eq!(parsed.fuzztest_options.corpus_db.as_deref(), Some("/tmp/corpus_db")); + + let mode = parsed.execution_mode().expect("valid execution mode"); + assert_eq!( + mode, + ExecutionMode::ReplayCrash(ReplayCrashOptions { replay_id: "crash_12345".to_string() }) + ); + } + + #[gtest] + fn test_execution_mode_replay_id_missing_corpus_db_errors() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_id: Some("crash_12345".to_string()), + ..Default::default() + }, + centipede_binary_path: Some("/custom/centipede".to_string()), + ..Default::default() + }; + let err = options.execution_mode().expect_err("missing corpus-db should cause error"); + assert!(err.to_string().contains("`--corpus-db` needs to be specified")); + } + + #[gtest] + fn test_execution_mode_replay_id_missing_centipede_binary_path_errors() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_id: Some("crash_12345".to_string()), + corpus_db: Some("/tmp/corpus_db".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let err = + options.execution_mode().expect_err("missing centipede-binary-path should cause error"); + assert!(err.to_string().contains("`--centipede-binary-path` needs to be specified")); + } + + #[gtest] + fn test_build_run_command_replay_crash() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_id: Some("crash_12345".to_string()), + corpus_db: Some("/tmp/corpus_db".to_string()), + ..Default::default() + }, + centipede_binary_path: Some("/custom/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = + runner.build_run_command(Path::new("/tmp/test_bin")).expect("should build run command"); + + let envs: HashMap> = cmd + .get_envs() + .map(|(k, v)| { + (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string())) + }) + .collect(); + + expect_eq!(envs.get("FUZZTEST_REPLAY_ID").and_then(|v| v.as_deref()), Some("crash_12345")); + expect_eq!( + envs.get("FUZZTEST_CORPUS_DB").and_then(|v| v.as_deref()), + Some("/tmp/corpus_db") + ); + expect_eq!( + envs.get("FUZZTEST_CENTIPEDE_BINARY_PATH").and_then(|v| v.as_deref()), + Some("/custom/centipede") + ); + } + + #[gtest] + fn test_cli_option_parsing_replay_findings_success() { + let parsed = CargoFuzzTestOptions::try_parse_from([ + "cargo-fuzztest", + "--replay-findings", + "--corpus-db", + "/tmp/corpus_db", + "--centipede-binary-path", + "/custom/centipede", + ]) + .expect("valid replay-findings options should parse successfully"); + + assert!(parsed.fuzztest_options.replay_findings); + assert_eq!(parsed.fuzztest_options.corpus_db.as_deref(), Some("/tmp/corpus_db")); + + let mode = parsed.execution_mode().expect("valid execution mode"); + assert_eq!(mode, ExecutionMode::ReplayAllCrashes); + } + + #[gtest] + fn test_execution_mode_replay_findings_missing_centipede_binary_path_errors() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_findings: true, + corpus_db: Some("/tmp/corpus_db".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let err = + options.execution_mode().expect_err("missing centipede-binary-path should cause error"); + assert!(err.to_string().contains("`--centipede-binary-path` needs to be specified")); + } + + #[gtest] + fn test_build_run_command_replay_all_crashes() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_findings: true, + corpus_db: Some("/tmp/corpus_db".to_string()), + ..Default::default() + }, + centipede_binary_path: Some("/custom/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = + runner.build_run_command(Path::new("/tmp/test_bin")).expect("should build run command"); + + let envs: HashMap> = cmd + .get_envs() + .map(|(k, v)| { + (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string())) + }) + .collect(); + + expect_eq!(envs.get("FUZZTEST_REPLAY_FINDINGS").and_then(|v| v.as_deref()), Some("true")); + expect_eq!( + envs.get("FUZZTEST_CORPUS_DB").and_then(|v| v.as_deref()), + Some("/tmp/corpus_db") + ); + expect_eq!( + envs.get("FUZZTEST_CENTIPEDE_BINARY_PATH").and_then(|v| v.as_deref()), + Some("/custom/centipede") + ); + } + + #[gtest] + fn test_cli_option_parsing_replay_corpus_for_success() { + let parsed = CargoFuzzTestOptions::try_parse_from([ + "cargo-fuzztest", + "--replay-corpus-for", + "10s", + "--corpus-db", + "/tmp/corpus_db", + "--centipede-binary-path", + "/custom/centipede", + ]) + .expect("valid replay-corpus-for options should parse successfully"); + + assert_eq!(parsed.fuzztest_options.replay_corpus_for, Some("10s".parse().unwrap())); + assert_eq!(parsed.fuzztest_options.time_budget_type, TimeBudgetType::PerTest); + assert_eq!(parsed.fuzztest_options.corpus_db.as_deref(), Some("/tmp/corpus_db")); + + let mode = parsed.execution_mode().expect("valid execution mode"); + assert_eq!( + mode, + ExecutionMode::ReplayCorpus(ReplayCorpusOptions { + replay_corpus_for: "10s".parse().unwrap(), + time_budget_type: TimeBudgetType::PerTest, + jobs: None, + }) + ); + } + + #[gtest] + fn test_cli_option_parsing_replay_corpus_for_with_time_budget_type() { + let parsed = CargoFuzzTestOptions::try_parse_from([ + "cargo-fuzztest", + "--replay-corpus-for", + "10s", + "--time-budget-type", + "total", + "--corpus-db", + "/tmp/corpus_db", + "--centipede-binary-path", + "/custom/centipede", + ]) + .expect("valid options with time-budget-type should parse successfully"); + + assert_eq!(parsed.fuzztest_options.replay_corpus_for, Some("10s".parse().unwrap())); + assert_eq!(parsed.fuzztest_options.time_budget_type, TimeBudgetType::Total); + + let mode = parsed.execution_mode().expect("valid execution mode"); + assert_eq!( + mode, + ExecutionMode::ReplayCorpus(ReplayCorpusOptions { + replay_corpus_for: "10s".parse().unwrap(), + time_budget_type: TimeBudgetType::Total, + jobs: None, + }) + ); + } + + #[gtest] + fn test_execution_mode_replay_corpus_missing_centipede_binary_path_errors() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_corpus_for: Some("10s".parse().unwrap()), + corpus_db: Some("/tmp/corpus_db".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let err = + options.execution_mode().expect_err("missing centipede-binary-path should cause error"); + assert!(err.to_string().contains("`--centipede-binary-path` needs to be specified")); + } + + #[gtest] + fn test_build_run_command_replay_corpus() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_corpus_for: Some("10s".parse().unwrap()), + time_budget_type: TimeBudgetType::Total, + corpus_db: Some("/tmp/corpus_db".to_string()), + ..Default::default() + }, + centipede_binary_path: Some("/custom/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = + runner.build_run_command(Path::new("/tmp/test_bin")).expect("should build run command"); + + let envs: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| { + (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string())) + }) + .collect(); + + assert!(envs.contains(&("FUZZTEST_REPLAY_CORPUS_FOR".to_string(), Some("10s".to_string())))); + assert!( + envs.contains(&("FUZZTEST_TIME_BUDGET_TYPE".to_string(), Some("total".to_string()))) + ); + assert!( + envs.contains(&("FUZZTEST_CORPUS_DB".to_string(), Some("/tmp/corpus_db".to_string()))) + ); + assert!(envs.contains(&( + "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(), + Some("/custom/centipede".to_string()) + ))); + } } diff --git a/rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/Cargo.toml b/rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/Cargo.toml new file mode 100644 index 000000000..d863aea22 --- /dev/null +++ b/rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "another_sample_fuzz_crate" +version = "0.1.0" +edition = "2024" + +[dependencies] +fuzztest = { path = "../../.." } +googletest = "0.14.3" + +[workspace] + diff --git a/rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/src/lib.rs b/rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/src/lib.rs new file mode 100644 index 000000000..09954100f --- /dev/null +++ b/rust/cargo_fuzztest/test_crates/another_sample_fuzz_crate/src/lib.rs @@ -0,0 +1,24 @@ +use fuzztest::domains::arbitrary::Arbitrary; +use fuzztest::fuzztest; + +#[fuzztest(data = Arbitrary::::default())] +fn sample_fuzztest_target(data: i32) { + let _ = data; +} + +#[fuzztest(data = Arbitrary::::default())] +fn another_sample_fuzztest_target(data: i32) { + let _ = data; +} + +#[fuzztest(data = Arbitrary::::default())] +fn jobs_fuzztest_target(_data: i32) { + println!("JOBS_TEST_PID: {}", std::process::id()); +} + +#[fuzztest(data = Arbitrary::::default())] +fn crashing_fuzztest_target(data: u8) { + if data == 10 { + panic!("Crashing bug found!"); + } +} diff --git a/rust/cargo_fuzztest/test_crates/sample_fuzz_crate/src/lib.rs b/rust/cargo_fuzztest/test_crates/sample_fuzz_crate/src/lib.rs index 955508333..9fba8bedd 100644 --- a/rust/cargo_fuzztest/test_crates/sample_fuzz_crate/src/lib.rs +++ b/rust/cargo_fuzztest/test_crates/sample_fuzz_crate/src/lib.rs @@ -10,3 +10,9 @@ fn sample_fuzztest_target(data: i32) { fn another_sample_fuzztest_target(data: i32) { let _ = data; } + +#[fuzztest(_a = Arbitrary::::default())] +fn jobs_test(_a: i32) { + println!("JOBS_TEST_PID: {}", std::process::id()); + std::thread::sleep(std::time::Duration::from_millis(250)); +} diff --git a/rust/cargo_fuzztest/tests/common/mod.rs b/rust/cargo_fuzztest/tests/common/mod.rs new file mode 100644 index 000000000..820ce7b36 --- /dev/null +++ b/rust/cargo_fuzztest/tests/common/mod.rs @@ -0,0 +1,45 @@ +use cargo_fuzztest::FuzztestRunner; +use std::env; +use std::path::PathBuf; + +// Helper to locate compiled sample fuzz test executables across different build environments +// (Cargo tests vs Blaze/Bazel tests). +pub fn get_sample_test_bin_path(crate_name: &str) -> PathBuf { + let test_bin_name = format!("{crate_name}_bin"); + + // 1. Cargo test: Check if CARGO_BIN_EXE_ is set by Cargo when running integration tests. + if let Ok(cargo_bin) = env::var(format!("CARGO_BIN_EXE_{test_bin_name}")) { + return PathBuf::from(cargo_bin); + } + + // 2. Bazel/Blaze test: Fall back to TEST_SRCDIR and TEST_WORKSPACE. + if let Ok(src_dir) = env::var("TEST_SRCDIR") { + let test_workspace = env::var("TEST_WORKSPACE").unwrap_or_else(|_| { + "_main".to_string() + }); + + let relative_binary_path = + format!("rust/cargo_fuzztest/{test_bin_name}"); + + let blaze_path = PathBuf::from(src_dir).join(test_workspace).join(relative_binary_path); + assert!(blaze_path.exists()); + return blaze_path; + } + + // 3. Cargo test fallback: Query compiled test executable path via Cargo JSON compiler messages. + if env::var("CARGO_MANIFEST_DIR").is_ok() { + let cargo_bin = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + let output = std::process::Command::new(cargo_bin) + .args(["test", "--no-run", "--message-format=json", "--test", &test_bin_name]) + .output() + .expect("cargo test compilation command should execute successfully"); + assert!(output.status.success()); + + let json_stdout = String::from_utf8_lossy(&output.stdout); + if let Ok(exe) = FuzztestRunner::parse_compiler_messages(&json_stdout) { + return exe; + } + } + + panic!("Could not locate {test_bin_name} via CARGO_BIN_EXE, TEST_SRCDIR, or Cargo compiler messages"); +} diff --git a/rust/cargo_fuzztest/tests/e2e_cli_test.rs b/rust/cargo_fuzztest/tests/e2e_cli_test.rs index 1caaaf6cb..fa73f9944 100644 --- a/rust/cargo_fuzztest/tests/e2e_cli_test.rs +++ b/rust/cargo_fuzztest/tests/e2e_cli_test.rs @@ -1,5 +1,6 @@ use googletest::prelude::*; use std::env; +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; @@ -102,3 +103,334 @@ fn test_cargo_fuzztest_e2e_specific_target() { expect_true!(stdout_str.contains("sample_fuzztest_target")); expect_false!(stdout_str.contains("another_sample_fuzztest_target")); } + +#[gtest] +fn test_cargo_fuzztest_e2e_parallel_jobs() { + let sample_crate_path = get_sample_crate_path("sample_fuzz_crate"); + + let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory"); + + let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH") + .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be set for the test"); + + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg("__fuzztest_mod__jobs_test::jobs_test") + .arg("--jobs") + .arg("4") + .arg("--fuzz-for") + .arg("5s") + .env_remove("FUZZTEST_CENTIPEDE_BINARY_PATH") + .arg("--centipede-binary-path") + .arg(centipede_bin) + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true"); + + let output = cmd.output().expect("running local cargo-fuzztest sub-process should complete"); + + expect_true!(output.status.success()); + + let stderr_str = String::from_utf8_lossy(&output.stderr); + use std::collections::HashSet; + let mut pids = HashSet::new(); + for line in stderr_str.lines() { + if let Some(pos) = line.find("LOG: JOBS_TEST_PID: ") { + let pid_str = &line[pos + "LOG: JOBS_TEST_PID: ".len()..]; + if let Ok(pid) = pid_str.parse::() { + pids.insert(pid); + } + } + } + expect_that!(pids.len(), eq(4)); +} + +#[gtest] +fn test_cargo_fuzztest_e2e_replay_by_id() { + let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH") + .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be set for the test"); + + let sample_crate_path = get_sample_crate_path("another_sample_fuzz_crate"); + + let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory"); + fs::create_dir_all(&temp_target_dir).expect("Failed to corpus db directory"); + + let temp_db_dir = TempDir::new().expect("Failed to create temporary corpus db directory"); + fs::create_dir_all(&temp_db_dir).expect("Failed to corpus db directory"); + + let workdir_root_dir = + TempDir::new().expect("Failed to create temporary workdir_root directory"); + fs::create_dir_all(&workdir_root_dir).expect("Failed to workdir_root directory"); + + let test_target = "__fuzztest_mod__crashing_fuzztest_target::crashing_fuzztest_target"; + let normalized_test_name = test_target.replace("::", "."); + + // 1. Retrieve the target binary path. + let host_triple = cargo_fuzztest::get_host_target_triple() + .expect("Failed to get host target triple for compilation"); + let runner = cargo_fuzztest::FuzztestRunner::new( + host_triple, + cargo_fuzztest::CargoFuzzTestOptions::default(), + ); + let mut compile_cmd = runner.build_compile_command(); + compile_cmd.current_dir(&sample_crate_path).env("CARGO_TARGET_DIR", temp_target_dir.path()); + + let compile_output = compile_cmd.output().expect("Failed to execute cargo compilation command"); + assert!(compile_output.status.success()); + let json_stdout = String::from_utf8(compile_output.stdout) + .expect("Cargo compilation stdout must be valid UTF-8"); + let target_binary_path = cargo_fuzztest::FuzztestRunner::parse_compiler_messages(&json_stdout) + .expect("Failed to parse target binary path from cargo JSON output"); + + let target_binary_str = target_binary_path.to_str().expect("Valid binary path string"); + let binary_id = target_binary_str.strip_prefix('/').unwrap_or(target_binary_str); + + // 2. Run Centipede to fuzz the target and populate the corpus database. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg(test_target) + .arg("--fuzz-for=5s") + .env_remove("FUZZTEST_CENTIPEDE_BINARY_PATH") + .arg("--centipede-binary-path") + .arg(¢ipede_bin) + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--workdir-root") + .arg(workdir_root_dir.path()); + + let output = cmd.output().expect("Failed to run cargo-fuzztest to fuzz target"); + assert!(output.status.success()); + + // 3. Get list of crash ids + let list_temp_dir = TempDir::new().expect("Failed to create temporary list directory"); + let crash_ids_file = list_temp_dir.path().join("crash_ids.txt"); + + let list_args = [ + format!("--binary={}", target_binary_path.display()), + format!("--fuzztest_binary_identifier={}", binary_id), + format!("--test_name={}", normalized_test_name), + format!("--fuzztest_corpus_database={}", temp_db_dir.path().display()), + "--list_crash_ids=1".to_string(), + format!("--list_crash_ids_file={}", crash_ids_file.display()), + ]; + let list_args_refs: Vec<&str> = list_args.iter().map(|s| s.as_str()).collect(); + run_centipede_with_args_expect_termination(¢ipede_bin, &list_args_refs); + + let crash_ids_contents = + fs::read_to_string(&crash_ids_file).expect("Failed to read crash IDs file"); + let crash_ids: Vec<&str> = + crash_ids_contents.lines().filter(|line| !line.trim().is_empty()).collect(); + + expect_true!(!crash_ids.is_empty()); + let crash_id = crash_ids[0]; + + // 4. Run cargo-fuzztest CLI with --replay-id to verify it replays the crash. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg(test_target) + .arg("--replay-id") + .arg(crash_id) + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--workdir-root") + .arg(workdir_root_dir.path()) + .arg("--centipede-binary-path") + .arg(¢ipede_bin); + + let output = cmd.output().expect("Failed to run cargo-fuzztest in replay mode"); + + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + expect_false!(output.status.success()); + expect_true!( + stderr_str.contains("FuzzTest controller reported failure") + || stderr_str.contains("Crashing bug found!") + || stdout_str.contains("FuzzTest controller reported failure") + ); +} + +#[gtest] +fn test_cargo_fuzztest_e2e_replay_all_crashes() { + let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH") + .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be provided"); + + let sample_crate_path = get_sample_crate_path("another_sample_fuzz_crate"); + + let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory"); + fs::create_dir_all(&temp_target_dir).expect("Failed to create target directory"); + + let temp_db_dir = TempDir::new().expect("Failed to create temporary corpus db directory"); + fs::create_dir_all(&temp_db_dir).expect("Failed to create corpus db directory"); + + let workdir_root_dir = + TempDir::new().expect("Failed to create temporary workdir_root directory"); + fs::create_dir_all(&workdir_root_dir).expect("Failed to create workdir_root directory"); + + let test_target = "__fuzztest_mod__crashing_fuzztest_target::crashing_fuzztest_target"; + + // 1. Run Centipede via cargo-fuzztest to fuzz the target and populate the corpus database. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg(test_target) + .arg("--fuzz-for=5s") + .env_remove("FUZZTEST_CENTIPEDE_BINARY_PATH") + .arg("--centipede-binary-path") + .arg(¢ipede_bin) + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--workdir-root") + .arg(workdir_root_dir.path()); + let output = cmd.output().expect("Failed to run cargo-fuzztest to fuzz target"); + assert!(output.status.success()); + + // 2. Run cargo-fuzztest CLI with --replay-findings to verify it replays all crashes from corpus db. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg(test_target) + .arg("--replay-findings") + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--centipede-binary-path") + .arg(¢ipede_bin); + + let output = cmd.output().expect("Failed to run cargo-fuzztest in replay-findings mode"); + + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + expect_true!(output.status.success()); + expect_true!( + stderr_str.contains("Crashing bug found!") || stdout_str.contains("Crashing bug found!") + ); +} + +#[gtest] +fn test_cargo_fuzztest_e2e_replay_corpus() { + let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH") + .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be provided"); + + let sample_crate_path = get_sample_crate_path("sample_fuzz_crate"); + + let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory"); + fs::create_dir_all(&temp_target_dir).expect("Failed to create target directory"); + + let temp_db_dir = TempDir::new().expect("Failed to create temporary corpus db directory"); + fs::create_dir_all(&temp_db_dir).expect("Failed to create corpus db directory"); + + let workdir_root_dir = + TempDir::new().expect("Failed to create temporary workdir_root directory"); + fs::create_dir_all(&workdir_root_dir).expect("Failed to create workdir_root directory"); + + // 1. Run Centipede via cargo-fuzztest to fuzz and populate the corpus database. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg("__fuzztest_mod__sample_fuzztest_target::sample_fuzztest_target") + .arg("--fuzz-for=3s") + .env_remove("FUZZTEST_CENTIPEDE_BINARY_PATH") + .arg("--centipede-binary-path") + .arg(¢ipede_bin) + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--workdir-root") + .arg(workdir_root_dir.path()); + let output = cmd.output().expect("Failed to run cargo-fuzztest to fuzz target"); + assert!(output.status.success()); + + // 2. Run cargo-fuzztest CLI with --replay-corpus-for to verify it replays the corpus. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg("__fuzztest_mod__sample_fuzztest_target::sample_fuzztest_target") + .arg("--replay-corpus-for=2s") + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--centipede-binary-path") + .arg(¢ipede_bin); + + let output = cmd.output().expect("Failed to run cargo-fuzztest in replay-corpus mode"); + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + + expect_true!(output.status.success()); + expect_true!( + stderr_str.contains( + "Replaying __fuzztest_mod__sample_fuzztest_target.sample_fuzztest_target for 2s" + ) || stdout_str.contains( + "Replaying __fuzztest_mod__sample_fuzztest_target.sample_fuzztest_target for 2s" + ) + ); +} + +#[gtest] +fn test_cargo_fuzztest_e2e_replay_corpus_total_budget() { + let centipede_bin = env::var("FUZZTEST_CENTIPEDE_BINARY_PATH") + .expect("FUZZTEST_CENTIPEDE_BINARY_PATH needs to be provided"); + + let sample_crate_path = get_sample_crate_path("sample_fuzz_crate"); + + let temp_target_dir = TempDir::new().expect("Failed to create temporary target directory"); + fs::create_dir_all(&temp_target_dir).expect("Failed to create target directory"); + + let temp_db_dir = TempDir::new().expect("Failed to create temporary corpus db directory"); + fs::create_dir_all(&temp_db_dir).expect("Failed to create corpus db directory"); + + let workdir_root_dir = + TempDir::new().expect("Failed to create temporary workdir_root directory"); + fs::create_dir_all(&workdir_root_dir).expect("Failed to create workdir_root directory"); + + // 1. Run Centipede via cargo-fuzztest to fuzz and populate the corpus database. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg("__fuzztest_mod__sample_fuzztest_target::sample_fuzztest_target") + .arg("--fuzz-for=3s") + .env_remove("FUZZTEST_CENTIPEDE_BINARY_PATH") + .arg("--centipede-binary-path") + .arg(¢ipede_bin) + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--workdir-root") + .arg(workdir_root_dir.path()); + let output = cmd.output().expect("Failed to run cargo-fuzztest to fuzz target"); + assert!(output.status.success()); + + // 2. Run cargo-fuzztest CLI with --replay-corpus-for and --time-budget-type total. + let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); + cmd.arg("__fuzztest_mod__sample_fuzztest_target::sample_fuzztest_target") + .arg("--replay-corpus-for=3s") + .arg("--time-budget-type=total") + .arg("--corpus-db") + .arg(temp_db_dir.path()) + .arg("--centipede-binary-path") + .arg(¢ipede_bin); + + let output = cmd.output().expect("Failed to run cargo-fuzztest in replay-corpus mode"); + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + + expect_true!(output.status.success()); + expect_true!( + stderr_str.contains( + "Replaying __fuzztest_mod__sample_fuzztest_target.sample_fuzztest_target for 1s" + ) || stdout_str.contains( + "Replaying __fuzztest_mod__sample_fuzztest_target.sample_fuzztest_target for 1s" + ) + ); +} + +fn run_centipede_with_args_expect_termination(centipede_bin: &str, args: &[&str]) -> String { + // Disable interference from Bazel environment variables. + let env_diff = [ + "-TEST_DIAGNOSTICS_OUTPUT_DIR", + "-TEST_INFRASTRUCTURE_FAILURE_FILE", + "-TEST_LOGSPLITTER_OUTPUT_FILE", + "-TEST_PREMATURE_EXIT_FILE", + "-TEST_RANDOM_SEED", + "-TEST_RUN_NUMBER", + "-TEST_SHARD_INDEX", + "-TEST_SHARD_STATUS_FILE", + "-TEST_TOTAL_SHARDS", + "-TEST_UNDECLARED_OUTPUTS_ANNOTATIONS_DIR", + "-TEST_UNDECLARED_OUTPUTS_DIR", + "-TEST_WARNINGS_OUTPUT_FILE", + "-GTEST_OUTPUT", + "-XML_OUTPUT_FILE", + ]; + let process = Command::new(centipede_bin) + .arg("--populate_binary_info=0") + .arg("--fork_server=0") + .arg("--persistent_mode=0") + .arg(format!("--env_diff_for_binaries={}", env_diff.join(","))) + .args(args) + .output() + .expect("Centipede should have executed"); + + String::from_utf8_lossy(&process.stderr).to_string() +} diff --git a/rust/cargo_fuzztest/tests/runner_test.rs b/rust/cargo_fuzztest/tests/runner_test.rs index 15b98f8a1..57aaba889 100644 --- a/rust/cargo_fuzztest/tests/runner_test.rs +++ b/rust/cargo_fuzztest/tests/runner_test.rs @@ -1,12 +1,13 @@ +mod common; + use cargo_fuzztest::{CargoFuzzTestOptions, FuzztestRunner}; -use fuzztest_options::{FuzzFor, FuzzTestOptions}; +use common::get_sample_test_bin_path; +use fuzztest_options::{FuzzFor, FuzzTestOptions, TimeBudgetType}; use googletest::prelude::*; -use std::env; -use std::path::PathBuf; #[gtest] fn test_runner_execution() { - let binary_path = get_sample_fuzz_test_bin_path(); + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); assert!(binary_path.exists(), "Sample fuzz binary does not exist at {}", binary_path.display()); @@ -24,7 +25,7 @@ fn test_runner_execution() { #[gtest] fn test_runner_build_run_command_with_target() { - let binary_path = get_sample_fuzz_test_bin_path(); + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); let options = CargoFuzzTestOptions { test_path: Some( "__fuzztest_mod__sample_fuzztest_target::sample_fuzztest_target".to_string(), @@ -43,7 +44,7 @@ fn test_runner_build_run_command_with_target() { #[gtest] fn test_runner_build_run_command_with_duration() { - let binary_path = get_sample_fuzz_test_bin_path(); + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); let fuzztest_options = FuzzTestOptions { fuzz_for: Some(FuzzFor::Duration("5s".parse().unwrap())), ..Default::default() @@ -65,7 +66,7 @@ fn test_runner_build_run_command_with_duration() { #[gtest] fn test_runner_build_run_command_with_indefinitely() { - let binary_path = get_sample_fuzz_test_bin_path(); + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); let fuzztest_options = FuzzTestOptions { fuzz_for: Some(FuzzFor::Indefinitely), ..Default::default() }; let options = CargoFuzzTestOptions { @@ -85,8 +86,74 @@ fn test_runner_build_run_command_with_indefinitely() { #[gtest] fn test_runner_build_run_command_with_centipede_binary_path() { - let binary_path = get_sample_fuzz_test_bin_path(); + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let options = CargoFuzzTestOptions { + centipede_binary_path: Some("/custom/path/to/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = runner.build_run_command(&binary_path).expect("valid run command"); + + let envs: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))) + .collect(); + expect_true!(envs.contains(&( + "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(), + Some("/custom/path/to/centipede".to_string()) + ))); +} + +#[gtest] +fn test_runner_build_run_command_with_jobs() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { + jobs: Some(4), + fuzz_for: Some(FuzzFor::Duration("10s".parse().expect("static valid duration string"))), + ..Default::default() + }; + let options = CargoFuzzTestOptions { + fuzztest_options, + centipede_binary_path: Some("/custom/path/to/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = runner.build_run_command(&binary_path).expect("valid run command"); + + let envs: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))) + .collect(); + expect_true!(envs.contains(&("FUZZTEST_JOBS".to_string(), Some("4".to_string())))); + expect_true!(envs.contains(&("FUZZTEST_FUZZ_FOR".to_string(), Some("10s".to_string())))); +} + +#[gtest] +fn test_runner_build_run_command_with_jobs_only() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { jobs: Some(4), ..Default::default() }; + let options = CargoFuzzTestOptions { fuzztest_options, ..Default::default() }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = runner.build_run_command(&binary_path).expect("valid run command"); + + let envs: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))) + .collect(); + expect_false!(envs.iter().any(|(k, _)| k == "FUZZTEST_JOBS")); + expect_false!(envs.iter().any(|(k, _)| k == "FUZZTEST_FUZZ_FOR")); +} + +#[gtest] +fn test_runner_build_run_command_with_replay_id() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { + replay_id: Some("crash_12345".to_string()), + corpus_db: Some("/custom/path/to/corpus_db".to_string()), + ..Default::default() + }; let options = CargoFuzzTestOptions { + fuzztest_options, centipede_binary_path: Some("/custom/path/to/centipede".to_string()), ..Default::default() }; @@ -97,6 +164,13 @@ fn test_runner_build_run_command_with_centipede_binary_path() { .get_envs() .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))) .collect(); + expect_true!( + envs.contains(&("FUZZTEST_REPLAY_ID".to_string(), Some("crash_12345".to_string()))) + ); + expect_true!(envs.contains(&( + "FUZZTEST_CORPUS_DB".to_string(), + Some("/custom/path/to/corpus_db".to_string()) + ))); expect_true!(envs.contains(&( "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(), Some("/custom/path/to/centipede".to_string()) @@ -104,17 +178,133 @@ fn test_runner_build_run_command_with_centipede_binary_path() { } #[gtest] -fn test_execution_mode_without_centipede_binary_path_errors() { +fn test_execution_mode_replay_id_missing_corpus_db_errors() { let fuzztest_options = - FuzzTestOptions { fuzz_for: Some(FuzzFor::Indefinitely), ..Default::default() }; + FuzzTestOptions { replay_id: Some("crash_12345".to_string()), ..Default::default() }; + let options = CargoFuzzTestOptions { + fuzztest_options, + centipede_binary_path: Some("/custom/path/to/centipede".to_string()), + ..Default::default() + }; + let result = options.execution_mode(); + expect_true!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + expect_true!(err_msg.contains("`--corpus-db` needs to be specified")); +} + +#[gtest] +fn test_execution_mode_replay_id_missing_centipede_binary_path_errors() { + let fuzztest_options = FuzzTestOptions { + replay_id: Some("crash_12345".to_string()), + corpus_db: Some("/custom/path/to/corpus_db".to_string()), + ..Default::default() + }; let options = CargoFuzzTestOptions { fuzztest_options, ..Default::default() }; let result = options.execution_mode(); expect_true!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + expect_true!(err_msg.contains("`--centipede-binary-path` needs to be specified")); +} + +#[gtest] +fn test_runner_build_run_command_with_replay_findings() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { + replay_findings: true, + corpus_db: Some("/custom/path/to/corpus_db".to_string()), + ..Default::default() + }; + let options = CargoFuzzTestOptions { + fuzztest_options, + centipede_binary_path: Some("/custom/path/to/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = runner.build_run_command(&binary_path).expect("valid run command"); + + let envs: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))) + .collect(); + expect_true!(envs.contains(&("FUZZTEST_REPLAY_FINDINGS".to_string(), Some("true".to_string())))); + expect_true!(envs.contains(&( + "FUZZTEST_CORPUS_DB".to_string(), + Some("/custom/path/to/corpus_db".to_string()) + ))); + expect_true!(envs.contains(&( + "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(), + Some("/custom/path/to/centipede".to_string()) + ))); +} + +#[gtest] +fn test_execution_mode_replay_findings_missing_centipede_binary_path_errors() { + let fuzztest_options = FuzzTestOptions { + replay_findings: true, + corpus_db: Some("/custom/path/to/corpus_db".to_string()), + ..Default::default() + }; + let options = CargoFuzzTestOptions { fuzztest_options, ..Default::default() }; + let result = options.execution_mode(); + expect_true!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + expect_true!(err_msg.contains("`--centipede-binary-path` needs to be specified")); +} + +#[gtest] +fn test_runner_build_run_command_with_replay_corpus() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { + replay_corpus_for: Some("10s".parse().expect("valid duration")), + time_budget_type: TimeBudgetType::Total, + corpus_db: Some("/custom/path/to/corpus_db".to_string()), + ..Default::default() + }; + let options = CargoFuzzTestOptions { + fuzztest_options, + centipede_binary_path: Some("/custom/path/to/centipede".to_string()), + ..Default::default() + }; + let runner = FuzztestRunner::new("x86_64-unknown-linux-gnu".to_string(), options); + let cmd = runner.build_run_command(&binary_path).expect("valid run command"); + + let envs: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| (k.to_string_lossy().to_string(), v.map(|s| s.to_string_lossy().to_string()))) + .collect(); + expect_true!( + envs.contains(&("FUZZTEST_REPLAY_CORPUS_FOR".to_string(), Some("10s".to_string()))) + ); + expect_true!( + envs.contains(&("FUZZTEST_TIME_BUDGET_TYPE".to_string(), Some("total".to_string()))) + ); + expect_true!(envs.contains(&( + "FUZZTEST_CORPUS_DB".to_string(), + Some("/custom/path/to/corpus_db".to_string()) + ))); + expect_true!(envs.contains(&( + "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(), + Some("/custom/path/to/centipede".to_string()) + ))); +} + +#[gtest] +fn test_execution_mode_replay_corpus_missing_centipede_binary_path_errors() { + let fuzztest_options = FuzzTestOptions { + replay_corpus_for: Some("10s".parse().expect("valid duration")), + corpus_db: Some("/custom/path/to/corpus_db".to_string()), + ..Default::default() + }; + let options = CargoFuzzTestOptions { fuzztest_options, ..Default::default() }; + let result = options.execution_mode(); + expect_true!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + expect_true!(err_msg.contains("`--centipede-binary-path` needs to be specified")); } #[gtest] fn test_runner_list_command() { - let binary_path = get_sample_fuzz_test_bin_path(); + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); assert!(binary_path.exists(), "Sample fuzz binary does not exist at {}", binary_path.display()); @@ -127,43 +317,3 @@ fn test_runner_list_command() { let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().to_string()).collect(); expect_eq!(args, &["__fuzztest_mod__", "--list"]); } - -fn get_sample_fuzz_test_bin_path() -> PathBuf { - // 1. Cargo test: Check if CARGO_BIN_EXE_ is set - if let Ok(cargo_bin) = env::var("CARGO_BIN_EXE_sample_fuzz_test_bin") { - return PathBuf::from(cargo_bin); - } - - // 2. Bazel/Blaze test: Fall back to TEST_SRCDIR and TEST_WORKSPACE - if let Ok(src_dir) = env::var("TEST_SRCDIR") { - let test_workspace = env::var("TEST_WORKSPACE").unwrap_or_else(|_| { - "_main".to_string() - }); - - const RELATIVE_BINARY_PATH: &str = - "rust/cargo_fuzztest/sample_fuzz_test_bin"; - - let blaze_path = PathBuf::from(src_dir).join(test_workspace).join(RELATIVE_BINARY_PATH); - if blaze_path.exists() { - return blaze_path; - } - } - - // 2. Cargo test fallback: Query compiled test executable path via Cargo JSON compiler messages - if env::var("CARGO_MANIFEST_DIR").is_ok() { - let cargo_bin = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); - let output = std::process::Command::new(cargo_bin) - .args(["test", "--no-run", "--message-format=json", "--test", "sample_fuzz_test_bin"]) - .output() - .expect("Failed to execute `cargo test --no-run --message-format=json` to locate test binary"); - - if output.status.success() { - let json_stdout = String::from_utf8_lossy(&output.stdout); - if let Ok(exe) = FuzztestRunner::parse_compiler_messages(&json_stdout) { - return exe; - } - } - } - - panic!("Could not locate sample_fuzz_test_bin via CARGO_BIN_EXE, TEST_SRCDIR, or Cargo compiler messages"); -} diff --git a/rust/options/src/lib.rs b/rust/options/src/lib.rs index f211dbcae..d072ceff3 100644 --- a/rust/options/src/lib.rs +++ b/rust/options/src/lib.rs @@ -71,11 +71,11 @@ pub struct FuzzTestOptions { pub replay_id: Option, /// Replay all crashing inputs from the corpus database. - #[arg(env = "FUZZTEST_REPLAY_FINDINGS", long)] + #[arg(env = "FUZZTEST_REPLAY_FINDINGS", long, requires = "corpus_db")] pub replay_findings: bool, /// Replay the corpus for a specified duration. - #[arg(env = "FUZZTEST_REPLAY_CORPUS_FOR", long)] + #[arg(env = "FUZZTEST_REPLAY_CORPUS_FOR", long, requires = "corpus_db")] pub replay_corpus_for: Option, /// Time budget calculation type for replay corpus mode. @@ -124,6 +124,7 @@ impl ExecutionMode { return ExecutionMode::ReplayCorpus(ReplayCorpusOptions { replay_corpus_for, time_budget_type: options.time_budget_type, + jobs: options.jobs.clone(), }); } @@ -135,8 +136,12 @@ impl ExecutionMode { return ExecutionMode::ReplayCrash(ReplayCrashOptions { replay_id: replay_id.clone() }); } - if let Some(fuzz_for) = options.fuzz_for { - return ExecutionMode::Fuzz(FuzzOptions { fuzz_for, jobs: options.jobs.clone() }); + // Continuous fuzzing mode is selected if an explicit duration/budget (`fuzz_for`) is specified. + if let Some(fuzz_for) = &options.fuzz_for { + return ExecutionMode::Fuzz(FuzzOptions { + fuzz_for: *fuzz_for, + jobs: options.jobs.clone(), + }); } ExecutionMode::SmokeTest @@ -174,6 +179,9 @@ pub struct ReplayCrashOptions { pub struct ReplayCorpusOptions { pub replay_corpus_for: Duration, pub time_budget_type: TimeBudgetType, + /// If `jobs` is `None`, we won't specify the number of jobs while invoking Centipede and it + /// will use its own default value. + pub jobs: Option, } #[cfg(test)] @@ -222,4 +230,187 @@ mod tests { expect_that!(options.replay_id.as_deref(), eq(Some("my_crash_123"))); expect_that!(options.corpus_db.as_deref(), eq(Some("/tmp/corpus_db"))); } + + #[gtest] + fn test_jobs_options_parsing_env() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_JOBS", "4"); + } + + let options = FuzzTestOptions::parse_from(std::iter::empty::()); + + expect_that!(options.jobs, eq(Some(4))); + // Setting jobs alone should not enter fuzzing mode; it defaults to smoke test mode. + expect_that!(ExecutionMode::from_fuzztest_options(&options), eq(&ExecutionMode::SmokeTest)); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_JOBS"); + } + } + + #[gtest] + fn test_jobs_with_fuzz_for_parsing_env() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_JOBS", "4"); + std::env::set_var("FUZZTEST_FUZZ_FOR", "5s"); + } + + let options = FuzzTestOptions::parse_from(std::iter::empty::()); + + expect_that!(options.jobs, eq(Some(4))); + let expected_duration = "5s".parse().expect("valid duration"); + expect_that!( + ExecutionMode::from_fuzztest_options(&options), + eq(&ExecutionMode::Fuzz(FuzzOptions { + fuzz_for: FuzzFor::Duration(expected_duration), + jobs: Some(4), + })) + ); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_JOBS"); + std::env::remove_var("FUZZTEST_FUZZ_FOR"); + } + } + + #[gtest] + fn test_jobs_with_replay_corpus_parsing_env() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_JOBS", "4"); + std::env::set_var("FUZZTEST_REPLAY_CORPUS_FOR", "10s"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + } + + let options = FuzzTestOptions::parse_from(std::iter::empty::()); + + expect_that!(options.jobs, eq(Some(4))); + let expected_duration = "10s".parse().expect("valid duration string"); + expect_that!( + ExecutionMode::from_fuzztest_options(&options), + eq(&ExecutionMode::ReplayCorpus(ReplayCorpusOptions { + replay_corpus_for: expected_duration, + time_budget_type: TimeBudgetType::PerTest, + jobs: Some(4), + })) + ); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_JOBS"); + std::env::remove_var("FUZZTEST_REPLAY_CORPUS_FOR"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + } + + #[gtest] + fn test_replay_findings_requires_corpus_db() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_FINDINGS", "true"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_REPLAY_FINDINGS"); + } + + let err = result.expect_err("parsing should fail when corpus_db is missing"); + expect_that!(err.kind(), eq(clap::error::ErrorKind::MissingRequiredArgument)); + } + + #[gtest] + fn test_replay_findings_with_corpus_db_succeeds() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_FINDINGS", "true"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_REPLAY_FINDINGS"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + + let options = result + .expect("parsing should succeed when both replay_findings and corpus_db are present"); + expect_that!(options.replay_findings, eq(true)); + expect_that!(options.corpus_db.as_deref(), eq(Some("/tmp/corpus_db"))); + } + + #[gtest] + fn test_replay_corpus_for_requires_corpus_db() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_CORPUS_FOR", "10s"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_REPLAY_CORPUS_FOR"); + } + + let err = result.expect_err("parsing should fail when corpus_db is missing"); + expect_that!(err.kind(), eq(clap::error::ErrorKind::MissingRequiredArgument)); + } + + #[gtest] + fn test_replay_corpus_for_with_corpus_db_succeeds() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_CORPUS_FOR", "10s"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_REPLAY_CORPUS_FOR"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + + let options = result + .expect("parsing should succeed when both replay_corpus_for and corpus_db are present"); + expect_that!(options.replay_corpus_for, eq(Some("10s".parse().unwrap()))); + expect_that!(options.corpus_db.as_deref(), eq(Some("/tmp/corpus_db"))); + expect_that!(options.time_budget_type, eq(TimeBudgetType::PerTest)); + } + + #[gtest] + fn test_replay_corpus_for_with_total_time_budget() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_CORPUS_FOR", "10s"); + std::env::set_var("FUZZTEST_TIME_BUDGET_TYPE", "total"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_REPLAY_CORPUS_FOR"); + std::env::remove_var("FUZZTEST_TIME_BUDGET_TYPE"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + + let options = result.expect("parsing should succeed with total time budget type"); + expect_that!(options.replay_corpus_for, eq(Some("10s".parse().unwrap()))); + expect_that!(options.corpus_db.as_deref(), eq(Some("/tmp/corpus_db"))); + expect_that!(options.time_budget_type, eq(TimeBudgetType::Total)); + } } diff --git a/rust/src/options.rs b/rust/src/options.rs index 073cde1d5..2c8cbcf0b 100644 --- a/rust/src/options.rs +++ b/rust/src/options.rs @@ -255,6 +255,9 @@ impl CentipedeArgs { add_arg("--fuzztest_replay_coverage_inputs=true".to_string())?; add_arg("--load_shards_only=true".to_string())?; add_arg(format!("--fuzztest_time_limit_per_test={time_limit}"))?; + if let Some(jobs) = &replay_corpus_opts.jobs { + add_arg(format!("--j={jobs}"))?; + } } ExecutionMode::SmokeTest => unreachable!(), ExecutionMode::ListFuzzTests => unreachable!(), @@ -604,6 +607,30 @@ mod tests { .any(|s| s.starts_with("--binary=") && s.contains("my_mod::my_test --exact"))); assert!(args_str.contains(&"--fuzztest_only_replay=true")); assert!(args_str.contains(&"--fuzztest_time_limit_per_test=10s")); + assert!(!args_str.iter().any(|s| s.starts_with("--j="))); + } + + #[gtest] + fn test_determine_execution_action_replay_corpus_with_jobs() { + let expected_duration = "10s".parse().expect("failed to parse duration"); + let options = FuzzTestOptions { + replay_corpus_for: Some(expected_duration), + jobs: Some(4), + time_budget_type: TimeBudgetType::PerTest, + ..Default::default() + }; + let action = determine_execution_action_internal(&options, "my_mod::my_test"); + + let ExecutionAction::Standalone(args) = action else { + panic!("Expected Standalone action"); + }; + + let args_str: Vec<&str> = + args._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect(); + + assert!(args_str.contains(&"--j=4")); + assert!(args_str.contains(&"--fuzztest_only_replay=true")); + assert!(args_str.contains(&"--fuzztest_time_limit_per_test=10s")); } #[gtest]