cargo-fuzztest: support replaying all crashes from corpus - Add requires = "corpus_db" to replay_findings in FuzzTestOptions. - Support ExecutionMode::ReplayAllCrashes in CargoFuzzTestOptions. - Set FUZZTEST_REPLAY_FINDINGS environment variable in FuzztestRunner. - Add unit, runner, and end-to-end integration tests for replaying all crashes. PiperOrigin-RevId: 966003938
diff --git a/rust/cargo_fuzztest/src/lib.rs b/rust/cargo_fuzztest/src/lib.rs index 78b6942..d3a9c4e 100644 --- a/rust/cargo_fuzztest/src/lib.rs +++ b/rust/cargo_fuzztest/src/lib.rs
@@ -82,7 +82,7 @@ self.check_centipede_binary_path_is_set()?; mode } - ExecutionMode::ReplayCrash(_) => { + ExecutionMode::ReplayAllCrashes | ExecutionMode::ReplayCrash(_) => { self.check_centipede_binary_path_is_set()?; self.check_corpus_db_is_set()?; mode @@ -259,6 +259,10 @@ cmd.env("FUZZTEST_REPLAY_ID", replay_options.replay_id); } + ExecutionMode::ReplayAllCrashes => { + cmd.env("FUZZTEST_REPLAY_FINDINGS", "true"); + } + ExecutionMode::SmokeTest => { // nothing to be done } @@ -328,6 +332,7 @@ mod tests { use super::*; use googletest::prelude::*; + use std::collections::HashMap; #[gtest] fn test_parse_host_triple_valid() { @@ -478,20 +483,88 @@ let cmd = runner.build_run_command(Path::new("/tmp/test_bin")).expect("should build run command"); - let envs: Vec<(String, Option<String>)> = cmd + let envs: HashMap<String, Option<String>> = 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_ID".to_string(), Some("crash_12345".to_string())))); - assert!( - envs.contains(&("FUZZTEST_CORPUS_DB".to_string(), Some("/tmp/corpus_db".to_string()))) + 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") ); - assert!(envs.contains(&( - "FUZZTEST_CENTIPEDE_BINARY_PATH".to_string(), - Some("/custom/centipede".to_string()) - ))); + 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<String, Option<String>> = 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") + ); } }
diff --git a/rust/cargo_fuzztest/tests/e2e_cli_test.rs b/rust/cargo_fuzztest/tests/e2e_cli_test.rs index 221a21d..7541d5e 100644 --- a/rust/cargo_fuzztest/tests/e2e_cli_test.rs +++ b/rust/cargo_fuzztest/tests/e2e_cli_test.rs
@@ -186,7 +186,7 @@ let mut cmd = setup_cargo_fuzztest_command(&sample_crate_path, temp_target_dir.path()); cmd.arg(test_target) .arg("--fuzz-for=5s") - .env_remove("CENTIPEDE_BINARY_PATH") + .env_remove("FUZZTEST_CENTIPEDE_BINARY_PATH") .arg("--centipede-binary-path") .arg(¢ipede_bin) .arg("--corpus-db") @@ -244,6 +244,58 @@ ); } +#[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!") + ); +} + fn run_centipede_with_args_expect_termination(centipede_bin: &str, args: &[&str]) -> String { // Disable interference from Bazel environment variables. let env_diff = [
diff --git a/rust/cargo_fuzztest/tests/runner_test.rs b/rust/cargo_fuzztest/tests/runner_test.rs index b45852c..8498c43 100644 --- a/rust/cargo_fuzztest/tests/runner_test.rs +++ b/rust/cargo_fuzztest/tests/runner_test.rs
@@ -207,6 +207,51 @@ } #[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<String>)> = 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_list_command() { let binary_path = get_sample_test_bin_path("sample_fuzz_crate");
diff --git a/rust/options/src/lib.rs b/rust/options/src/lib.rs index 78145e5..94c1b58 100644 --- a/rust/options/src/lib.rs +++ b/rust/options/src/lib.rs
@@ -71,7 +71,7 @@ pub replay_id: Option<String>, /// 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. @@ -304,4 +304,45 @@ std::env::remove_var("FUZZTEST_REPLAY_CORPUS_FOR"); } } + + #[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::<OsString>()); + + // 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::<OsString>()); + + // 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"))); + } }