fuzztest-rust | Add support for execution ID Add support for the execution ID flag and environment variable in Rust FuzzTest. This enables CI workflows (such as fuzzing and coverage replay) to resume interrupted runs. - Add execution_id to FuzzTestOptions with requires = "corpus_db", mapping from --execution-id and FUZZTEST_EXECUTION_ID. - Propagate execution_id to FuzzOptions and ReplayCorpusOptions execution modes. - Forward --fuzztest_execution_id to the Centipede controller in CentipedeArgs during fuzzing and corpus replay modes. PiperOrigin-RevId: 967456187
diff --git a/rust/cargo_fuzztest/src/lib.rs b/rust/cargo_fuzztest/src/lib.rs index bdfa67f..fe9afcd 100644 --- a/rust/cargo_fuzztest/src/lib.rs +++ b/rust/cargo_fuzztest/src/lib.rs
@@ -101,6 +101,7 @@ .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 @@ } 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 @@ 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 @@ 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 @@ time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -646,6 +655,7 @@ time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -672,6 +682,7 @@ time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -702,6 +713,7 @@ time_budget_type: TimeBudgetType::Total, jobs: None, continue_after_crash: false, + execution_id: None, }) ); } @@ -912,6 +924,7 @@ fuzz_for: RunDuration::Fixed(expected_duration), jobs: None, continue_after_crash: true, + execution_id: None, }) ); } @@ -972,4 +985,64 @@ ); 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<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_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<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_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 2a22451..e4c410a 100644 --- a/rust/cargo_fuzztest/tests/runner_test.rs +++ b/rust/cargo_fuzztest/tests/runner_test.rs
@@ -421,3 +421,65 @@ 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<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_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<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_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 6f6eabc..52d5a3b 100644 --- a/rust/options/src/lib.rs +++ b/rust/options/src/lib.rs
@@ -135,6 +135,13 @@ /// 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<String>, } /// Strongly-typed domain execution mode for test runs. @@ -186,6 +193,7 @@ 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 @@ 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 jobs: Option<usize>, pub continue_after_crash: bool, + + pub execution_id: Option<String>, } /// The duration or limit for fuzzing or replaying corpus. @@ -272,6 +283,7 @@ /// will use its own default value. pub jobs: Option<usize>, pub continue_after_crash: bool, + pub execution_id: Option<String>, } /// Mode-specific options for listing crash IDs from the database. @@ -398,6 +410,7 @@ fuzz_for: RunDuration::Fixed(expected_duration), jobs: Some(4), continue_after_crash: false, + execution_id: None, })) ); @@ -428,6 +441,7 @@ time_budget_type: TimeBudgetType::PerTest, jobs: Some(4), continue_after_crash: false, + execution_id: None, })) ); @@ -456,6 +470,7 @@ fuzz_for: RunDuration::Fixed(expected_duration), jobs: None, continue_after_crash: true, + execution_id: None, })) ); @@ -485,6 +500,7 @@ time_budget_type: TimeBudgetType::PerTest, jobs: None, continue_after_crash: true, + execution_id: None, })) ); @@ -775,4 +791,97 @@ })) ); } + + #[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::<OsString>()); + + // 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::<OsString>()); + + // 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::<OsString>()); + + // 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 9f49718..cb636a1 100644 --- a/rust/src/options.rs +++ b/rust/src/options.rs
@@ -232,6 +232,9 @@ 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 @@ 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 @@ 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="))); + } }