| // 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(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="))); | |
| } | |
| } |