diff --git a/rust/cargo_fuzztest/src/lib.rs b/rust/cargo_fuzztest/src/lib.rs index bdfa67fc7..fe9afcd5e 100644 --- a/rust/cargo_fuzztest/src/lib.rs +++ b/rust/cargo_fuzztest/src/lib.rs @@ -101,6 +101,7 @@ impl CargoFuzzTestOptions { .unwrap_or(RunDuration::Indefinitely), jobs: self.fuzztest_options.jobs, continue_after_crash: self.fuzztest_options.continue_after_crash, + execution_id: self.fuzztest_options.execution_id.clone(), }) } else { mode @@ -249,7 +250,8 @@ impl FuzztestRunner { } ExecutionMode::Fuzz(fuzz_options) => { - let FuzzOptions { fuzz_for, jobs, continue_after_crash } = fuzz_options; + let FuzzOptions { fuzz_for, jobs, continue_after_crash, execution_id } = + fuzz_options; cmd.env("FUZZTEST_FUZZ_FOR", fuzz_for.to_string()); if let Some(jobs) = jobs { cmd.env("FUZZTEST_JOBS", jobs.to_string()); @@ -257,6 +259,9 @@ impl FuzztestRunner { if continue_after_crash { cmd.env("FUZZTEST_CONTINUE_AFTER_CRASH", "true"); } + if let Some(execution_id) = execution_id { + cmd.env("FUZZTEST_EXECUTION_ID", execution_id); + } } ExecutionMode::ReplayCrash(replay_options) => { @@ -280,6 +285,9 @@ impl FuzztestRunner { if replay_corpus_options.continue_after_crash { cmd.env("FUZZTEST_CONTINUE_AFTER_CRASH", "true"); } + if let Some(execution_id) = replay_corpus_options.execution_id { + cmd.env("FUZZTEST_EXECUTION_ID", execution_id); + } } ExecutionMode::ListCrashIds(list_crash_ids_options) => { @@ -617,6 +625,7 @@ mod tests { time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -646,6 +655,7 @@ mod tests { time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -672,6 +682,7 @@ mod tests { time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -702,6 +713,7 @@ mod tests { time_budget_type: TimeBudgetType::Total, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -912,6 +924,7 @@ mod tests { fuzz_for: RunDuration::Fixed(expected_duration), jobs: None, continue_after_crash: true, + execution_id: None, }) ); } @@ -972,4 +985,64 @@ mod tests { ); assert!(envs.contains(&("FUZZTEST_REPLAY_CORPUS_FOR".to_string(), Some("10s".to_string())))); } + + #[gtest] + fn test_build_run_command_fuzz_with_execution_id() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + fuzz_for: Some("5s".parse().unwrap()), + corpus_db: Some("/tmp/corpus_db".into()), + execution_id: Some("exec_123".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_EXECUTION_ID".to_string(), Some("exec_123".to_string())))); + assert!( + envs.contains(&("FUZZTEST_CORPUS_DB".to_string(), Some("/tmp/corpus_db".to_string()))) + ); + assert!(envs.contains(&("FUZZTEST_FUZZ_FOR".to_string(), Some("5s".to_string())))); + } + + #[gtest] + fn test_build_run_command_replay_corpus_with_execution_id() { + let options = CargoFuzzTestOptions { + fuzztest_options: FuzzTestOptions { + replay_corpus_for: Some("10s".parse().expect("valid duration")), + corpus_db: Some("/tmp/corpus_db".into()), + execution_id: Some("exec_456".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_EXECUTION_ID".to_string(), Some("exec_456".to_string())))); + assert!( + envs.contains(&("FUZZTEST_CORPUS_DB".to_string(), Some("/tmp/corpus_db".to_string()))) + ); + assert!(envs.contains(&("FUZZTEST_REPLAY_CORPUS_FOR".to_string(), Some("10s".to_string())))); + } } diff --git a/rust/cargo_fuzztest/tests/runner_test.rs b/rust/cargo_fuzztest/tests/runner_test.rs index 2a224519c..e4c410a5d 100644 --- a/rust/cargo_fuzztest/tests/runner_test.rs +++ b/rust/cargo_fuzztest/tests/runner_test.rs @@ -421,3 +421,65 @@ fn test_execution_mode_list_crash_ids_missing_centipede_binary_path_errors() { 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_execution_id_fuzz() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { + fuzz_for: Some("5s".parse().unwrap()), + corpus_db: Some("/custom/path/to/corpus_db".into()), + execution_id: Some("exec_workflow_123".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("sample-host-triple".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_EXECUTION_ID".to_string(), Some("exec_workflow_123".to_string())))); + expect_true!(envs.contains(&( + "FUZZTEST_CORPUS_DB".to_string(), + Some("/custom/path/to/corpus_db".to_string()) + ))); + expect_true!(envs.contains(&("FUZZTEST_FUZZ_FOR".to_string(), Some("5s".to_string())))); +} + +#[gtest] +fn test_runner_build_run_command_with_execution_id_replay_corpus() { + let binary_path = get_sample_test_bin_path("sample_fuzz_crate"); + let fuzztest_options = FuzzTestOptions { + replay_corpus_for: Some("10s".parse().unwrap()), + corpus_db: Some("/custom/path/to/corpus_db".into()), + execution_id: Some("exec_workflow_456".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("sample-host-triple".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_EXECUTION_ID".to_string(), Some("exec_workflow_456".to_string())))); + expect_true!(envs.contains(&( + "FUZZTEST_CORPUS_DB".to_string(), + Some("/custom/path/to/corpus_db".to_string()) + ))); + expect_true!( + envs.contains(&("FUZZTEST_REPLAY_CORPUS_FOR".to_string(), Some("10s".to_string()))) + ); +} diff --git a/rust/options/src/lib.rs b/rust/options/src/lib.rs index 6f6eabc8d..52d5a3b69 100644 --- a/rust/options/src/lib.rs +++ b/rust/options/src/lib.rs @@ -135,6 +135,13 @@ pub struct FuzzTestOptions { /// crashing inputs regardless of this flag. #[arg(env = "FUZZTEST_CONTINUE_AFTER_CRASH", long)] pub continue_after_crash: bool, + + /// The execution identifier for the fuzz test run. + /// + /// When specified with `corpus_db`, allows resuming interrupted fuzzing or corpus replay + /// sessions, or skipping tests that already finished with the same execution ID. + #[arg(env = "FUZZTEST_EXECUTION_ID", long, requires = "corpus_db")] + pub execution_id: Option, } /// Strongly-typed domain execution mode for test runs. @@ -186,6 +193,7 @@ impl ExecutionMode { time_budget_type: options.time_budget_type, jobs: options.jobs, continue_after_crash: options.continue_after_crash, + execution_id: options.execution_id.clone(), }); } @@ -203,6 +211,7 @@ impl ExecutionMode { fuzz_for: *fuzz_for, jobs: options.jobs, continue_after_crash: options.continue_after_crash, + execution_id: options.execution_id.clone(), }); } @@ -220,6 +229,8 @@ pub struct FuzzOptions { pub jobs: Option, pub continue_after_crash: bool, + + pub execution_id: Option, } /// The duration or limit for fuzzing or replaying corpus. @@ -272,6 +283,7 @@ pub struct ReplayCorpusOptions { /// will use its own default value. pub jobs: Option, pub continue_after_crash: bool, + pub execution_id: Option, } /// Mode-specific options for listing crash IDs from the database. @@ -398,6 +410,7 @@ mod tests { fuzz_for: RunDuration::Fixed(expected_duration), jobs: Some(4), continue_after_crash: false, + execution_id: None, })) ); @@ -428,6 +441,7 @@ mod tests { time_budget_type: TimeBudgetType::PerTest, jobs: Some(4), continue_after_crash: false, + execution_id: None, })) ); @@ -456,6 +470,7 @@ mod tests { fuzz_for: RunDuration::Fixed(expected_duration), jobs: None, continue_after_crash: true, + execution_id: None, })) ); @@ -485,6 +500,7 @@ mod tests { time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: true, + execution_id: None, })) ); @@ -775,4 +791,97 @@ mod tests { })) ); } + + #[gtest] + fn test_execution_id_requires_corpus_db() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_EXECUTION_ID", "exec_123"); + 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_EXECUTION_ID"); + } + + let err = + result.expect_err("parsing should fail when corpus_db is missing for execution_id"); + expect_that!(err.kind(), eq(clap::error::ErrorKind::MissingRequiredArgument)); + } + + #[gtest] + fn test_execution_id_with_corpus_db_and_fuzz_for_succeeds() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_EXECUTION_ID", "exec_123"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + std::env::set_var("FUZZTEST_FUZZ_FOR", "5s"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_EXECUTION_ID"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + std::env::remove_var("FUZZTEST_FUZZ_FOR"); + } + + let options = result.expect( + "parsing should succeed when execution_id, corpus_db, and fuzz_for are present", + ); + expect_that!(options.execution_id.as_deref(), eq(Some("exec_123"))); + expect_that!(options.corpus_db.as_deref(), eq(Some(Path::new("/tmp/corpus_db")))); + + let expected_duration = "5s".parse().expect("valid duration"); + expect_that!( + ExecutionMode::from_fuzztest_options(&options), + eq(&ExecutionMode::Fuzz(FuzzOptions { + fuzz_for: RunDuration::Fixed(expected_duration), + jobs: None, + continue_after_crash: false, + execution_id: Some("exec_123".to_string()), + })) + ); + } + + #[gtest] + fn test_execution_id_with_corpus_db_and_replay_corpus_succeeds() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_EXECUTION_ID", "exec_123"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + std::env::set_var("FUZZTEST_REPLAY_CORPUS_FOR", "10s"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_EXECUTION_ID"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + std::env::remove_var("FUZZTEST_REPLAY_CORPUS_FOR"); + } + + let options = result.expect( + "parsing should succeed when execution_id, corpus_db, and replay_corpus_for are present", + ); + expect_that!(options.execution_id.as_deref(), eq(Some("exec_123"))); + expect_that!(options.corpus_db.as_deref(), eq(Some(Path::new("/tmp/corpus_db")))); + + 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: None, + continue_after_crash: false, + execution_id: Some("exec_123".to_string()), + })) + ); + } } diff --git a/rust/src/options.rs b/rust/src/options.rs index 9f4971827..cb636a139 100644 --- a/rust/src/options.rs +++ b/rust/src/options.rs @@ -232,6 +232,9 @@ impl CentipedeArgs { if !fuzz_opts.continue_after_crash { add_arg("--exit_on_crash".to_string())?; } + if let Some(execution_id) = &fuzz_opts.execution_id { + add_arg(format!("--fuzztest_execution_id={execution_id}"))?; + } } ExecutionMode::ReplayCrash(replay_opts) => { add_arg("--replay_crash".to_string())?; @@ -276,6 +279,9 @@ impl CentipedeArgs { if !replay_corpus_opts.continue_after_crash { add_arg("--exit_on_crash".to_string())?; } + if let Some(execution_id) = &replay_corpus_opts.execution_id { + add_arg(format!("--fuzztest_execution_id={execution_id}"))?; + } } ExecutionMode::ListCrashIds(list_opts) => { add_arg("--list_crash_ids=true".to_string())?; @@ -944,4 +950,52 @@ mod tests { let args_str: Vec<&str> = args._c_strings.iter().map(|s| s.to_str().unwrap()).collect(); expect_false!(args_str.contains(&"--exit_on_crash")); } + + #[gtest] + fn test_determine_execution_action_fuzz_with_execution_id() { + let options = FuzzTestOptions { + fuzz_for: Some("5s".parse().unwrap()), + corpus_db: Some("/tmp/corpus_db".into()), + execution_id: Some("my_exec_123".to_string()), + ..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().unwrap()).collect(); + expect_true!(args_str.contains(&"--fuzztest_execution_id=my_exec_123")); + } + + #[gtest] + fn test_determine_execution_action_replay_corpus_with_execution_id() { + let options = FuzzTestOptions { + replay_corpus_for: Some("5s".parse().unwrap()), + corpus_db: Some("/tmp/corpus_db".into()), + execution_id: Some("my_exec_456".to_string()), + ..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().unwrap()).collect(); + expect_true!(args_str.contains(&"--fuzztest_execution_id=my_exec_456")); + } + + #[gtest] + fn test_determine_execution_action_fuzz_without_execution_id() { + let options = FuzzTestOptions { + fuzz_for: Some("5s".parse().unwrap()), + corpus_db: Some("/tmp/corpus_db".into()), + execution_id: None, + ..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().unwrap()).collect(); + expect_false!(args_str.iter().any(|s| s.starts_with("--fuzztest_execution_id="))); + } }