From feee9353ad9bb3ae3876e0ba6c258cb6d782de05 Mon Sep 17 00:00:00 2001 From: Shashank Sharma Date: Wed, 19 Aug 2026 16:00:57 -0700 Subject: [PATCH] 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 --- rust/cargo_fuzztest/src/lib.rs | 75 +++++++++++++++- rust/cargo_fuzztest/tests/runner_test.rs | 62 +++++++++++++ rust/options/src/lib.rs | 109 +++++++++++++++++++++++ rust/src/options.rs | 54 +++++++++++ 4 files changed, 299 insertions(+), 1 deletion(-) 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="))); + } }