| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| use crate::internal::FuzzTestRegistration; |
| use ::engine::engine_ffi; |
| use anyhow::Context; |
| use clap::Parser; |
| use std::env; |
| use std::ffi::CString; |
| use std::ffi::OsString; |
| use std::iter; |
| use std::path::Path; |
| use std::sync::OnceLock; |
| use tempfile::{NamedTempFile, TempDir}; |
| |
| pub use fuzztest_options::{ |
| ExecutionMode, FuzzOptions, FuzzTestOptions, ReplayCorpusOptions, ReplayCrashOptions, |
| RunDuration, TimeBudgetType, |
| }; |
| |
| /// Returns a lazily-initialized static reference to the global `FuzzTestOptions`. |
| pub fn get_fuzztest_options() -> &'static FuzzTestOptions { |
| static OPTIONS: OnceLock<FuzzTestOptions> = OnceLock::new(); |
| // We parse FuzzTestOptions from an empty iterator so that clap would parse the options only |
| // from environment variables (like `FUZZTEST_FUZZ_FOR` etc.). We (currently) do not envisage |
| // support for passing flags on cli as the Rust's libtest harness does not support custom |
| // flags. |
| OPTIONS.get_or_init(|| FuzzTestOptions::parse_from(iter::empty::<OsString>())) |
| } |
| |
| trait ExecutionModeExt { |
| /// Prepares the concrete `ExecutionAction` payload for execution by building any required |
| /// `CentipedeArgs` or temporary runtime assets. |
| fn prepare_action( |
| &self, |
| options: &FuzzTestOptions, |
| current_test_name: &str, |
| ) -> anyhow::Result<ExecutionAction>; |
| } |
| |
| impl ExecutionModeExt for ExecutionMode { |
| /// Prepares the concrete `ExecutionAction` payload for execution by building any required |
| /// `CentipedeArgs` or temporary runtime assets. |
| fn prepare_action( |
| &self, |
| options: &FuzzTestOptions, |
| current_test_name: &str, |
| ) -> anyhow::Result<ExecutionAction> { |
| match self { |
| ExecutionMode::SmokeTest => Ok(ExecutionAction::SmokeTest), |
| ExecutionMode::Fuzz(_) |
| | ExecutionMode::ReplayCrash(_) |
| | ExecutionMode::ReplayCorpus(_) |
| | ExecutionMode::ListCrashIds(_) => { |
| let centipede_args = |
| CentipedeArgs::from_execution_mode(self, options, current_test_name, None)? |
| .context( |
| "while attempting to build CentipedeArgs for standalone execution mode", |
| )?; |
| Ok(ExecutionAction::Standalone(centipede_args)) |
| } |
| ExecutionMode::ReplayAllCrashes => { |
| let list_file = NamedTempFile::new() |
| .context("while attempting to create temp file for ReplayAllCrashes")?; |
| let centipede_args = CentipedeArgs::from_execution_mode( |
| self, |
| options, |
| current_test_name, |
| Some(list_file.path()), |
| )? |
| .context("while attempting to build CentipedeArgs for replay all crashes mode")?; |
| Ok(ExecutionAction::ReplayAllCrashes { args: centipede_args, list_file }) |
| } |
| ExecutionMode::ListFuzzTests => { |
| anyhow::bail!("ListFuzzTest mode is supported only in cargo-fuzztest"); |
| } |
| } |
| } |
| } |
| |
| /// The prepared, ready-to-run execution payload passed to the test worker. |
| /// |
| /// This enum bridges parsed domain intent (`ExecutionMode`) with the concrete runtime |
| /// assets (`CentipedeArgs`, temporary files, workdir paths) needed to execute the test run. |
| #[derive(Debug)] |
| pub enum ExecutionAction { |
| /// Run as a smoke test (regular unit test) with random inputs for a short duration. |
| SmokeTest, |
| |
| /// Run in standalone mode, invoking the Centipede fuzzing controller. |
| Standalone(CentipedeArgs), |
| |
| /// Replay all crash IDs from the database (carries `list_file` for worker post-processing). |
| ReplayAllCrashes { args: CentipedeArgs, list_file: NamedTempFile }, |
| } |
| |
| /// Encapsulates command-line arguments and lifetime holders for Centipede execution. |
| #[derive(Debug)] |
| pub struct CentipedeArgs { |
| // Holds the owned C-compatible strings. We must keep these alive because the `views` |
| // field contains pointers into the memory owned by these `CString`s. |
| _c_strings: Vec<CString>, |
| |
| // The (optional) TempDir instance which is to be used as a workdir by centipede. |
| // We keep the ownership here so that it is not cleaned while the centipede execution is still |
| // in progress. |
| _temp_workdir: Option<TempDir>, |
| |
| // FFI-compatible views into the arguments in `_c_strings`. These are passed across |
| // the FFI boundary to Centipede. |
| views: Vec<engine_ffi::FuzzTestBytesView>, |
| } |
| |
| impl CentipedeArgs { |
| fn new(args: Vec<CString>, temp_workdir: Option<TempDir>) -> Self { |
| let views = args |
| .iter() |
| .map(|arg| engine_ffi::FuzzTestBytesView::from_bytes(arg.as_bytes())) |
| .collect(); |
| |
| Self { _c_strings: args, _temp_workdir: temp_workdir, views } |
| } |
| |
| /// Decides if Centipede needs to be invoked based on the execution mode, |
| /// and constructs the appropriate arguments for it if so. |
| /// |
| /// Returns `Ok(Some(args))` if Centipede should be invoked, `Ok(None)` for `SmokeTest`, |
| /// or `Err` if argument generation fails. |
| pub fn from_execution_mode( |
| mode: &ExecutionMode, |
| options: &FuzzTestOptions, |
| current_test_name: &str, |
| list_file_path: Option<&Path>, |
| ) -> anyhow::Result<Option<Self>> { |
| let mode_opts = match mode { |
| ExecutionMode::SmokeTest => return Ok(None), |
| other => other, |
| }; |
| |
| let mut args = Vec::new(); |
| let mut add_arg = |arg: String| -> anyhow::Result<()> { |
| args.push(CString::new(arg).context("while attempting to create CString arg")?); |
| Ok(()) |
| }; |
| |
| // ============================================================================== |
| // 1. Common Base Arguments (Required across all Centipede executions) |
| // ============================================================================== |
| let argv0 = env::args().next().context("while attempting to get argv[0]")?; |
| |
| add_arg(format!("--binary={argv0} {current_test_name} --exact --nocapture"))?; |
| let normalized_test_name = current_test_name.replace("::", "."); |
| add_arg(format!("--test_name={normalized_test_name}"))?; |
| |
| // Binary identifier is derived from the binary path. |
| let binary_id = normalize_binary_identifier(&argv0); |
| add_arg(format!("--fuzztest_binary_identifier={binary_id}"))?; |
| |
| add_arg("--populate_binary_info=false".to_string())?; |
| // TODO(the-shank): provide a way to override this. |
| // allow more crashes to be reported when running with FuzzTest (default is 5) |
| add_arg("--max_num_crash_reports=20".to_string())?; |
| add_arg("--fork_server=false".to_string())?; |
| |
| add_arg(format!("--print_runner_log={}", options.print_subprocess_log))?; |
| |
| // Add a dummy hash so as to avoid Centipede computing the hash of the binary. |
| add_arg("--binary_hash=DUMMY_HASH".to_string())?; |
| |
| // ============================================================================== |
| // 2. Common Working Directory & Corpus Database Resolution |
| // ============================================================================== |
| let (opt_corpusdb, opt_workdir_root, opt_workdir) = |
| get_corpusdb_and_workdir_root_and_workdir(options) |
| .context("while attempting to resolve corpus and workdir options")?; |
| |
| if let Some(corpus_db) = opt_corpusdb { |
| add_arg(format!("--fuzztest_corpus_database={corpus_db}"))?; |
| } |
| |
| if let Some(workdir_root) = opt_workdir_root { |
| add_arg(format!("--fuzztest_workdir_root={workdir_root}"))?; |
| } |
| |
| if let Some(temp_workdir) = &opt_workdir { |
| let workdir_path = temp_workdir |
| .path() |
| .to_str() |
| .context("while attempting to convert temp dir path to str")? |
| .to_string(); |
| add_arg(format!("--workdir={workdir_path}"))?; |
| } |
| |
| // ============================================================================== |
| // 3. Common Environment Variables Differential |
| // ============================================================================== |
| let env_diff = Self::default_env_diff(); |
| add_arg(format!("--env_diff_for_binaries={}", env_diff.join(",")))?; |
| |
| // ============================================================================== |
| // 4. Mode-Specific Arguments |
| // ============================================================================== |
| match mode_opts { |
| ExecutionMode::Fuzz(fuzz_opts) => { |
| match &fuzz_opts.fuzz_for { |
| RunDuration::Indefinitely => { |
| // not specifying `--stop_after` means to run indefinitely. |
| } |
| RunDuration::Fixed(duration) => { |
| let duration_secs = duration.as_secs_f64(); |
| if opt_corpusdb.is_some() { |
| add_arg(format!("--fuzztest_time_limit_per_test={duration_secs}s"))?; |
| } else { |
| add_arg(format!("--stop_after={duration_secs}s"))?; |
| } |
| } |
| } |
| if let Some(jobs) = &fuzz_opts.jobs { |
| add_arg(format!("--j={jobs}"))?; |
| } |
| 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())?; |
| add_arg(format!("--crash_id={}", replay_opts.replay_id))?; |
| add_arg("--exit_on_crash".to_string())?; |
| } |
| ExecutionMode::ReplayAllCrashes => { |
| let path = list_file_path |
| .context("while attempting to get list_file path for ReplayAllCrashes")?; |
| add_arg("--list_crash_ids=true".to_string())?; |
| add_arg(format!("--list_crash_ids_file={}", path.display()))?; |
| } |
| ExecutionMode::ReplayCorpus(replay_corpus_opts) => { |
| add_arg("--fuzztest_only_replay=true".to_string())?; |
| add_arg("--fuzztest_replay_coverage_inputs=true".to_string())?; |
| add_arg("--load_shards_only=true".to_string())?; |
| match replay_corpus_opts.replay_corpus_for { |
| RunDuration::Indefinitely => { |
| // Not specifying `--fuzztest_time_limit_per_test` means to run indefinitely |
| } |
| RunDuration::Fixed(duration) => { |
| let time_limit: humantime::Duration = match replay_corpus_opts |
| .time_budget_type |
| { |
| TimeBudgetType::PerTest => duration, |
| TimeBudgetType::Total => { |
| let num_tests = inventory::iter::<FuzzTestRegistration>().count(); |
| if num_tests == 0 { |
| duration |
| } else { |
| (*duration.as_ref() / (num_tests as u32)).into() |
| } |
| } |
| }; |
| let time_limit_secs = time_limit.as_secs_f64(); |
| add_arg(format!("--fuzztest_time_limit_per_test={time_limit_secs}s"))?; |
| } |
| } |
| if let Some(jobs) = &replay_corpus_opts.jobs { |
| add_arg(format!("--j={jobs}"))?; |
| } |
| 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())?; |
| add_arg(format!("--list_crash_ids_file={}", list_opts.list_crash_ids_file))?; |
| } |
| ExecutionMode::SmokeTest => unreachable!(), |
| ExecutionMode::ListFuzzTests => unreachable!(), |
| } |
| |
| Ok(Some(Self::new(args, opt_workdir))) |
| } |
| |
| /// Generates the default environment variable diff list. |
| /// |
| /// This replicates the logic in `GetEnvDiffForBinaries` from `centipede_adaptor.cc`. |
| /// |
| /// When Centipede spawns the test binary as a subprocess, we do not want it to inherit |
| /// environment variables set by the test harness (like Blaze/Bazel). Inheriting these |
| /// could cause the subprocess to think it's running in a sharded environment, try to |
| /// write to restricted output files, or trigger other harness-specific behavior that |
| /// interferes with fuzzing. |
| fn default_env_diff() -> Vec<String> { |
| let env_diff = vec![ |
| "-TEST_DIAGNOSTICS_OUTPUT_DIR".to_string(), |
| "-TEST_INFRASTRUCTURE_FAILURE_FILE".to_string(), |
| "-TEST_LOGSPLITTER_OUTPUT_FILE".to_string(), |
| "-TEST_PREMATURE_EXIT_FILE".to_string(), |
| "-TEST_RANDOM_SEED".to_string(), |
| "-TEST_RUN_NUMBER".to_string(), |
| "-TEST_SHARD_INDEX".to_string(), |
| "-TEST_SHARD_STATUS_FILE".to_string(), |
| "-TEST_TOTAL_SHARDS".to_string(), |
| "-TEST_UNDECLARED_OUTPUTS_ANNOTATIONS_DIR".to_string(), |
| "-TEST_UNDECLARED_OUTPUTS_DIR".to_string(), |
| "-TEST_WARNINGS_OUTPUT_FILE".to_string(), |
| "-GTEST_OUTPUT".to_string(), |
| "-XML_OUTPUT_FILE".to_string(), |
| ]; |
| |
| env_diff |
| } |
| |
| /// Exposes the FFI-compatible argument views vector for C++ Centipede binding. |
| pub fn as_bytes_views(&self) -> engine_ffi::FuzzTestBytesViews { |
| engine_ffi::FuzzTestBytesViews { views: self.views.as_ptr(), count: self.views.len() } |
| } |
| } |
| |
| /// Determines the `ExecutionAction` for the given test name by inspecting global options. |
| pub fn determine_execution_action(current_test_name: &str) -> ExecutionAction { |
| determine_execution_action_internal(get_fuzztest_options(), current_test_name) |
| } |
| |
| fn determine_execution_action_internal( |
| options: &FuzzTestOptions, |
| current_test_name: &str, |
| ) -> ExecutionAction { |
| ExecutionMode::from_fuzztest_options(options) |
| .prepare_action(options, current_test_name) |
| .expect("failed to prepare execution action from fuzztest options") |
| } |
| |
| fn get_corpusdb_and_workdir_root_and_workdir( |
| options: &FuzzTestOptions, |
| ) -> anyhow::Result<(Option<&str>, Option<&str>, Option<TempDir>)> { |
| let corpus_db = options.corpus_db.as_deref(); |
| let workdir_root = options.workdir_root.as_deref(); |
| |
| if let Some(corpus_db) = corpus_db { |
| if let Some(workdir_root) = workdir_root { |
| Ok(( |
| Some(corpus_db.to_str().context("converting corpus_db to str")?), |
| Some(workdir_root), |
| None, |
| )) |
| } else { |
| let temp_dir = |
| TempDir::new().context("while attempting to create temporary working directory")?; |
| Ok(( |
| Some(corpus_db.to_str().context("converting corpus_db to str")?), |
| None, |
| Some(temp_dir), |
| )) |
| } |
| } else { |
| if workdir_root.is_some() { |
| anyhow::bail!("NOT ALLOWED: workdir_root provided without corpus_db") |
| } else { |
| let temp_dir = |
| TempDir::new().context("while attempting to create temporary working directory")?; |
| Ok((None, None, Some(temp_dir))) |
| } |
| } |
| } |
| |
| /// Normalizes a binary path to be used as a binary identifier. |
| /// |
| /// Centipede creates corpus and working directory paths by joining a base |
| /// directory with the binary identifier (for example, `corpus_database / binary_id`). |
| /// |
| /// If `binary_path` starts with a `/`, joining paths treats it as an absolute path |
| /// and ignores the base directory. Stripping the leading slash turns it into a |
| /// relative path so it joins correctly. |
| fn normalize_binary_identifier(binary_path: &str) -> &str { |
| // TODO(the-shank): Handle absolute paths on windows as well. |
| binary_path.strip_prefix('/').unwrap_or(binary_path) |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use fuzztest_options::env_vars::*; |
| use googletest::prelude::*; |
| use std::ffi::OsString; |
| |
| #[gtest] |
| fn test_fuzz_options_parsing_env() { |
| assert_single_threaded_test_environment(); |
| // SAFETY: Single-threaded test environment and no background threads. |
| let options = unsafe { |
| with_env_var("FUZZTEST_FUZZ_FOR", "5s", || { |
| FuzzTestOptions::parse_from(std::iter::empty::<OsString>()) |
| }) |
| }; |
| |
| expect_true!(options.fuzz_for.is_some()); |
| expect_that!( |
| ExecutionMode::from_fuzztest_options(&options), |
| matches_pattern!(ExecutionMode::Fuzz(_)) |
| ); |
| } |
| |
| #[gtest] |
| fn test_fuzz_options_parsing_indefinite_env() { |
| assert_single_threaded_test_environment(); |
| // SAFETY: Single-threaded test environment and no background threads. |
| let options = unsafe { |
| with_env_var("FUZZTEST_FUZZ_FOR", "inf", || { |
| FuzzTestOptions::parse_from(std::iter::empty::<OsString>()) |
| }) |
| }; |
| |
| expect_that!(options.fuzz_for, eq(Some(RunDuration::Indefinitely))); |
| let mode = ExecutionMode::from_fuzztest_options(&options); |
| let ExecutionMode::Fuzz(fuzz_opts) = mode else { |
| panic!("Expected ExecutionMode::Fuzz"); |
| }; |
| expect_that!(fuzz_opts.fuzz_for, eq(RunDuration::Indefinitely)); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_standalone_indefinite() { |
| let options = |
| FuzzTestOptions { fuzz_for: Some(RunDuration::Indefinitely), ..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(); |
| |
| // When running indefinitely, --stop_after must be omitted so Centipede runs until interrupted. |
| expect_false!(args_str.iter().any(|s| s.starts_with("--stop_after="))); |
| expect_true!(args_str.contains(&"--test_name=my_mod.my_test")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_smoke_test() { |
| let options = FuzzTestOptions::default(); |
| let action = determine_execution_action_internal(&options, "my_mod::my_test"); |
| assert!(matches!(action, ExecutionAction::SmokeTest)); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_standalone() { |
| let expected_duration: RunDuration = "10s".parse().unwrap(); |
| let options = FuzzTestOptions { fuzz_for: Some(expected_duration), ..Default::default() }; |
| let action = determine_execution_action_internal(&options, "my_mod::my_test"); |
| |
| let ExecutionAction::Standalone(args) = action else { |
| panic!("Expected Standalone action"); |
| }; |
| |
| expect_that!(args._c_strings.len(), eq(args.views.len())); |
| |
| let args_str: Vec<&str> = |
| args._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect(); |
| |
| expect_true!(args_str |
| .iter() |
| .any(|s| s.starts_with("--binary=") && s.contains("my_mod::my_test --exact"))); |
| |
| expect_true!(args_str.contains(&"--stop_after=10s")); |
| expect_true!(args_str.contains(&"--test_name=my_mod.my_test")); |
| expect_true!(args_str.iter().any(|s| s.starts_with("--env_diff_for_binaries="))); |
| |
| // Verify that a temporary workdir was created and passed as an argument. |
| expect_true!(args._temp_workdir.is_some()); |
| let temp_workdir_path = args._temp_workdir.as_ref().unwrap().path().to_str().unwrap(); |
| let expected_workdir_arg = format!("--workdir={}", temp_workdir_path); |
| expect_true!(args_str.contains(&expected_workdir_arg.as_str())); |
| } |
| |
| #[gtest] |
| fn test_centipede_args_binary_identifier() { |
| let expected_duration = "1s".parse().unwrap(); |
| let options = FuzzTestOptions { fuzz_for: Some(expected_duration), ..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(); |
| |
| let argv0 = std::env::args().next().expect("get argv[0]"); |
| let binary_id = normalize_binary_identifier(&argv0); |
| let expected_binary_id_arg = format!("--fuzztest_binary_identifier={binary_id}"); |
| |
| expect_true!(args_str.contains(&expected_binary_id_arg.as_str())); |
| } |
| |
| #[gtest] |
| fn test_centipede_args_jobs() { |
| let expected_duration = "1s".parse().expect("failed to parse duration"); |
| let options_no_jobs = |
| FuzzTestOptions { fuzz_for: Some(expected_duration), ..Default::default() }; |
| let action_no_jobs = |
| determine_execution_action_internal(&options_no_jobs, "my_mod::my_test"); |
| let ExecutionAction::Standalone(args_no_jobs) = action_no_jobs else { |
| panic!("Expected Standalone action"); |
| }; |
| let args_str_no_jobs: Vec<&str> = |
| args_no_jobs._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect(); |
| assert!(!args_str_no_jobs.iter().any(|s| s.starts_with("--j="))); |
| |
| let options_with_jobs = FuzzTestOptions { |
| fuzz_for: Some(expected_duration), |
| jobs: Some(4), |
| ..Default::default() |
| }; |
| let action_with_jobs = |
| determine_execution_action_internal(&options_with_jobs, "my_mod::my_test"); |
| let ExecutionAction::Standalone(args_with_jobs) = action_with_jobs else { |
| panic!("Expected Standalone action"); |
| }; |
| let args_str_with_jobs: Vec<&str> = |
| args_with_jobs._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect(); |
| assert!(args_str_with_jobs.contains(&"--j=4")); |
| } |
| |
| #[gtest] |
| fn test_default_env_diff_set() { |
| let duration = "1s".parse().unwrap(); |
| let options = FuzzTestOptions { fuzz_for: Some(duration), ..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(); |
| |
| let env_diff_arg = args_str |
| .iter() |
| .find(|s| s.starts_with("--env_diff_for_binaries=")) |
| .expect("missing --env_diff_for_binaries arg"); |
| |
| expect_true!(env_diff_arg.contains("-TEST_SHARD_INDEX")); |
| expect_true!(env_diff_arg.contains("-XML_OUTPUT_FILE")); |
| expect_true!(env_diff_arg.contains("-GTEST_OUTPUT")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_crash_id() { |
| let options = FuzzTestOptions { |
| replay_id: Some("my_crash_123".to_string()), |
| corpus_db: Some("/tmp/corpus_db".into()), |
| ..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(); |
| |
| expect_true!(args_str.contains(&"--replay_crash")); |
| expect_true!(args_str.contains(&"--crash_id=my_crash_123")); |
| expect_true!(args_str.contains(&"--fuzztest_corpus_database=/tmp/corpus_db")); |
| expect_true!(args_str.contains(&"--test_name=my_mod.my_test")); |
| expect_true!(args_str.iter().any(|s| s.starts_with("--workdir="))); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_findings() { |
| let options = FuzzTestOptions { |
| replay_findings: true, |
| corpus_db: Some("/tmp/corpus_db".into()), |
| ..Default::default() |
| }; |
| let action = determine_execution_action_internal(&options, "my_mod::my_test"); |
| |
| let ExecutionAction::ReplayAllCrashes { args, list_file } = action else { |
| panic!("Expected ReplayAllCrashes action"); |
| }; |
| |
| let args_str: Vec<&str> = |
| args._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect(); |
| |
| expect_true!(args_str.contains(&"--list_crash_ids=true")); |
| expect_true!(args_str |
| .contains(&format!("--list_crash_ids_file={}", list_file.path().display()).as_str())); |
| expect_true!(args_str.contains(&"--fuzztest_corpus_database=/tmp/corpus_db")); |
| expect_true!(args_str.iter().any(|s| s.starts_with("--workdir="))); |
| expect_true!(args_str.contains(&"--test_name=my_mod.my_test")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_corpus() { |
| let expected_duration = "10s".parse().expect("failed to parse duration"); |
| let options = FuzzTestOptions { |
| replay_corpus_for: Some(expected_duration), |
| 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 |
| .iter() |
| .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] |
| fn test_determine_execution_action_replay_corpus_indefinite() { |
| let options = FuzzTestOptions { |
| replay_corpus_for: Some(RunDuration::Indefinitely), |
| 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 |
| .iter() |
| .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_replay_coverage_inputs=true")); |
| assert!(args_str.contains(&"--load_shards_only=true")); |
| assert!(!args_str.iter().any(|s| s.starts_with("--fuzztest_time_limit_per_test="))); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_corpus_total_budget() { |
| let expected_duration = "10s".parse().expect("failed to parse duration"); |
| let options = FuzzTestOptions { |
| replay_corpus_for: Some(expected_duration), |
| time_budget_type: TimeBudgetType::Total, |
| ..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(); |
| |
| let num_tests = inventory::iter::<FuzzTestRegistration>().count(); |
| let RunDuration::Fixed(fixed_duration) = expected_duration else { |
| panic!("expected Fixed duration"); |
| }; |
| let expected_limit = if num_tests == 0 { |
| fixed_duration |
| } else { |
| (*fixed_duration.as_ref() / (num_tests as u32)).into() |
| }; |
| let expected_limit_str = format!("--fuzztest_time_limit_per_test={}", expected_limit); |
| |
| assert!(args_str.contains(&expected_limit_str.as_str())); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_corpus_indefinite_total_budget() { |
| let options = FuzzTestOptions { |
| replay_corpus_for: Some(RunDuration::Indefinitely), |
| time_budget_type: TimeBudgetType::Total, |
| ..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(&"--fuzztest_only_replay=true")); |
| assert!(!args_str.iter().any(|s| s.starts_with("--fuzztest_time_limit_per_test="))); |
| } |
| |
| #[gtest] |
| fn test_get_corpusdb_and_workdir_default_creates_temp_workdir() -> Result<()> { |
| let options = FuzzTestOptions::default(); |
| let (corpus_db, workdir_root, workdir) = |
| get_corpusdb_and_workdir_root_and_workdir(&options).or_fail()?; |
| expect_true!(corpus_db.is_none()); |
| expect_true!(workdir_root.is_none()); |
| expect_true!(workdir.is_some()); |
| Ok(()) |
| } |
| |
| #[gtest] |
| fn test_get_corpusdb_and_workdir_succeeds_with_corpusdb_and_workdir_root() -> Result<()> { |
| let options = FuzzTestOptions { |
| corpus_db: Some("/tmp/db".into()), |
| workdir_root: Some("/tmp/root".to_string()), |
| ..Default::default() |
| }; |
| let (corpus_db, workdir_root, workdir) = |
| get_corpusdb_and_workdir_root_and_workdir(&options).or_fail()?; |
| expect_that!(corpus_db, eq(Some("/tmp/db"))); |
| expect_that!(workdir_root, eq(Some("/tmp/root"))); |
| expect_true!(workdir.is_none()); |
| Ok(()) |
| } |
| |
| #[gtest] |
| fn test_get_corpusdb_and_workdir_succeeds_with_corpusdb_only() -> Result<()> { |
| let options = FuzzTestOptions { corpus_db: Some("/tmp/db".into()), ..Default::default() }; |
| let (corpus_db, workdir_root, workdir) = |
| get_corpusdb_and_workdir_root_and_workdir(&options).or_fail()?; |
| expect_that!(corpus_db, eq(Some("/tmp/db"))); |
| expect_true!(workdir_root.is_none()); |
| expect_true!(workdir.is_some()); |
| Ok(()) |
| } |
| |
| #[gtest] |
| fn test_replay_id_requires_corpus_db() { |
| assert_single_threaded_test_environment(); |
| // SAFETY: Single-threaded test environment and no background threads. |
| let result = unsafe { |
| with_env_vars( |
| [("FUZZTEST_REPLAY_ID", Some("my_crash_123")), ("FUZZTEST_CORPUS_DB", None)], |
| || FuzzTestOptions::try_parse_from(std::iter::empty::<OsString>()), |
| ) |
| }; |
| |
| 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_id_with_corpus_db_succeeds() { |
| assert_single_threaded_test_environment(); |
| // SAFETY: Single-threaded test environment and no background threads. |
| let options = unsafe { |
| with_env_vars( |
| [ |
| ("FUZZTEST_REPLAY_ID", Some("my_crash_123")), |
| ("FUZZTEST_CORPUS_DB", Some("/tmp/corpus_db")), |
| ], |
| || FuzzTestOptions::try_parse_from(std::iter::empty::<OsString>()), |
| ) |
| } |
| .expect("parsing should succeed when both replay_id and corpus_db are present"); |
| |
| expect_that!(options.replay_id.as_deref(), eq(Some("my_crash_123"))); |
| expect_that!(options.corpus_db.as_deref(), eq(Some(Path::new("/tmp/corpus_db")))); |
| } |
| |
| #[gtest] |
| fn test_centipede_args_fuzz_for_duration_without_corpus_db() { |
| // When corpus_db is not provided, fuzzing for a fixed duration should pass --stop_after |
| // to Centipede so it stops fuzzing after the specified duration. |
| let duration = "1s".parse().expect("fixed test string should parse as duration"); |
| let options = |
| FuzzTestOptions { fuzz_for: Some(duration), corpus_db: None, ..Default::default() }; |
| let action = determine_execution_action_internal(&options, "my_mod::my_test"); |
| |
| let ExecutionAction::Standalone(args) = action else { |
| panic!("Expected Standalone action when fuzzing for duration"); |
| }; |
| |
| let args_str: Vec<&str> = args |
| ._c_strings |
| .iter() |
| .map(|s| s.to_str().expect("args strings should be valid UTF-8")) |
| .collect(); |
| |
| expect_true!(args_str.contains(&"--stop_after=1s")); |
| expect_false!(args_str.iter().any(|s| s.starts_with("--fuzztest_time_limit_per_test="))); |
| } |
| |
| #[gtest] |
| fn test_centipede_args_fuzz_for_duration_with_corpus_db() { |
| // When corpus_db is set, fuzzing for a duration should pass --fuzztest_time_limit_per_test |
| // instead of --stop_after, because Centipede uses time_limit_per_test when running against |
| // a corpus database. |
| let duration = "1s".parse().expect("fixed test string should parse as duration"); |
| let options = FuzzTestOptions { |
| fuzz_for: Some(duration), |
| corpus_db: Some("/tmp/corpus_db".into()), |
| ..Default::default() |
| }; |
| let action = determine_execution_action_internal(&options, "my_mod::my_test"); |
| |
| let ExecutionAction::Standalone(args) = action else { |
| panic!("Expected Standalone action when fuzzing with corpus_db and duration"); |
| }; |
| |
| let args_str: Vec<&str> = args |
| ._c_strings |
| .iter() |
| .map(|s| s.to_str().expect("args strings should be valid UTF-8")) |
| .collect(); |
| |
| expect_true!(args_str.contains(&"--fuzztest_time_limit_per_test=1s")); |
| expect_true!(args_str.contains(&"--fuzztest_corpus_database=/tmp/corpus_db")); |
| expect_false!(args_str.iter().any(|s| s.starts_with("--stop_after="))); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_list_crash_ids() { |
| let options = FuzzTestOptions { |
| list_crash_ids: true, |
| list_crash_ids_file: Some("/tmp/custom_crashes.txt".to_string()), |
| corpus_db: Some("/tmp/corpus_db".into()), |
| ..Default::default() |
| }; |
| let action = determine_execution_action_internal(&options, "my_mod::my_test"); |
| |
| let ExecutionAction::Standalone(args) = action else { |
| panic!("Expected Standalone action for ListCrashIds"); |
| }; |
| |
| let args_str: Vec<&str> = |
| args._c_strings.iter().map(|s| s.to_str().expect("invalid utf8")).collect(); |
| |
| expect_true!(args_str.contains(&"--list_crash_ids=true")); |
| expect_true!(args_str.contains(&"--list_crash_ids_file=/tmp/custom_crashes.txt")); |
| expect_true!(args_str.contains(&"--fuzztest_corpus_database=/tmp/corpus_db")); |
| expect_true!(args_str.iter().any(|s| s.starts_with("--workdir="))); |
| expect_true!(args_str.contains(&"--test_name=my_mod.my_test")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_fuzz_continue_after_crash_false() { |
| let options = FuzzTestOptions { |
| fuzz_for: Some("5s".parse().unwrap()), |
| continue_after_crash: false, |
| ..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(&"--exit_on_crash")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_fuzz_continue_after_crash_true() { |
| let options = FuzzTestOptions { |
| fuzz_for: Some("5s".parse().unwrap()), |
| continue_after_crash: true, |
| ..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.contains(&"--exit_on_crash")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_corpus_continue_after_crash_false() { |
| let options = FuzzTestOptions { |
| replay_corpus_for: Some("5s".parse().unwrap()), |
| continue_after_crash: false, |
| corpus_db: Some("/tmp/corpus_db".into()), |
| ..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(&"--exit_on_crash")); |
| } |
| |
| #[gtest] |
| fn test_determine_execution_action_replay_corpus_continue_after_crash_true() { |
| let options = FuzzTestOptions { |
| replay_corpus_for: Some("5s".parse().unwrap()), |
| continue_after_crash: true, |
| corpus_db: Some("/tmp/corpus_db".into()), |
| ..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.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="))); |
| } |
| } |