diff --git a/centipede/BUILD b/centipede/BUILD index 683b2afde..2b2880612 100644 --- a/centipede/BUILD +++ b/centipede/BUILD @@ -979,8 +979,6 @@ cc_library( name = "engine_worker", srcs = [ "engine_worker.cc", - "runner_utils.cc", - "runner_utils.h", ], hdrs = ["engine_worker_abi.h"], deps = [ @@ -989,6 +987,7 @@ cc_library( ":feature", ":runner_request", ":runner_result", + ":runner_utils", ":shared_memory_blob_sequence", "@abseil-cpp//absl/base:nullability", "@com_google_fuzztest//common:defs", @@ -1215,8 +1214,6 @@ cc_library( "reverse_pc_table.h", "runner_dl_info.cc", "runner_dl_info.h", - "runner_utils.cc", - "runner_utils.h", "sancov_callbacks.cc", "sancov_interceptors.cc", "sancov_object_array.cc", @@ -1238,6 +1235,7 @@ cc_library( ":foreach_nonzero", ":int_utils", ":runner_cmp_trace", + ":runner_utils", "@abseil-cpp//absl/base:core_headers", "@abseil-cpp//absl/base:nullability", "@abseil-cpp//absl/numeric:bits", @@ -2000,3 +1998,23 @@ sh_test( ":test_util_sh", ], ) + +cc_library( + name = "runner_utils", + srcs = ["runner_utils.cc"], + hdrs = ["runner_utils.h"], + copts = DISABLE_SANCOV_COPTS, + deps = [ + "@abseil-cpp//absl/base:nullability", + ], +) + +cc_static_library( + name = "centipede_engine_static", + deps = [ + ":engine_controller_with_subprocess", + ":engine_worker", + ":sancov_runtime", + ":weak_sancov_stubs", + ], +) diff --git a/rust/.rustfmt.toml b/rust/.rustfmt.toml new file mode 100644 index 000000000..33b356d5f --- /dev/null +++ b/rust/.rustfmt.toml @@ -0,0 +1,3 @@ +use_field_init_shorthand = true +use_try_shorthand = true +edition = "2021" diff --git a/rust/BUILD b/rust/BUILD new file mode 100644 index 000000000..b6f5e8402 --- /dev/null +++ b/rust/BUILD @@ -0,0 +1,44 @@ +load("//third_party/bazel_rules/rules_rust/rust:defs.bzl", "rust_library", "rust_test") + +licenses(["notice"]) + +exports_files(["BUILD"]) + +rust_library( + name = "fuzztest", + srcs = glob([ + "src/**/*.rs", + ]), + edition = "2024", + proc_macro_deps = [ + "@com_google_fuzztest//rust/fuzztest_macro:fuzztest_macro", + ], + rustc_flags = ["-Zallow-features=cfg_sanitize"], + visibility = ["__subpackages__"], + deps = [ + "//third_party/rust/anyhow/v1:anyhow", + "//third_party/rust/clap/v4:clap", + "//third_party/rust/humantime/v2:humantime", + "//third_party/rust/inventory/v0_3:inventory", + "//third_party/rust/num_traits/v0_2:num_traits", + "//third_party/rust/postcard/v1:postcard", + "//third_party/rust/rand/v0_10:rand", + "//third_party/rust/serde/v1:serde", + "//third_party/rust/spin/v0_10:spin", + "//third_party/rust/tempfile/v3:tempfile", + "@com_google_fuzztest//rust/coverage", + "@com_google_fuzztest//rust/engine", + ], +) + +rust_test( + name = "fuzztest_test", + # Avoid interference when setting/resetting environment variables in tests. + args = ["--test-threads=1"], + crate = ":fuzztest", + edition = "2024", + rustc_flags = ["-Zallow-features=cfg_sanitize"], + deps = [ + "//third_party/gtest_rust/googletest", + ], +) diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 000000000..85683a4d0 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "fuzztest" +version = "0.1.0" +edition = "2024" + +[[test]] +name = "trybuild" +path = "tests/trybuild.rs" + +[dependencies] +anyhow = "1.0.100" +fuzztest-macro = { path = "fuzztest_macro" } +inventory = "0.3.20" +num-traits = "0.2" +postcard = { version = "1.0.0", features = ["use-std"] } +rand = { version = "0.10.1", features = ["std_rng"] } +serde = { version = "1.0", features = ["derive"] } +coverage = { path = "coverage" } +engine = { path = "engine", package = "engine-ffi" } +spin = "0.10.0" +tempfile = "3.27.0" +clap = { version = "4.6.1", features = ["derive", "env"] } +humantime = "2.4.0" + +[dev-dependencies] +googletest = "0.14.3" + +[dev-dependencies.trybuild] +version = "1.0.103" +features = ["diff"] + +[profile.fuzztest] +inherits = 'release' +panic = 'abort' +opt-level = "s" +debug = true +split-debuginfo = "packed" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 000000000..acad7e2e7 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,57 @@ +# FuzzTest Rust + +A framework for fuzzing Rust projects using Google FuzzTest. + +## Prerequisites + +1. Clone the repository. +2. Build the C++ Centipede static library engine using Bazel (from the + repository root): + + ```bash + cd /path/to/fuzztest + bazel build //centipede:centipede_engine_static + ``` + +## Setup a Project + +1. Create a new Rust project: + + ```bash + cargo new my_fuzz_project --bin + ``` + +2. Add `fuzztest` as a dependency in your `Cargo.toml`: + + ```toml + [dependencies] + fuzztest = { path = "/path/to/fuzztest/rust" } + ``` + +## Write a Fuzz Test + +In `src/main.rs`: + +```rust +#[cfg(test)] +mod tests { + use fuzztest::domains::arbitrary::Arbitrary; + use fuzztest::fuzztest; + + #[fuzztest(a = Arbitrary::::default(), b = Arbitrary::::default())] + fn test_addition(a: i32, b: i32) { + let _ = a.wrapping_add(b); + } +} +``` + +## Build and Run Fuzz Tests + +To run the fuzz tests, you must specify: + +- `CENTIPEDE_BIN_DIR`: The directory containing the compiled C++ static + libraries (i.e. `bazel-bin/centipede`). + +```bash +CENTIPEDE_BIN_DIR=/path/to/fuzztest/bazel-bin/centipede cargo test __fuzztest_mod__::test_addition +``` diff --git a/rust/coverage/BUILD b/rust/coverage/BUILD new file mode 100644 index 000000000..78cf7c9f2 --- /dev/null +++ b/rust/coverage/BUILD @@ -0,0 +1,34 @@ +load("//third_party/bazel_rules/rules_rust/rust:defs.bzl", "rust_library", "rust_test") + +licenses(["notice"]) + +rust_library( + name = "coverage", + srcs = [ + "src/coverage.rs", + "src/lib.rs", + ], + cc_deps = [ + "@com_google_fuzztest//centipede:sancov_runtime", + ], + visibility = ["@com_google_fuzztest//rust:__subpackages__"], +) + +rust_test( + name = "coverage_test", + # Avoid interference on coverage collection between threads. + args = ["--test-threads=1"], + crate = ":coverage", + env = { + "CENTIPEDE_RUNNER_FLAGS": ":use_cmp_features:", + }, + # TODO: b/437896409 - Replace with global config once it's available. + rustc_flags = [ + "-Cpasses=sancov-module", + "-Cllvm-args=-sanitizer-coverage-level=1", + "-Cllvm-args=-sanitizer-coverage-trace-compares", + ], + deps = [ + "//third_party/gtest_rust/googletest", + ], +) diff --git a/rust/coverage/Cargo.toml b/rust/coverage/Cargo.toml new file mode 100644 index 000000000..055dab5dd --- /dev/null +++ b/rust/coverage/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "coverage" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/rust/coverage/src/coverage.rs b/rust/coverage/src/coverage.rs new file mode 100644 index 000000000..258987e6a --- /dev/null +++ b/rust/coverage/src/coverage.rs @@ -0,0 +1,78 @@ +use std::slice; + +#[repr(C)] +pub struct SanCovRuntimeRawFeatureParts { + // Safety invariant: The only way to receive this type is through `SanCovRuntimeGetCoverage()` + // which guarantees that `ptr` always points to a valid slice of `size` elements. + ptr: *const u64, + size: usize, +} + +impl SanCovRuntimeRawFeatureParts { + /// # Safety + /// + /// Must not be held on when its data has to be mutated. For example, do not hold on to it + /// when calling `SanCovRuntimeGetCoverage()`. + pub unsafe fn as_slice(&self) -> &[u64] { + // Safety: self ensures that `ptr` always points to a valid slice of `size` elements. + unsafe { slice::from_raw_parts(self.ptr, self.size) } + } +} + +unsafe extern "C" { + pub safe fn SanCovRuntimeClearCoverage(full_clear: bool); + + pub safe fn SanCovRuntimeGetCoverage(reject_input: bool) -> SanCovRuntimeRawFeatureParts; + + pub safe fn SanCovRuntimePostProcessCoverage(reject_input: bool); + + // Exposed only for testing purposes. + pub safe fn __sanitizer_cov_trace_const_cmp1(Arg1: u8, Arg2: u8); +} + +#[cfg(test)] +mod test { + use super::*; + use googletest::prelude::*; + + #[gtest] + fn should_collect_features_from_sancov_callback() { + SanCovRuntimeClearCoverage(true); + + __sanitizer_cov_trace_const_cmp1(1, 1); + + let features = SanCovRuntimeGetCoverage(false); + expect_false!(features.ptr.is_null()); + expect_gt!(features.size, 0); + } + + #[gtest] + fn should_not_collect_features_from_empty_user_code() { + SanCovRuntimeClearCoverage(true); + + // An execution where sancov hooks such as + // `__sanitizer_cov_trace_const_cmp1` are not called. + + let features = SanCovRuntimeGetCoverage(false); + expect_false!(features.ptr.is_null()); + expect_eq!(features.size, 0); + } + + #[gtest] + fn should_collect_features_from_user_code() { + let x = std::env::args().count(); + + SanCovRuntimeClearCoverage(true); + + { + // An LLVM trace cmp callback is expected to be instrumented here. + if x == 2 { + std::hint::black_box(2); // To prevent dead code elimination. + } + } + + let features = SanCovRuntimeGetCoverage(false); + expect_false!(features.ptr.is_null()); + expect_gt!(features.size, 0); + } +} diff --git a/rust/coverage/src/lib.rs b/rust/coverage/src/lib.rs new file mode 100644 index 000000000..16eca13b3 --- /dev/null +++ b/rust/coverage/src/lib.rs @@ -0,0 +1,15 @@ +mod coverage; + +pub use coverage::SanCovRuntimeRawFeatureParts; + +pub fn prepare_coverage(full_clear: bool) { + coverage::SanCovRuntimeClearCoverage(full_clear) +} + +pub fn get_coverage(reject_input: bool) -> SanCovRuntimeRawFeatureParts { + coverage::SanCovRuntimeGetCoverage(reject_input) +} + +pub fn post_process_coverage(reject_input: bool) { + coverage::SanCovRuntimePostProcessCoverage(reject_input) +} diff --git a/rust/e2e_tests/BUILD b/rust/e2e_tests/BUILD new file mode 100644 index 000000000..9e2a0d628 --- /dev/null +++ b/rust/e2e_tests/BUILD @@ -0,0 +1,90 @@ +load("//third_party/bazel_rules/rules_rust/rust:defs.bzl", "rust_library", "rust_test") + +licenses(["notice"]) + +rust_test( + name = "worker_test", + srcs = ["worker_test.rs"], + data = [ + "@com_google_fuzztest//rust/e2e_tests/testdata:fuzztest_main", + ], + deps = [ + ":test_utils", + "//third_party/gtest_rust/googletest", + ], +) + +rust_test( + name = "standalone_mode_test", + srcs = ["standalone_mode_test.rs"], + data = [ + "@com_google_fuzztest//centipede/google:centipede_uninstrumented", + "@com_google_fuzztest//rust/e2e_tests/testdata:fuzztest_main", + "@com_google_fuzztest//rust/e2e_tests/testdata:standalone_fuzz_tests_bin", + ], + deps = [ + ":test_utils", + "//third_party/gtest_rust/googletest", + ], +) + +rust_test( + name = "worker_with_centipede_test", + srcs = ["worker_with_centipede_test.rs"], + data = [ + "@com_google_fuzztest//centipede/google:centipede_uninstrumented", + "@com_google_fuzztest//rust/e2e_tests/testdata:fuzztest_main", + ], + deps = [ + ":test_utils", + "//third_party/gtest_rust/googletest", + "//third_party/rust/rand/v0_10:rand", + ], +) + +rust_test( + name = "worker_with_centipede_sanitizer_test", + srcs = ["worker_with_centipede_sanitizer_test.rs"], + data = [ + "@com_google_fuzztest//centipede/google:centipede_uninstrumented", + "@com_google_fuzztest//rust/e2e_tests/testdata:fuzztest_main", + ], + rustc_flags = ["-Zallow-features=cfg_sanitize"], + deps = [ + ":test_utils", + "//third_party/gtest_rust/googletest", + ], +) + +rust_library( + name = "test_utils", + testonly = 1, + srcs = ["test_utils.rs"], + deps = [ + "//third_party/gtest_rust/googletest", + ], +) + +rust_test( + name = "gtest_registration_test", + srcs = ["gtest_registration_test.rs"], + data = [ + "@com_google_fuzztest//rust/e2e_tests/testdata:fuzz_tests_as_gtests", + ], + deps = [ + "//third_party/gtest_rust/googletest", + ], +) + +rust_test( + name = "replay_test", + srcs = ["replay_test.rs"], + data = [ + "@com_google_fuzztest//centipede/google:centipede_uninstrumented", + "@com_google_fuzztest//rust/e2e_tests/testdata:replay_fuzz_tests_bin", + ], + deps = [ + ":test_utils", + "//third_party/gtest_rust/googletest", + ], +) diff --git a/rust/e2e_tests/gtest_registration_test.rs b/rust/e2e_tests/gtest_registration_test.rs new file mode 100644 index 000000000..f4d01f0ad --- /dev/null +++ b/rust/e2e_tests/gtest_registration_test.rs @@ -0,0 +1,45 @@ +use googletest::matchers; +use googletest::prelude::*; +use std::env; +use std::path::PathBuf; +use std::process::Command; + +#[gtest] +fn test_fuzztests_are_registered_as_gtests() { + let src_dir_path = PathBuf::from(env::var("TEST_SRCDIR").unwrap()); + let target_binary_path = src_dir_path + .join("com_google_fuzztest/rust/e2e_tests/testdata/fuzz_tests_as_gtests"); + + // Run the test binary with `--list` to list all registered tests. + let output = Command::new(&target_binary_path) + .arg("--list") + .output() + .expect("Failed to execute test binary"); + + assert!(output.status.success(), "Test binary failed to execute with --list"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Verify that the expected fuzz tests are listed as regular tests. + expect_that!(stdout, matchers::contains_substring("bool_fuzz_test: test")); + expect_that!(stdout, matchers::contains_substring("multi_arg_fuzz_test: test")); +} + +#[gtest] +fn test_fuzztest_property_function_executes_in_gtest() { + let src_dir_path = PathBuf::from(env::var("TEST_SRCDIR").unwrap()); + let target_binary_path = src_dir_path + .join("com_google_fuzztest/rust/e2e_tests/testdata/fuzz_tests_as_gtests"); + + // Run a fuzz test + let output = Command::new(&target_binary_path) + .arg("bool_fuzz_test") + .output() + .expect("Failed to execute test binary"); + + assert!(output.status.success(), "Test binary failed to execute `bool_fuzz_test`"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + expect_that!(stdout, matchers::contains_substring("bool_fuzz_test property function ran...")); +} diff --git a/rust/e2e_tests/replay_test.rs b/rust/e2e_tests/replay_test.rs new file mode 100644 index 000000000..0bf2f17a4 --- /dev/null +++ b/rust/e2e_tests/replay_test.rs @@ -0,0 +1,106 @@ +use googletest::matchers; +use googletest::prelude::*; +use std::process::Command; +use test_utils::EnvVars; + +#[gtest] +fn replay_by_id_reproduces_panic(fixture: &EnvVars) { + let test_name = "__fuzztest_mod__find_bug_fuzz_test::find_bug_fuzz_test"; + let normalized_test_name = test_name.replace("::", "."); + let crash_id = "my_custom_crash_id"; + + let target_binary_path = fixture + .target_binary_path + .parent() + .expect("target_binary_path must have a parent") + .join("replay_fuzz_tests_bin"); + + // use a relative path for the target-binary + let current_dir = std::env::current_dir().expect("Failed to get current directory"); + let relative_target_binary = target_binary_path + .strip_prefix(¤t_dir) + .expect("target_binary_path must be prefix of current_dir"); + + let db_dir = fixture.tmp_dir_path.join("corpus_db"); + let crash_file_dir = + db_dir.join(relative_target_binary).join(&normalized_test_name).join("crashing"); + + // Write the crashing input to the database + std::fs::create_dir_all(&crash_file_dir).expect("Failed to create corpus_db directory"); + std::fs::write(crash_file_dir.join(crash_id), &[1, 123]) + .expect("Failed to write crash file to db"); + + let process = Command::new(relative_target_binary) + .arg(test_name) + .env("FUZZTEST_REPLAY_ID", crash_id) + .env("FUZZTEST_CORPUS_DB", &db_dir) + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) // Needed for FFI export + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output = process.wait_with_output().expect("Should terminate"); + let stderr = String::from_utf8_lossy(&output.stderr); + + expect_that!(output.status.success(), eq(false)); + expect_that!(stderr, matchers::contains_substring("panicked at")); + expect_that!(stderr, matchers::contains_substring("Bug found!")); + expect_that!(stderr, matchers::contains_substring("FuzzTest controller reported failure")); +} + +#[gtest] +fn replay_all_reproduces_all_failures(fixture: &EnvVars) { + let test_name = "__fuzztest_mod__find_bug_fuzz_test::find_bug_fuzz_test"; + let normalized_test_name = test_name.replace("::", "."); + + let target_binary_path = fixture + .target_binary_path + .parent() + .expect("target_binary_path must have a parent") + .join("replay_fuzz_tests_bin"); + + // use a relative path for the target-binary + let current_dir = std::env::current_dir().expect("Failed to get current directory"); + let relative_target_binary = target_binary_path + .strip_prefix(¤t_dir) + .expect("target_binary_path must be prefix of current_dir"); + + let db_dir = fixture.tmp_dir_path.join("corpus_db_all"); + let crash_file_dir = + db_dir.join(relative_target_binary).join(&normalized_test_name).join("crashing"); + + // Write multiple crashing inputs to the database + std::fs::create_dir_all(&crash_file_dir).expect("Failed to create corpus_db directory"); + + // Length 1, [123] + std::fs::write(crash_file_dir.join("crash_1"), &[1, 123]) + .expect("Failed to write crash_1 file to db"); + + // Length 2, [123, 44] + std::fs::write(crash_file_dir.join("crash_2"), &[2, 123, 44]) + .expect("Failed to write crash_2 file to db"); + + // Length 3, [123, 55, 66] + std::fs::write(crash_file_dir.join("crash_3"), &[3, 123, 55, 66]) + .expect("Failed to write crash_3 file to db"); + + let process = std::process::Command::new(relative_target_binary) + .arg(test_name) + .env("FUZZTEST_REPLAY_FINDINGS", "true") + .env("FUZZTEST_CORPUS_DB", &db_dir) + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) // Needed for FFI export + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output = process.wait_with_output().expect("Should terminate"); + let stderr = String::from_utf8_lossy(&output.stderr); + + expect_that!(output.status.success(), eq(true)); + + // Check that "Bug found!" appears exactly 3 times + let bug_count = stderr.matches("Bug found!").count(); + expect_that!(bug_count, eq(3)); +} diff --git a/rust/e2e_tests/run_with_centipede.sh b/rust/e2e_tests/run_with_centipede.sh new file mode 100755 index 000000000..b52785be7 --- /dev/null +++ b/rust/e2e_tests/run_with_centipede.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# +# Use this script for when one wants to manually fuzz a test target with +# Centipede: internal only. Workdir for Centipede is the dir from where this +# script is executed. User is in charge of removing the corpus to start anew. +# +# This script will build Centipede and the fuzz target every time by default. +# If you wish to skip building Centipede (speeds up execution), set +# --build_centipede=false in the command line for subsequent runs. +# +# First argument should be the fuzz target. +# Example usage: +# +# ./third_party/googlefuzztest/rust/e2e_tests/run_with_centipede.sh \ +# //third_party/googlefuzztest/rust/e2e_tests/testdata:fuzztest_main \ +# --fuzz=find_rarer_bug_fuzz_test --fuzz_for=10s + +source gbash.sh || exit + +DEFINE_string artifact_dir "/tmp" "Build artifacts will be stored here." +DEFINE_string fuzz_for "" "How much time to run the fuzz test for" +DEFINE_string fuzz --required "" "The name of the fuzz test to run" +DEFINE_bool build_centipede true "If false, will not build Centipede." + +gbash::init_google "$@" +set -- "${GBASH_ARGV[@]}" + +readonly FUZZ_TARGET="${1}" +readonly CENTIPEDE_TARGET="//third_party/googlefuzztest/centipede/google:centipede_uninstrumented" + +readonly SYMLINK="${FLAGS_artifact_dir%/}/blaze-" +readonly CENTIPEDE_SYMLINK="${SYMLINK}centipede-" +readonly TARGET_SYMLINK="${SYMLINK}target-" +readonly CENTIPEDE_PATH="${CENTIPEDE_SYMLINK}bin/third_party/googlefuzztest/centipede/google/centipede_uninstrumented" + +get_fuzz_target_binary_path() { + # Remove leading double slashes. + local FUZZ_TARGET_PATH="${FUZZ_TARGET##//}" + # Replace last colon with a slash. + echo "${TARGET_SYMLINK}bin/${FUZZ_TARGET_PATH%:*}/${FUZZ_TARGET_PATH##*:}" +} + +build_all() { + + if (( FLAGS_build_centipede )); then + # BUILD Centipede. + bazel build -c opt --symlink_prefix="${CENTIPEDE_SYMLINK}" \ + "${CENTIPEDE_TARGET}" + fi + + # TODO: b/437896409 - Use a dedicated config for Rust FuzzTest. + # BUILD the fuzz target. + bazel build --symlink_prefix="${TARGET_SYMLINK}" "${FUZZ_TARGET}" \ + --config=rust-cov --config=asan + +} + +main() { + + build_all + + declare -a centipede_flags=( + --binary="$(get_fuzz_target_binary_path)" \ + --persistent_mode=0 --fork_server=0 --populate_binary_info=0 \ + --test_name="${FLAGS_fuzz}" \ + ) + + if [[ -n "${FLAGS_fuzz_for}" ]]; then + centipede_flags+=(--stop_after="${FLAGS_fuzz_for}") + fi + + "${CENTIPEDE_PATH}" "${centipede_flags[@]}" +} + +main diff --git a/rust/e2e_tests/standalone_mode_test.rs b/rust/e2e_tests/standalone_mode_test.rs new file mode 100644 index 000000000..821fbac06 --- /dev/null +++ b/rust/e2e_tests/standalone_mode_test.rs @@ -0,0 +1,300 @@ +use googletest::matchers; +use googletest::prelude::*; +use std::process::Command; +use test_utils::EnvVars; + +#[gtest] +fn standalone_mode_invokes_centipede(fixture: &EnvVars) { + let target_binary_path = + fixture.target_binary_path.parent().unwrap().join("standalone_fuzz_tests_bin"); + let test_name = "__fuzztest_mod__standalone_validation_test::standalone_validation_test"; + + let process = Command::new(&target_binary_path) + .arg(test_name) + .env("FUZZTEST_FUZZ_FOR", "3s") + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true") + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) + .env("RUST_TEST_NOCAPTURE", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output = process.wait_with_output().expect("Should terminate"); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Assert that Centipede actually ran the test binary, and forwarded the worker log. + // We check for "LOG: STANDALONE_VALIDATION_WORKER_EXECUTED". The prefix "LOG: " indicates that + // this output is from centipede forwarding the output of the worker (due to --print_runner_log), + // whereas the rest is from the fuzztest itself. + expect_that!( + stderr, + matchers::contains_substring("LOG: STANDALONE_VALIDATION_WORKER_EXECUTED") + ); +} + +#[gtest] +fn standalone_mode_invokes_the_correct_fuzztest(fixture: &EnvVars) { + let target_binary_path = + fixture.target_binary_path.parent().unwrap().join("standalone_fuzz_tests_bin"); + let test_name = "__fuzztest_mod__standalone_validation_test::standalone_validation_test"; + + let process = Command::new(&target_binary_path) + .arg(test_name) + .env("FUZZTEST_FUZZ_FOR", "3s") + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true") + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) + .env("RUST_TEST_NOCAPTURE", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output = process.wait_with_output().expect("Should terminate"); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check that the first test is executed + expect_that!( + stderr, + matchers::contains_substring("LOG: STANDALONE_VALIDATION_WORKER_EXECUTED") + ); + + // Check that the second test does not get executed + expect_that!(stderr, not(matchers::contains_substring("SECOND_VALIDATION_TEST_EXECUTED"))); +} + +#[gtest] +fn standalone_mode_handles_worker_crash(fixture: &EnvVars) { + let test_name = "__fuzztest_mod__find_bug_fuzz_test::find_bug_fuzz_test"; + + let process = Command::new(&fixture.target_binary_path) + .arg(test_name) + .env("FUZZTEST_FUZZ_FOR", "5s") + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true") + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) + .env("RUST_TEST_NOCAPTURE", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output = process.wait_with_output().expect("Should terminate"); + let stderr = String::from_utf8_lossy(&output.stderr); + + expect_that!( + stderr, + matchers::contains_substring("INPUT FAILURE: Property function ran but crashed.") + ); + expect_that!(stderr, matchers::contains_regex("Signature[ \t]*: Unwinding panic")); + // Centipede prefixes logs from the crashing worker with "CRASH LOG: ". + expect_that!(stderr, matchers::contains_substring("CRASH LOG: Bug found!")); +} + +#[gtest] +fn standalone_mode_spawns_parallel_jobs(fixture: &EnvVars) { + let target_binary_path = + fixture.target_binary_path.parent().unwrap().join("standalone_fuzz_tests_bin"); + let test_name = "__fuzztest_mod__jobs_test::jobs_test"; + + let process = Command::new(&target_binary_path) + .arg(test_name) + .env("FUZZTEST_FUZZ_FOR", "5s") + .env("FUZZTEST_JOBS", "4") + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true") + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) + .env("RUST_TEST_NOCAPTURE", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output = process.wait_with_output().expect("Should terminate"); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Extract all PIDs from logs. + // Expected format: "LOG: JOBS_TEST_PID: " + use std::collections::HashSet; + let mut pids = HashSet::new(); + for line in stderr.lines() { + if let Some(pos) = line.find("LOG: JOBS_TEST_PID: ") { + let pid_str = &line[pos + "LOG: JOBS_TEST_PID: ".len()..]; + if let Ok(pid) = pid_str.parse::() { + pids.insert(pid); + } + } + } + + // We expect 4 different PIDs + expect_that!(pids.len(), eq(4)); +} + +#[gtest] +fn standalone_mode_replay_corpus_per_test_budget(fixture: &EnvVars) { + let target_binary_path = + fixture.target_binary_path.parent().unwrap().join("standalone_fuzz_tests_bin"); + + let corpus_db = fixture.tmp_dir_path.join("corpus_db_per_test"); + let work_dir_root = fixture.tmp_dir_path.join("WD_root_per_test"); + + // Centipede appends the binary identifier (relative path) to the corpus database path. + let identifier = target_binary_path.to_str().unwrap(); + let identifier_relative = identifier.strip_prefix('/').unwrap_or(identifier); + + // Create corpus dirs for tests + let test1_dir = corpus_db + .join(identifier_relative) + .join("__fuzztest_mod__standalone_validation_test.standalone_validation_test") + .join("coverage"); + let test2_dir = corpus_db + .join(identifier_relative) + .join("__fuzztest_mod__second_validation_test.second_validation_test") + .join("coverage"); + + std::fs::create_dir_all(&test1_dir).unwrap(); + std::fs::create_dir_all(&test2_dir).unwrap(); + + // Write some corpus inputs. + // Assuming Arbitrary consumes 4 bytes. + use std::fs::File; + use std::io::Write; + + let mut file1 = File::create(test1_dir.join("input1")).unwrap(); + file1.write_all(&[1, 0, 0, 0]).unwrap(); + + let mut file2 = File::create(test1_dir.join("input2")).unwrap(); + file2.write_all(&[2, 0, 0, 0]).unwrap(); + + let mut file3 = File::create(test2_dir.join("input3")).unwrap(); + file3.write_all(&[3, 0, 0, 0]).unwrap(); + + let process_per_test = Command::new(&target_binary_path) + .arg("__fuzztest_mod__") + .env("FUZZTEST_REPLAY_CORPUS_FOR", "3s") + .env("FUZZTEST_TIME_BUDGET_TYPE", "per-test") + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true") + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) + .env("FUZZTEST_CORPUS_DB", corpus_db.to_str().unwrap()) + .env("FUZZTEST_WORKDIR_ROOT", work_dir_root.to_str().unwrap()) + .env("RUST_TEST_NOCAPTURE", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output_per_test = process_per_test.wait_with_output().expect("Should terminate"); + let stderr_per_test = String::from_utf8_lossy(&output_per_test.stderr); + + // Verify logs for per-test + expect_that!( + stderr_per_test, + matchers::contains_substring("Replaying __fuzztest_mod__standalone_validation_test.standalone_validation_test for 3s") + ); + expect_that!( + stderr_per_test, + matchers::contains_substring( + "Replaying __fuzztest_mod__second_validation_test.second_validation_test for 3s" + ) + ); + + // Count executions for per-test + let mut val1_count = 0; + let mut val2_count = 0; + for line in stderr_per_test.lines() { + if line.contains("STANDALONE_VALIDATION_INPUT:") { + val1_count += 1; + } + if line.contains("SECOND_VALIDATION_INPUT:") { + val2_count += 1; + } + } + // we had 2 inputs for first test + expect_that!(val1_count, eq(2)); + // and 1 input for second test + expect_that!(val2_count, eq(1)); +} + +#[gtest] +fn standalone_mode_replay_corpus_total_budget(fixture: &EnvVars) { + let target_binary_path = + fixture.target_binary_path.parent().unwrap().join("standalone_fuzz_tests_bin"); + + let corpus_db = fixture.tmp_dir_path.join("corpus_db_total"); + let work_dir_root = fixture.tmp_dir_path.join("WD_root_total"); + + // Centipede appends the binary identifier (relative path) to the corpus database path. + let identifier = target_binary_path.to_str().unwrap(); + let identifier_relative = identifier.strip_prefix('/').unwrap_or(identifier); + + // Create corpus dirs for tests + let test1_dir = corpus_db + .join(identifier_relative) + .join("__fuzztest_mod__standalone_validation_test.standalone_validation_test") + .join("coverage"); + let test2_dir = corpus_db + .join(identifier_relative) + .join("__fuzztest_mod__second_validation_test.second_validation_test") + .join("coverage"); + + std::fs::create_dir_all(&test1_dir).unwrap(); + std::fs::create_dir_all(&test2_dir).unwrap(); + + // Write some corpus inputs. + // Assuming Arbitrary consumes 4 bytes. + use std::fs::File; + use std::io::Write; + + let mut file1 = File::create(test1_dir.join("input1")).unwrap(); + file1.write_all(&[1, 0, 0, 0]).unwrap(); + + let mut file2 = File::create(test1_dir.join("input2")).unwrap(); + file2.write_all(&[2, 0, 0, 0]).unwrap(); + + let mut file3 = File::create(test2_dir.join("input3")).unwrap(); + file3.write_all(&[3, 0, 0, 0]).unwrap(); + + // We have 3 fuzz tests in the binary. Total 3s / 3 = 1s per test. + let process_total = Command::new(&target_binary_path) + .arg("__fuzztest_mod__") + .env("FUZZTEST_REPLAY_CORPUS_FOR", "3s") + .env("FUZZTEST_TIME_BUDGET_TYPE", "total") + .env("FUZZTEST_PRINT_SUBPROCESS_LOG", "true") + .env("FUZZTEST_CENTIPEDE_BINARY_PATH", &fixture.centipede_path) + .env("FUZZTEST_CORPUS_DB", corpus_db.to_str().unwrap()) + .env("FUZZTEST_WORKDIR_ROOT", work_dir_root.to_str().unwrap()) + .env("RUST_TEST_NOCAPTURE", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn binary"); + + let output_total = process_total.wait_with_output().expect("Should terminate"); + let stderr_total = String::from_utf8_lossy(&output_total.stderr); + + // Verify logs for total -> should be 1s + expect_that!( + stderr_total, + matchers::contains_substring("Replaying __fuzztest_mod__standalone_validation_test.standalone_validation_test for 1s") + ); + expect_that!( + stderr_total, + matchers::contains_substring( + "Replaying __fuzztest_mod__second_validation_test.second_validation_test for 1s" + ) + ); + + // Counts should still match because it finishes early anyway + let mut val1_count_total = 0; + let mut val2_count_total = 0; + for line in stderr_total.lines() { + if line.contains("STANDALONE_VALIDATION_INPUT:") { + val1_count_total += 1; + } + if line.contains("SECOND_VALIDATION_INPUT:") { + val2_count_total += 1; + } + } + // we had 2 inputs for first test + expect_that!(val1_count_total, eq(2)); + // and 1 input for second test + expect_that!(val2_count_total, eq(1)); +} diff --git a/rust/e2e_tests/test_utils.rs b/rust/e2e_tests/test_utils.rs new file mode 100644 index 000000000..ea9dc8a5b --- /dev/null +++ b/rust/e2e_tests/test_utils.rs @@ -0,0 +1,65 @@ +use googletest::fixtures::Fixture; +use std::env; +use std::path::PathBuf; +use std::process::Command; + +pub struct EnvVars { + pub tmp_dir_path: PathBuf, + pub target_binary_path: PathBuf, + pub centipede_path: PathBuf, +} + +impl Fixture for EnvVars { + fn set_up() -> googletest::Result { + const TARGET_BINARY: &str = + "com_google_fuzztest/rust/e2e_tests/testdata/fuzztest_main"; + const CENTIPEDE: &str = + "com_google_fuzztest/centipede/google/centipede_uninstrumented"; + let src_dir_path = PathBuf::from(env::var("TEST_SRCDIR").unwrap()); + let tmp_dir_path = PathBuf::from(env::var("TEST_TMPDIR").unwrap()); + let target_binary_path = src_dir_path.join(PathBuf::from(TARGET_BINARY)); + let centipede_path = src_dir_path.join(PathBuf::from(CENTIPEDE)); + + Ok(EnvVars { tmp_dir_path, target_binary_path, centipede_path }) + } + fn tear_down(self) -> googletest::Result<()> { + Ok(()) + } +} + +/// Returns stderr of a Centipede process with `args` that is expected to terminate through some +/// internal flag. e.g. `--exit_on_crash` or `--stop_after`. +/// +/// Each arg of `args` should be a string that Centipede recognizes containing a flag with a +/// possible value, e.g. `--test_name=my_test_name` or `--exit_on_crash`. In addition to `args`, +/// the function will also pass `--populate_binary_info=0`, `--fork_server=0`, +/// `--persistent_mode=0`, and `--env_diff_for_binaries`. +pub fn run_centipede_with_args_expect_termination(fixture: &EnvVars, args: &[&str]) -> String { + // Disable interference from Bazel environment variables. + let env_diff = [ + "-TEST_DIAGNOSTICS_OUTPUT_DIR", + "-TEST_INFRASTRUCTURE_FAILURE_FILE", + "-TEST_LOGSPLITTER_OUTPUT_FILE", + "-TEST_PREMATURE_EXIT_FILE", + "-TEST_RANDOM_SEED", + "-TEST_RUN_NUMBER", + "-TEST_SHARD_INDEX", + "-TEST_SHARD_STATUS_FILE", + "-TEST_TOTAL_SHARDS", + "-TEST_UNDECLARED_OUTPUTS_ANNOTATIONS_DIR", + "-TEST_UNDECLARED_OUTPUTS_DIR", + "-TEST_WARNINGS_OUTPUT_FILE", + "-GTEST_OUTPUT", + "-XML_OUTPUT_FILE", + ]; + let process = Command::new(&fixture.centipede_path) + .arg("--populate_binary_info=0") + .arg("--fork_server=0") + .arg("--persistent_mode=0") + .arg(format!("--env_diff_for_binaries={}", env_diff.join(","))) + .args(args) + .output() + .expect("Centipede should have executed"); + + String::from_utf8_lossy(&process.stderr).to_string() +} diff --git a/rust/e2e_tests/testdata/BUILD b/rust/e2e_tests/testdata/BUILD new file mode 100644 index 000000000..976fa6f7c --- /dev/null +++ b/rust/e2e_tests/testdata/BUILD @@ -0,0 +1,106 @@ +load("//third_party/bazel_rules/rules_rust/rust:defs.bzl", "rust_test") + +package(default_visibility = ["@com_google_fuzztest//rust/e2e_tests:__subpackages__"]) + +licenses(["notice"]) + +rust_test( + name = "fuzztest_main", + testonly = 1, + srcs = [ + "fuzz_tests.rs", + ], + edition = "2024", + # TODO: b/437896409 - Replace with global config once it's available. + rustc_flags = [ + "-Ccodegen-units=1", + "-Clink-dead-code", + "-Cpasses=sancov-module", + "-Cllvm-args=-sanitizer-coverage-level=1", + "-Cllvm-args=-sanitizer-coverage-trace-compares", + ], + tags = [ + "manual", + "notap", + ], + deps = [ + "//third_party/gtest_rust/googletest", + "//third_party/rust/anyhow/v1:anyhow", + "//third_party/rust/inventory/v0_3:inventory", + "//third_party/rust/rand/v0_10:rand", + "@com_google_fuzztest//rust:fuzztest", + ], +) + +rust_test( + name = "standalone_fuzz_tests_bin", + testonly = 1, + srcs = [ + "standalone_fuzz_tests.rs", + ], + edition = "2024", + rustc_flags = [ + "-Ccodegen-units=1", + "-Clink-dead-code", + "-Cpasses=sancov-module", + "-Cllvm-args=-sanitizer-coverage-level=1", + "-Cllvm-args=-sanitizer-coverage-trace-compares", + ], + tags = [ + "manual", + "notap", + ], + deps = [ + "//third_party/gtest_rust/googletest", + "//third_party/rust/anyhow/v1:anyhow", + "//third_party/rust/inventory/v0_3:inventory", + "//third_party/rust/rand/v0_10:rand", + "@com_google_fuzztest//rust:fuzztest", + ], +) + +rust_test( + name = "replay_fuzz_tests_bin", + testonly = 1, + srcs = [ + "replay_fuzz_tests.rs", + ], + edition = "2024", + rustc_flags = [ + "-Ccodegen-units=1", + "-Clink-dead-code", + "-Cpasses=sancov-module", + "-Cllvm-args=-sanitizer-coverage-level=1", + "-Cllvm-args=-sanitizer-coverage-trace-compares", + ], + tags = [ + "manual", + "notap", + ], + deps = [ + "//third_party/gtest_rust/googletest", + "//third_party/rust/anyhow/v1:anyhow", + "//third_party/rust/inventory/v0_3:inventory", + "//third_party/rust/rand/v0_10:rand", + "@com_google_fuzztest//rust:fuzztest", + ], +) + +rust_test( + name = "fuzz_tests_as_gtests", + srcs = ["fuzz_tests_as_gtests.rs"], + edition = "2024", + rustc_flags = ["-Zallow-features=cfg_sanitize"], + tags = [ + "manual", + "notap", + ], + deps = [ + "//third_party/gtest_rust/googletest", + "//third_party/rust/anyhow/v1:anyhow", + "//third_party/rust/inventory/v0_3:inventory", + "//third_party/rust/postcard/v1:postcard", + "//third_party/rust/rand/v0_10:rand", + "@com_google_fuzztest//rust:fuzztest", + ], +) diff --git a/rust/e2e_tests/testdata/fuzz_tests.rs b/rust/e2e_tests/testdata/fuzz_tests.rs new file mode 100644 index 000000000..1a5e8745e --- /dev/null +++ b/rust/e2e_tests/testdata/fuzz_tests.rs @@ -0,0 +1,171 @@ +use fuzztest::domains::arbitrary::Arbitrary; +use fuzztest::domains::range::InRange; +use fuzztest::domains::Domain; +use fuzztest::fuzztest; +use rand::RngExt; +use std::mem::MaybeUninit; + +struct ByteVectorDomain {} + +impl ByteVectorDomain { + pub fn new() -> Self { + Self {} + } +} + +// Test-only domain. +impl Domain for ByteVectorDomain { + type UserValue<'user> = Vec; + type CorpusValue = Vec; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + let mut val = vec![0u8; rng.random_range(0..100)]; + rng.fill(&mut val[..]); + Ok(val) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + if rng.random::() < 0.05 && !val.is_empty() { + val.remove(rng.random_range(..val.len())); + } + + for elem in val { + if *elem != 0 { + *elem -= 1; + } + } + + return Ok(()); + } + if rng.random::() < 0.05 { + val.insert(rng.random_range(0..=val.len()), rng.random()); + } + + if rng.random::() < 0.05 && !val.is_empty() { + val.remove(rng.random_range(..val.len())); + } + + let start = rng.random_range(0..=val.len()); + let end = rng.random_range(start..=val.len()); + rng.fill(&mut val[start..end]); + + Ok(()) + } + + fn get_user_value<'a>( + &self, + val: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(val.clone()) + } +} + +#[fuzztest(_a = Arbitrary::::default())] +fn bool_fuzz_test(_a: bool) {} + +#[fuzztest(_a = Arbitrary::::default())] +fn i32_fuzz_test(_a: i32) {} + +#[fuzztest( + _a = Arbitrary::::default(), + _b = Arbitrary::::default(), + _c = Arbitrary::::default(), + _d = ByteVectorDomain::new() +)] +fn multi_arg_fuzz_test(_a: i32, _b: f32, _c: bool, _d: Vec) {} + +// Designed to fail in a "rare" instance. +#[fuzztest(a = ByteVectorDomain::new())] +fn find_bug_fuzz_test(a: Vec) { + if !a.is_empty() && a[0] == 123 { + panic!("Bug found!"); + } +} + +// Designed to fail in an even rarer instance to prove that coverage is working. +#[fuzztest(a = ByteVectorDomain::new())] +fn find_rarer_bug_fuzz_test(a: Vec) { + if a.len() == 5 && a[0] == b'P' && a[1] == b'a' && a[2] == b'N' && a[3] == b'i' && a[4] == b'C' + { + panic!("Bug found!"); + } +} + +#[fuzztest(a = ByteVectorDomain::new(), b = ByteVectorDomain::new())] +fn find_rarer_bug_2_args_fuzz_test(a: Vec, b: Vec) { + if !a.is_empty() && a[0] >= 128 && !b.is_empty() && b[0] == 42 { + panic!("Bug found!"); + } +} + +#[fuzztest(a = ByteVectorDomain::new(), b = Arbitrary::::default(), c = InRange::::new(0, 100))] +fn find_rarer_bug_3_args_fuzz_test(a: Vec, b: f32, c: i32) { + if !a.is_empty() && a[0] >= 128 && b < 0.0 && c >= 95 { + panic!("Bug found!"); + } +} +#[fuzztest(a = ByteVectorDomain::new())] +fn use_after_free_asan_death_test(mut a: Vec) { + if !a.is_empty() && a[0] == 123 { + let ptr = a.as_mut_ptr(); + drop(a); + // Intentionally trigger a use-after-free bug for ASAN to detect. + unsafe { + *(std::hint::black_box(ptr)) = 1; + } + } +} + +#[fuzztest(a = ByteVectorDomain::new())] +fn msan_death_test(a: Vec) { + if !a.is_empty() && a[0] == 123 { + // Intentionally trigger an uninitialized memory bug for MSAN to detect. + unsafe { + let x: MaybeUninit = MaybeUninit::uninit(); + let y = std::hint::black_box(x).assume_init(); + println!("y: {}", y); + } + } +} + +struct FallibleDomain {} + +impl FallibleDomain { + pub fn new() -> Self { + Self {} + } +} + +impl Domain for FallibleDomain { + type UserValue<'user> = u32; + type CorpusValue = u32; + + fn init(&self, _rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(0) + } + + fn mutate( + &self, + _val: &mut Self::CorpusValue, + _rng: &mut dyn rand::Rng, + _only_shrink: bool, + ) -> anyhow::Result<()> { + anyhow::bail!("Intentional mutate failure") + } + + fn get_user_value<'a>( + &self, + val: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(*val) + } +} + +#[fuzztest(a = FallibleDomain::new())] +fn fallible_fuzz_test(_a: u32) {} diff --git a/rust/e2e_tests/testdata/fuzz_tests_as_gtests.rs b/rust/e2e_tests/testdata/fuzz_tests_as_gtests.rs new file mode 100644 index 000000000..4e2ed7b36 --- /dev/null +++ b/rust/e2e_tests/testdata/fuzz_tests_as_gtests.rs @@ -0,0 +1,18 @@ +use fuzztest::domains::arbitrary::Arbitrary; +use fuzztest::fuzztest; +use std::sync::Once; + +#[fuzztest(_a = Arbitrary::::default())] +fn bool_fuzz_test(_a: bool) { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + println!("bool_fuzz_test property function ran..."); + }); +} + +#[fuzztest( + _a = Arbitrary::::default(), + _b = Arbitrary::::default(), + _c = Arbitrary::::default(), +)] +fn multi_arg_fuzz_test(_a: i32, _b: f32, _c: bool) {} diff --git a/rust/e2e_tests/testdata/replay_fuzz_tests.rs b/rust/e2e_tests/testdata/replay_fuzz_tests.rs new file mode 100644 index 000000000..efa610d49 --- /dev/null +++ b/rust/e2e_tests/testdata/replay_fuzz_tests.rs @@ -0,0 +1,83 @@ +use fuzztest::domains::arbitrary::Arbitrary; +use fuzztest::domains::Domain; +use fuzztest::fuzztest; +use rand::RngExt; + +struct ByteVectorDomain {} + +impl ByteVectorDomain { + pub fn new() -> Self { + Self {} + } +} + +// Test-only domain. +impl Domain for ByteVectorDomain { + type UserValue<'user> = Vec; + type CorpusValue = Vec; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + let mut val = vec![0u8; rng.random_range(0..100)]; + rng.fill(&mut val[..]); + Ok(val) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + if rng.random::() < 0.05 && !val.is_empty() { + val.remove(rng.random_range(..val.len())); + } + + for elem in val { + if *elem != 0 { + *elem -= 1; + } + } + + return Ok(()); + } + if rng.random::() < 0.05 { + val.insert(rng.random_range(0..=val.len()), rng.random()); + } + + if rng.random::() < 0.05 && !val.is_empty() { + val.remove(rng.random_range(..val.len())); + } + + let start = rng.random_range(0..=val.len()); + let end = rng.random_range(start..=val.len()); + rng.fill(&mut val[start..end]); + + Ok(()) + } + + fn get_user_value<'a>( + &self, + val: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(val.clone()) + } +} + +#[fuzztest(a = ByteVectorDomain::new())] +fn find_bug_fuzz_test(a: Vec) { + println!("PROPERTY_FUNCTION_EXECUTED"); + if !a.is_empty() && a[0] == 123 { + panic!("Bug found!"); + } +} + +#[fuzztest(_a = Arbitrary::::default())] +fn untargeted_test_1(_a: i32) { + println!("UNTARGETED_TEST_1_EXECUTED"); +} + +#[fuzztest(_a = Arbitrary::::default())] +fn untargeted_test_2(_a: i32) { + println!("UNTARGETED_TEST_2_EXECUTED"); +} diff --git a/rust/e2e_tests/testdata/standalone_fuzz_tests.rs b/rust/e2e_tests/testdata/standalone_fuzz_tests.rs new file mode 100644 index 000000000..e8d7d53d8 --- /dev/null +++ b/rust/e2e_tests/testdata/standalone_fuzz_tests.rs @@ -0,0 +1,21 @@ +use fuzztest::domains::arbitrary::Arbitrary; +use fuzztest::fuzztest; + +#[fuzztest(_a = Arbitrary::::default())] +fn standalone_validation_test(_a: i32) { + // Minimal test function to validate standalone mode. + println!("STANDALONE_VALIDATION_WORKER_EXECUTED"); + println!("STANDALONE_VALIDATION_INPUT: {}", _a); +} + +#[fuzztest(_a = Arbitrary::::default())] +fn second_validation_test(_a: i32) { + println!("SECOND_VALIDATION_TEST_EXECUTED"); + println!("SECOND_VALIDATION_INPUT: {}", _a); +} + +#[fuzztest(_a = Arbitrary::::default())] +fn jobs_test(_a: i32) { + println!("JOBS_TEST_PID: {}", std::process::id()); + std::thread::sleep(std::time::Duration::from_millis(250)); +} diff --git a/rust/e2e_tests/worker_test.rs b/rust/e2e_tests/worker_test.rs new file mode 100644 index 000000000..7676af509 --- /dev/null +++ b/rust/e2e_tests/worker_test.rs @@ -0,0 +1,26 @@ +use googletest::matchers; +use googletest::prelude::*; +use std::fs; +use std::process::Command; +use test_utils::EnvVars; + +#[gtest] +fn get_binary_id_test(fixture: &EnvVars) { + let binary_id_file = fixture.tmp_dir_path.join("binary_id.txt"); + + let mut process = Command::new(&fixture.target_binary_path) + .env( + "CENTIPEDE_RUNNER_FLAGS", + format!(":dump_binary_id:binary_id_output={}:", binary_id_file.display()), + ) + .spawn() + .expect("Failed to spawn binary"); + + process.wait().expect("Should terminate"); + expect_that!( + fs::read_to_string(binary_id_file).unwrap(), + matchers::contains_substring( + (fixture.target_binary_path.file_name()).unwrap().to_string_lossy() + ) + ); +} diff --git a/rust/e2e_tests/worker_with_centipede_sanitizer_test.rs b/rust/e2e_tests/worker_with_centipede_sanitizer_test.rs new file mode 100644 index 000000000..f4c122156 --- /dev/null +++ b/rust/e2e_tests/worker_with_centipede_sanitizer_test.rs @@ -0,0 +1,49 @@ +#![feature(cfg_sanitize)] + +#[cfg(any(sanitize = "address", sanitize = "memory"))] +use googletest::matchers; +#[cfg(any(sanitize = "address", sanitize = "memory"))] +use googletest::prelude::*; +#[cfg(any(sanitize = "address", sanitize = "memory"))] +use std::fs; +#[cfg(any(sanitize = "address", sanitize = "memory"))] +use test_utils::EnvVars; + +#[gtest] +#[cfg(sanitize = "address")] +fn ensure_use_after_free_signature_with_asan(fixture: &EnvVars) { + let work_dir = fixture.tmp_dir_path.join("WD"); // For permissions + fs::create_dir_all(&work_dir).expect("Failed to create working directory"); + + let args = [ + &format!("--binary={}", fixture.target_binary_path.display()), + &format!("--workdir={}", work_dir.display()), + "--exit_on_crash", + "--test_name=__fuzztest_mod__use_after_free_asan_death_test.use_after_free_asan_death_test", + ]; + + let stderr = test_utils::run_centipede_with_args_expect_termination(fixture, &args); + + expect_that!(stderr, matchers::contains_regex("Signature[ \t]*: heap-use-after-free")); +} + +// TODO(yamilmorales): Enable this test on presubmit with --config=msan. +#[gtest] +#[cfg(sanitize = "memory")] +fn ensure_sanitizer_crash_signature_with_msan(fixture: &EnvVars) { + let work_dir = fixture.tmp_dir_path.join("WD"); // For permissions + fs::create_dir_all(&work_dir).expect("Failed to create working directory"); + + let args = [ + &format!("--binary={}", fixture.target_binary_path.display()), + &format!("--workdir={}", work_dir.display()), + "--exit_on_crash", + "--test_name=__fuzztest_mod__msan_death_test.msan_death_test", + "--use_cmp_features=0", // Prevent msan from detecting nested bugs and aborting without + // triggering the death callback. + ]; + + let stderr = test_utils::run_centipede_with_args_expect_termination(fixture, &args); + + expect_that!(stderr, matchers::contains_regex("Signature[ \t]*: Sanitizer crash")); +} diff --git a/rust/e2e_tests/worker_with_centipede_test.rs b/rust/e2e_tests/worker_with_centipede_test.rs new file mode 100644 index 000000000..799545b15 --- /dev/null +++ b/rust/e2e_tests/worker_with_centipede_test.rs @@ -0,0 +1,119 @@ +use googletest::matchers; +use googletest::prelude::*; +use rand::RngExt; +use std::fs; +use test_utils::EnvVars; + +fn run_centipede_test<'a>( + fixture: &EnvVars, + test_name: &str, + extra_args: impl IntoIterator, +) -> String { + let random_suffix: String = + rand::rng().sample_iter(&rand::distr::Alphanumeric).take(10).map(char::from).collect(); + let work_dir_name = format!("WD_{}_{}", test_name, random_suffix); + let work_dir = fixture.tmp_dir_path.join(work_dir_name); + fs::create_dir_all(&work_dir).expect("Failed to create working directory"); + + let mut args: Vec = vec![ + format!("--binary={}", fixture.target_binary_path.display()), + format!("--workdir={}", work_dir.display()), + format!("--test_name={}", test_name), + ]; + args.extend(extra_args.into_iter().map(|s| s.to_string())); + + let args: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + test_utils::run_centipede_with_args_expect_termination(fixture, &args) +} + +#[gtest] +fn find_bug_with_centipede_test(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__find_bug_fuzz_test.find_bug_fuzz_test", + ["--exit_on_crash"], + ); + + expect_that!( + stderr, + matchers::contains_substring("INPUT FAILURE: Property function ran but crashed.") + ); + expect_that!(stderr, matchers::contains_regex("Signature[ \t]*: Unwinding panic")); + expect_that!(stderr, matchers::contains_substring("CRASH LOG: Bug found!")); +} + +#[gtest] +fn ensure_custom_mutator_with_centipede_test(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__find_bug_fuzz_test.find_bug_fuzz_test", + ["--stop_after=10s"], + ); + + // In the 10 seconds that Centipede ran, the fuzzing loop should have requested to mutate. + expect_that!(stderr, matchers::contains_substring("Custom mutator detected")); +} + +#[gtest] +fn ensure_seed_emition_with_centipede_test(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__find_bug_fuzz_test.find_bug_fuzz_test", + ["--exit_on_crash", "--require_seeds"], + ); + + // If not present, then exited early because no seeds were emitted. + expect_that!(stderr, matchers::contains_substring("Number of input seeds available")); +} + +#[gtest] +fn ensure_features_work_finding_rarer_bug(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__find_rarer_bug_fuzz_test.find_rarer_bug_fuzz_test", + ["--exit_on_crash"], + ); + + // Centipede's passes a length-prefixed screen, so the initial \\\\x5 stands for + // that length, not any kind of hex-escaping. + expect_that!(stderr, matchers::contains_regex("Input bytes[ \t]*: \\\\x5PaNiC")); + expect_that!(stderr, matchers::contains_substring("CRASH LOG: Bug found!")); +} + +#[gtest] +fn ensure_features_work_finding_rarer_bug_2_args(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__find_rarer_bug_2_args_fuzz_test.find_rarer_bug_2_args_fuzz_test", + ["--exit_on_crash"], + ); + + expect_that!(stderr, matchers::contains_substring("CRASH LOG: Bug found!")); +} + +#[gtest] +fn ensure_features_work_finding_rarer_bug_3_args(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__find_rarer_bug_3_args_fuzz_test.find_rarer_bug_3_args_fuzz_test", + ["--exit_on_crash"], + ); + + expect_that!(stderr, matchers::contains_substring("CRASH LOG: Bug found!")); +} + +#[gtest] +fn ensure_emit_error_with_centipede_test(fixture: &EnvVars) { + let stderr = run_centipede_test( + fixture, + "__fuzztest_mod__fallible_fuzz_test.fallible_fuzz_test", + ["--stop_after=5s"], + ); + + expect_that!( + stderr, + matchers::contains_substring( + "Emitted failure output: Failed to mutate: Intentional mutate failure" + ) + ); +} diff --git a/rust/engine/BUILD b/rust/engine/BUILD new file mode 100644 index 000000000..c5463a9f8 --- /dev/null +++ b/rust/engine/BUILD @@ -0,0 +1,17 @@ +load("//third_party/bazel_rules/rules_rust/rust:defs.bzl", "rust_library") + +licenses(["notice"]) + +rust_library( + name = "engine", + srcs = [ + "src/engine_ffi.rs", + "src/lib.rs", + ], + cc_deps = [ + "@com_google_fuzztest//centipede:engine_abi", + "@com_google_fuzztest//centipede:engine_controller_with_subprocess", + "@com_google_fuzztest//centipede:engine_worker", + ], + visibility = ["@com_google_fuzztest//rust:__subpackages__"], +) diff --git a/rust/engine/Cargo.toml b/rust/engine/Cargo.toml new file mode 100644 index 000000000..f1df136b7 --- /dev/null +++ b/rust/engine/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "engine-ffi" +version = "0.1.0" +edition = "2021" + +[dependencies] + + diff --git a/rust/engine/build.rs b/rust/engine/build.rs new file mode 100644 index 000000000..5fe9bead9 --- /dev/null +++ b/rust/engine/build.rs @@ -0,0 +1,56 @@ +//! Build script for the `engine-ffi` crate. +//! +//! ## Prerequisites +//! +//! Before compiling or testing this crate using Cargo (`cargo build` or `cargo test`), +//! you must first compile the C++ Centipede dependencies using Bazel (or Blaze): +//! +//! ```bash +//! bazel build //centipede:centipede_engine_static +//! ``` +//! +//! This command generates the required static library archive `libcentipede_engine_static.a` +//! under `bazel-bin/centipede` (or `blaze-bin/centipede`). + +use std::path::{Path, PathBuf}; + +fn copy_lib(src_dir: &Path, dst_dir: &Path, name: &str, rename_to: Option<&str>) { + let src_name = format!("lib{}.a", name); + let dst_name = format!("lib{}.a", rename_to.unwrap_or(name)); + let src_path = src_dir.join(src_name); + let dst_path = dst_dir.join(dst_name); + + if dst_path.exists() { + let _ = std::fs::remove_file(&dst_path); + } + + if let Err(e) = std::fs::copy(&src_path, &dst_path) { + panic!("Failed to copy {} to {}: {}", src_path.display(), dst_path.display(), e); + } +} + +fn main() { + let out_dir = std::env::var("OUT_DIR").map(PathBuf::from).expect("OUT_DIR not set"); + + let mut centipede_bin_dir = None; + + // Try environment variable override + if let Ok(dir) = std::env::var("CENTIPEDE_BIN_DIR") { + if let Ok(p) = PathBuf::from(dir).canonicalize() { + centipede_bin_dir = Some(p); + } + } + + if let Some(centipede_bin_dir) = centipede_bin_dir { + copy_lib(¢ipede_bin_dir, &out_dir, "centipede_engine_static", None); + println!("cargo:rustc-link-search=native={}", out_dir.display()); + } + + println!("cargo:rustc-link-lib=static=centipede_engine_static"); + + // Link required system libraries + println!("cargo:rustc-link-lib=dylib=stdc++"); + println!("cargo:rustc-link-lib=dylib=rt"); + println!("cargo:rustc-link-lib=dylib=dl"); + println!("cargo:rustc-link-lib=dylib=pthread"); +} diff --git a/rust/engine/src/engine_ffi.rs b/rust/engine/src/engine_ffi.rs new file mode 100644 index 000000000..7eceacb8d --- /dev/null +++ b/rust/engine/src/engine_ffi.rs @@ -0,0 +1,441 @@ +// The source of truth for the FFI definitions in this file are these C++ header files: +// - engine_abi.h +// - engine_worker_abi.h +// - engine_controller_abi.h +// TODO(b/529822163): use crubit + +use core::ffi::c_int; + +use core::marker::PhantomData; +use core::marker::PhantomPinned; +use core::ptr; + +// Opaque handles for in-memory input objects used by the test, which +// may be serialized/deserialized by the engine. +#[repr(transparent)] +pub struct FuzzTestInputHandle(pub usize); + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FuzzTestBytesView { + pub data: *const u8, + pub size: usize, +} + +impl FuzzTestBytesView { + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { data: bytes.as_ptr(), size: bytes.len() } + } + + pub fn to_bytes(&self) -> *const [u8] { + if self.data.is_null() { + &[] + } else { + ptr::slice_from_raw_parts(self.data, self.size) + } + } +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FuzzTestBytesViews { + pub views: *const FuzzTestBytesView, + pub count: usize, +} + +impl FuzzTestBytesViews { + pub fn from_slice(views: &[FuzzTestBytesView]) -> Self { + Self { views: views.as_ptr(), count: views.len() } + } + + pub fn as_slice(&self) -> *const [FuzzTestBytesView] { + if self.views.is_null() { + &[] + } else { + ptr::slice_from_raw_parts(self.views, self.count) + } + } +} + +#[repr(C)] +pub struct FuzzTestDiagnosticSinkCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +/// Sink for diagnostics during the setup and execution of the test +/// methods. Methods in this sink are async-safe and thread-safe. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct FuzzTestDiagnosticSink { + pub ctx: *mut FuzzTestDiagnosticSinkCtx, + + /// Emits an unrecoverable error with a human-readable `message`. Engine would + /// propagate the error to the controller command when in the worker mode. + pub emit_error: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestDiagnosticSinkCtx, + message: *const FuzzTestBytesView, + ), + >, + + /// Emits a warning with a human-readable `message`. Engine would log + /// the warning and continue gracefully when in the worker mode. + pub emit_warning: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestDiagnosticSinkCtx, + message: *const FuzzTestBytesView, + ), + >, + + /// Emits a finding for running a test input within `Execute()` with a + /// human-readable `description`, and `signature` for deduplication. + /// + /// Must not be called if the engine is not calling `Execute()`. If called + /// multiple times within the same `Execute()` window, a random one would take + /// effect. + pub emit_finding: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestDiagnosticSinkCtx, + description: *const FuzzTestBytesView, + signature: *const FuzzTestBytesView, + ), + >, +} + +// SAFETY: The engine guarantees that the diagnostic sink and its context are +// thread-safe and async-safe. +unsafe impl Send for FuzzTestDiagnosticSink {} +unsafe impl Sync for FuzzTestDiagnosticSink {} + +#[repr(C)] +pub struct FuzzTestBytesSinkCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +/// Sink for bytes data. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct FuzzTestBytesSink { + pub ctx: *mut FuzzTestBytesSinkCtx, + + /// Emits a byte buffer. Multiple emissions are concatenated. + pub emit: Option< + unsafe extern "C" fn(ctx: *mut FuzzTestBytesSinkCtx, view: *const FuzzTestBytesView), + >, +} + +#[repr(C)] +pub struct FuzzTestInputSinkCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +/// Sink for seed inputs. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct FuzzTestInputSink { + pub ctx: *mut FuzzTestInputSinkCtx, + + /// Emits a test `input` to the engine. Engine would call + /// `FuzzTestAdapter::FreeInput` on the emitted input after the engine is + /// done with it. + pub emit: + Option, +} + +#[repr(C)] +pub struct FuzzTestUint64sView { + pub data: *const u64, + pub size: usize, +} + +impl FuzzTestUint64sView { + pub fn from_slice(slice: &[u64]) -> Self { + Self { data: slice.as_ptr(), size: slice.len() } + } + + pub fn as_slice(&self) -> *const [u64] { + if self.data.is_null() || self.size == 0 { + &[] + } else { + ptr::slice_from_raw_parts(self.data, self.size) + } + } + + pub fn to_bytes(&self) -> *const [u8] { + if self.data.is_null() { + &[] + } else { + ptr::slice_from_raw_parts( + self.data as *const u8, + self.size * core::mem::size_of::(), + ) + } + } +} +/// Constants for the layout of the coverage feature as a 64-bit unsigned +/// integer: +/// +/// - Bits 63..59: 5-bit domain ID of the feature. Each domain is a +/// logically independent feature namespace registered in +/// `FuzzTestAdapter::SetUpCoverageDomains`. +/// - Bits 58..32: 27-bit feature ID within the domain. +/// - Bits 31..0: 32-bit counter value of the feature. +/// +pub struct FuzzTestCoverageFeatureLayout; + +impl FuzzTestCoverageFeatureLayout { + pub const COUNTER_START_BIT: u32 = 0; + pub const COUNTER_BIT_SIZE: u32 = 32; + pub const FEATURE_ID_START_BIT: u32 = 32; + pub const FEATURE_ID_BIT_SIZE: u32 = 27; + pub const DOMAIN_ID_START_BIT: u32 = 59; + pub const DOMAIN_ID_BIT_SIZE: u32 = 5; +} + +#[repr(C)] +pub struct FuzzTestFeedbackSinkCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +/// Sink for execution feedback. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct FuzzTestFeedbackSink { + pub ctx: *mut FuzzTestFeedbackSinkCtx, + + /// Emits an array of coverage features captured from the execution + /// inside `Execute` call. See `FuzzTestCoverageFeatureLayout` for the feature + /// layout. + /// + /// Multiple emissions are concatenated. + pub emit_coverage_features: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestFeedbackSinkCtx, + features: *const FuzzTestUint64sView, + ), + >, +} + +/// Information of a coverage domain. +#[repr(C)] +pub struct FuzzTestCoverageDomain { + /// 5-bit domain ID. + pub domain_id: u8, + /// Human-readable name of the domain for logging. + pub name: FuzzTestBytesView, + /// Number of bits used for the feature IDs in this domain, must be <= 27. + pub feature_id_bit_size: u8, + /// Number of bits used for the counter values in this domain, must be <= 32. + pub counter_bit_size: u8, +} + +#[repr(C)] +pub struct FuzzTestDomainRegistryCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +#[repr(C)] +#[derive(Copy, Clone)] +pub struct FuzzTestCoverageDomainRegistry { + pub ctx: *mut FuzzTestDomainRegistryCtx, + + /// Registers a new coverage `domain`. + pub register: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestDomainRegistryCtx, + domain: *const FuzzTestCoverageDomain, + ), + >, +} + +#[repr(C)] +pub struct FuzzTestAdapterCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +#[repr(C)] +pub struct FuzzTestAdapter { + pub ctx: *mut FuzzTestAdapterCtx, + + /// Sets up coverage domains using the domain `registry`. + /// The domain registrations must be the same for the all the test adapters of + /// the same test (identified by the test name and the binary). + pub set_up_coverage_domains: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + registry: *const FuzzTestCoverageDomainRegistry, + ), + >, + + /// [Optional] Emits any preset seed inputs of the test using `sink`. + /// The output must be the same for the all the test adapters of the same test + /// (identified by the test name and the binary). + pub get_preset_seed_inputs: + Option, + + /// Emits a randomly generated seed input using `sink`. + pub get_random_seed_input: + Option, + + /// Mutates from `origin`, and emits the mutant using `sink`. `shrink` != 0 + /// means to generate smaller mutant. + /// + /// It should not change the content/metadata of `origin`. + pub mutate: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + origin: FuzzTestInputHandle, + shrink: c_int, + sink: *const FuzzTestInputSink, + ), + >, + + /// [Optional] Performs cross-over mutation using `origin` and `other`, and + /// emits the mutant using `sink`. + /// + /// It should not change the content/metadata of `origin` or `other`. + pub cross_over: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + origin: FuzzTestInputHandle, + other: FuzzTestInputHandle, + sink: *const FuzzTestInputSink, + ), + >, + + /// Executes `input` for testing and emits any feedback to `sink`. + /// + /// The `input` metadata may be updated by the adapter for further + /// mutations. The `input` content, which affects the test behavior of + /// `Execute()`, should not be changed. + pub execute: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + input: FuzzTestInputHandle, + sink: *const FuzzTestFeedbackSink, + ), + >, + + /// Serializes the test `input` content into bytes using `sink`. + pub serialize_input_content: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + input: FuzzTestInputHandle, + sink: *const FuzzTestBytesSink, + ), + >, + + /// Deserializes the test `input` content from serialized `content` into + /// a `FuzzTestInputHandle` using `sink`. + pub deserialize_input_content: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + content: *const FuzzTestBytesView, + sink: *const FuzzTestInputSink, + ), + >, + + /// [Optional] Serializes the test `input` metadata into bytes using `sink`. + pub serialize_input_metadata: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + input: FuzzTestInputHandle, + sink: *const FuzzTestBytesSink, + ), + >, + + /// [Optional] Updates the test `input` metadata from serialized `metadata`. + pub update_input_metadata: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterCtx, + metadata: *const FuzzTestBytesView, + input: FuzzTestInputHandle, + ), + >, + + /// Callback to run when the engine is done with `input`. + pub free_input: + Option, + + /// Callback to run when the engine is done with `ctx` (and the adapter). + pub free_ctx: Option, +} + +#[repr(C)] +pub struct FuzzTestAdapterManagerCtx { + _opaque: (), + _not_send_nor_sync: PhantomData<*mut ()>, + _not_unpin: PhantomPinned, +} + +#[repr(C)] +pub struct FuzzTestAdapterManager { + pub ctx: *mut FuzzTestAdapterManagerCtx, + + /// [Optional] Emits the ID for the current binary. + pub get_binary_id: Option< + unsafe extern "C" fn(ctx: *mut FuzzTestAdapterManagerCtx, sink: *const FuzzTestBytesSink), + >, + + /// Emits the test name. + pub get_test_name: Option< + unsafe extern "C" fn(ctx: *mut FuzzTestAdapterManagerCtx, sink: *const FuzzTestBytesSink), + >, + + /// Constructs an adapter of the test into `adapter_out`. Any diagnostics + /// happening during the construction or running the adapter should be emitted + /// to `diagnostic_sink`. `diagnostic_sink` is guaranteed to live until + /// `FreeCtx` is called on the adapter. + pub construct_adapter: Option< + unsafe extern "C" fn( + ctx: *mut FuzzTestAdapterManagerCtx, + diagnostic_sink: *const FuzzTestDiagnosticSink, + adapter_out: *mut FuzzTestAdapter, + ), + >, +} + +#[repr(i32)] +pub enum FuzzTestWorkerStatus { + /// Test should finish with a success. + Success = 0, + /// Test should finish with a failure. + Failure = 1, + /// Test should continue with controller commands. + NotRequired = 2, +} + +#[derive(PartialEq, Eq)] +#[repr(i32)] +pub enum FuzzTestControllerStatus { + Success = 0, + Failure = 1, +} + +#[allow(improper_ctypes)] +unsafe extern "C" { + /// Try to run as a FuzzTest worker with `manager` if needed. + pub fn FuzzTestWorkerMaybeRun(manager: *const FuzzTestAdapterManager) -> FuzzTestWorkerStatus; + + /// Returns whether the current process is running as a FuzzTest worker - + /// non-zero means yes and zero means no. It can be called outside of tests. + pub fn FuzzTestWorkerIsRequired() -> c_int; + + /// Run the FuzzTest controller with `flags` and `manager`. + pub fn FuzzTestControllerRun( + manager: *const FuzzTestAdapterManager, + flags: *const FuzzTestBytesViews, + ) -> FuzzTestControllerStatus; +} diff --git a/rust/engine/src/lib.rs b/rust/engine/src/lib.rs new file mode 100644 index 000000000..c61ccf94e --- /dev/null +++ b/rust/engine/src/lib.rs @@ -0,0 +1,268 @@ +pub mod engine_ffi; + +use std::marker::PhantomData; + +#[allow(improper_ctypes)] +unsafe extern "C" { + fn SanCovRuntimeSetUpCoverageDomains( + registry: *const engine_ffi::FuzzTestCoverageDomainRegistry, + ) -> usize; + + fn SanCovRuntimeEmitFeatures(sink: *const engine_ffi::FuzzTestFeedbackSink); +} + +/// A zero-sized witness token proving that a test is currently executing. +/// +/// Some of the methods on FuzzTestDiagnosticSink require that they are called only if the engine is +/// calling `Execute` callback of FuzzTestAdapter, which includes: +/// +/// * `emit_finding` +/// +/// The methods receiving this type can safely assume that the `Execute` callback is active because +/// the caller ensures this by creating this type. +pub struct ExecuteContext { + _private: PhantomData<()>, +} + +impl ExecuteContext { + /// Creates a new execution context. + /// + /// # Safety + /// + /// * The caller must ensure that it is in the `Execute` callback of FuzzTestAdapter. + pub unsafe fn new() -> Self { + Self { _private: PhantomData } + } +} + +#[derive(Clone)] +pub struct DiagnosticSink { + raw: engine_ffi::FuzzTestDiagnosticSink, +} + +impl DiagnosticSink { + /// Creates a safe wrapper from a pointer to `FuzzTestDiagnosticSink`. + /// + /// # Safety + /// + /// The caller must ensure that: + /// * `raw` is a pointer to a valid `FuzzTestDiagnosticSink`. + /// * `(*raw).ctx` is a valid context pointer. + /// * The sink and its context remain valid for the duration of `DiagnosticSink`'s usage + /// (guaranteed by the engine until `FreeCtx` is called on the adapter). + pub unsafe fn from_raw(raw: *const engine_ffi::FuzzTestDiagnosticSink) -> Self { + // SAFETY: Caller guarantees `raw` is a valid pointer to `FuzzTestDiagnosticSink`. + let raw_sink = unsafe { *raw }; + Self { raw: raw_sink } + } + + /// Emits an unrecoverable error message to the underlying diagnostic sink. + pub fn emit_error(&self, message: &str) { + let emit = self.raw.emit_error.expect("EmitError is required by ABI"); + let view = engine_ffi::FuzzTestBytesView::from_bytes(message.as_bytes()); + // SAFETY: The presence of `self` guarantees that the context of the underlying + // `FuzzTestDiagnosticSink` remains live for the duration of this sink. + unsafe { + emit(self.raw.ctx, &view); + } + } + + /// Emits a warning message to the underlying diagnostic sink. + pub fn emit_warning(&self, message: &str) { + let emit = self.raw.emit_warning.expect("EmitWarning is required by ABI"); + let view = engine_ffi::FuzzTestBytesView::from_bytes(message.as_bytes()); + // SAFETY: The presence of `self` guarantees that the context of the underlying + // `FuzzTestDiagnosticSink` remains live for the duration of this sink. + unsafe { + emit(self.raw.ctx, &view); + } + } + + /// Emits a test finding (e.g., a crash or bug) to the underlying diagnostic sink. + /// + /// This must only be called during test execution, which is guaranteed by the + /// [`ExecuteContext`] token. + pub fn emit_finding(&self, _token: &ExecuteContext, description: &str, signature: &str) { + let emit = self.raw.emit_finding.expect("EmitFinding is required by ABI"); + let desc_view = engine_ffi::FuzzTestBytesView::from_bytes(description.as_bytes()); + let sig_view = engine_ffi::FuzzTestBytesView::from_bytes(signature.as_bytes()); + // SAFETY: The presence of `self` guarantees that the context of the underlying + // `FuzzTestDiagnosticSink` remains live for the duration of this sink. + // The witness `_token` further proves execution is currently within the `Execute` callback. + unsafe { + emit(self.raw.ctx, &desc_view, &sig_view); + } + } +} + +pub struct BytesSink { + raw: engine_ffi::FuzzTestBytesSink, +} + +impl BytesSink { + /// Creates a safe wrapper from a pointer to `FuzzTestBytesSink`. + /// + /// # Safety + /// + /// The caller must ensure that: + /// * `raw` is a non-null, properly aligned pointer to a valid `FuzzTestBytesSink`. + /// * `(*raw).ctx` is a valid context pointer. + /// * The sink and its context remain valid for the duration of `BytesSink`'s usage + /// (guaranteed by the engine until `FreeCtx` is called on the adapter). + pub unsafe fn from_raw(raw: *const engine_ffi::FuzzTestBytesSink) -> Self { + // SAFETY: Caller guarantees `raw` is a valid pointer to `FuzzTestBytesSink`. + let raw_sink = unsafe { *raw }; + Self { raw: raw_sink } + } + + /// Emits a byte slice to the underlying bytes sink. + /// + /// Multiple calls to this function will concatenate the emitted bytes. + pub fn emit(&mut self, bytes: &[u8]) { + let emit = self.raw.emit.expect("Emit is required by ABI"); + let view = engine_ffi::FuzzTestBytesView::from_bytes(bytes); + // SAFETY: The presence of `self` guarantees `from_raw` was called with a valid context + // pointer (`self.raw.ctx`) that remains live for the duration of this call. + unsafe { + emit(self.raw.ctx, &view); + } + } +} + +pub struct InputSink { + raw: engine_ffi::FuzzTestInputSink, +} + +impl InputSink { + /// Creates a safe wrapper from a pointer to `FuzzTestInputSink`. + /// + /// # Safety + /// + /// The caller must ensure that: + /// * `raw` is a non-null, properly aligned pointer to a valid `FuzzTestInputSink`. + /// * `(*raw).ctx` is a valid context pointer. + /// * The sink and its context remain valid for the duration of `InputSink`'s usage + /// (guaranteed by the engine until `FreeCtx` is called on the adapter). + pub unsafe fn from_raw(raw: *const engine_ffi::FuzzTestInputSink) -> Self { + // SAFETY: Caller guarantees `raw` is a valid pointer to `FuzzTestInputSink`. + let raw_sink = unsafe { *raw }; + Self { raw: raw_sink } + } + + /// Emits a test input handle to the underlying input sink. + /// + /// This function transfers ownership of the input represented by the handle + /// to the engine. The engine is responsible for freeing it. + pub fn emit(&mut self, input: engine_ffi::FuzzTestInputHandle) { + let emit = self.raw.emit.expect("Emit is required by ABI"); + // SAFETY: The presence of `self` guarantees `from_raw` was called with a valid context + // pointer (`self.raw.ctx`) that remains live for the duration of this call. + unsafe { + emit(self.raw.ctx, input); + } + } +} + +pub struct FeedbackSink { + raw: engine_ffi::FuzzTestFeedbackSink, +} + +impl FeedbackSink { + /// Creates a safe wrapper from a pointer to `FuzzTestFeedbackSink`. + /// + /// # Safety + /// + /// The caller must ensure that: + /// * `raw` is a non-null, properly aligned pointer to a valid `FuzzTestFeedbackSink`. + /// * `(*raw).ctx` is a valid context pointer. + /// * The sink and its context remain valid for the duration of `FeedbackSink`'s usage + /// (guaranteed by the engine until `FreeCtx` is called on the adapter). + pub unsafe fn from_raw(raw: *const engine_ffi::FuzzTestFeedbackSink) -> Self { + // SAFETY: Caller guarantees `raw` is a valid pointer to `FuzzTestFeedbackSink`. + let raw_sink = unsafe { *raw }; + Self { raw: raw_sink } + } + + /// Emits execution coverage features to the underlying feedback sink. + pub fn emit_coverage_features(&mut self, features: &[u64]) { + let emit = + self.raw.emit_coverage_features.expect("EmitCoverageFeatures is required by ABI"); + let view = engine_ffi::FuzzTestUint64sView::from_slice(features); + // SAFETY: The presence of `self` guarantees `from_raw` was called with a valid context + // pointer (`self.raw.ctx`) that remains live for the duration of this call. + unsafe { + emit(self.raw.ctx, &view); + } + } + + /// Emits SanitizerCoverage features. + pub fn emit_sancov_features(&self) { + // SAFETY: the sink pointer is guaranteed to be valid by the framework. + unsafe { SanCovRuntimeEmitFeatures(&self.raw) }; + } +} + +/// Information describing a coverage feature domain. +/// +/// This is a high-level wrapper for [`engine_ffi::FuzzTestCoverageDomain`], +/// which is the Rust representation of the C struct `FuzzTestCoverageDomain` +/// defined in `engine_abi.h` (the source of truth for the ABI). +/// +/// Unlike the FFI representation which uses [`engine_ffi::FuzzTestBytesView`], +/// this type uses Rust slices (`&[u8]`) for safety and convenience. It can be +/// converted to the FFI representation using `From`/`Into`. +/// +/// A coverage domain represents a logically independent feature namespace registered in +/// `CoverageDomainRegistry`. FuzzTest packs 64-bit coverage features into three parts: +/// * Bits 63..59 (5 bits): 5-bit domain ID (`domain_id`, up to 32 domains). +/// * Bits 58..32 (27 bits): Feature ID within the domain (`feature_id_bit_size` <= 27). +/// * Bits 31..0 (32 bits): Feature counter value (`counter_bit_size` <= 32). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CoverageDomain<'a> { + /// 5-bit unique domain identifier (must be < 32). + pub domain_id: u8, + /// Human-readable name of the domain for logging and debugging. + pub name: &'a str, + /// Number of bits used for feature IDs in this domain (must be <= 27). + pub feature_id_bit_size: u8, + /// Number of bits used for feature counter values in this domain (must be <= 32). + pub counter_bit_size: u8, +} + +impl<'a> From> for engine_ffi::FuzzTestCoverageDomain { + fn from(domain: CoverageDomain<'a>) -> Self { + Self { + domain_id: domain.domain_id, + name: engine_ffi::FuzzTestBytesView::from_bytes(domain.name.as_bytes()), + feature_id_bit_size: domain.feature_id_bit_size, + counter_bit_size: domain.counter_bit_size, + } + } +} + +pub struct CoverageDomainRegistry { + raw: engine_ffi::FuzzTestCoverageDomainRegistry, +} + +impl CoverageDomainRegistry { + /// Creates a safe wrapper from a pointer to `FuzzTestCoverageDomainRegistry`. + /// + /// # Safety + /// + /// The caller must ensure that: + /// * `raw` is a non-null, properly aligned pointer to a valid `FuzzTestCoverageDomainRegistry`. + /// * `(*raw).ctx` is a valid context pointer. + /// * The registry and its context remain valid for the duration of `CoverageDomainRegistry`'s usage + /// (guaranteed by the engine until `FreeCtx` is called on the adapter). + pub unsafe fn from_raw(raw: *const engine_ffi::FuzzTestCoverageDomainRegistry) -> Self { + // SAFETY: Caller guarantees `raw` is a valid pointer to `FuzzTestCoverageDomainRegistry`. + let raw_registry = unsafe { *raw }; + Self { raw: raw_registry } + } + + /// Sets up SanitizerCoverage domains. + pub fn set_up_sancov_domains(&self) { + // SAFETY: The registry pointer is guaranteed to be valid by the framework. + unsafe { SanCovRuntimeSetUpCoverageDomains(&self.raw) }; + } +} diff --git a/rust/fuzztest_macro/.rustfmt.toml b/rust/fuzztest_macro/.rustfmt.toml new file mode 100644 index 000000000..33b356d5f --- /dev/null +++ b/rust/fuzztest_macro/.rustfmt.toml @@ -0,0 +1,3 @@ +use_field_init_shorthand = true +use_try_shorthand = true +edition = "2021" diff --git a/rust/fuzztest_macro/BUILD b/rust/fuzztest_macro/BUILD new file mode 100644 index 000000000..85a1d3d61 --- /dev/null +++ b/rust/fuzztest_macro/BUILD @@ -0,0 +1,41 @@ +load("//third_party/bazel_rules/rules_rust/rust:defs.bzl", "rust_proc_macro", "rust_test") + +licenses(["notice"]) + +rust_proc_macro( + name = "fuzztest_macro", + srcs = glob( + [ + "src/**/*.rs", + ], + exclude = ["src/test_main.rs"], + ), + edition = "2024", + visibility = ["@com_google_fuzztest//rust:__subpackages__"], + deps = [ + "//third_party/rust/convert_case/v0_4:convert_case", + "//third_party/rust/proc_macro2/v1:proc_macro2", + "//third_party/rust/proc_macro_crate/v3:proc_macro_crate", + "//third_party/rust/quote/v1:quote", + "//third_party/rust/syn/v2:syn", + ], +) + +rust_test( + name = "fuzztest_macro_test", + srcs = glob( + ["src/**/*.rs"], + exclude = ["src/lib.rs"], + ), + crate_root = "src/test_main.rs", + edition = "2024", + proc_macro_deps = [":fuzztest_macro"], + deps = [ + "//third_party/gtest_rust/googletest", + "//third_party/rust/convert_case/v0_4:convert_case", + "//third_party/rust/proc_macro2/v1:proc_macro2", + "//third_party/rust/proc_macro_crate/v3:proc_macro_crate", + "//third_party/rust/quote/v1:quote", + "//third_party/rust/syn/v2:syn", + ], +) diff --git a/rust/fuzztest_macro/Cargo.toml b/rust/fuzztest_macro/Cargo.toml new file mode 100644 index 000000000..2b616da25 --- /dev/null +++ b/rust/fuzztest_macro/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fuzztest-macro" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +convert_case = "0.4.0" +proc-macro2 = "1.0.106" +proc-macro-crate = "3.4.0" +quote = "1.0.45" +syn = { version = "2.0.117", features = ["extra-traits", "full", "parsing", "printing", "visit-mut"] } + + +[dev-dependencies] +googletest = "0.14.3" diff --git a/rust/fuzztest_macro/src/helpers.rs b/rust/fuzztest_macro/src/helpers.rs new file mode 100644 index 000000000..14578c9f0 --- /dev/null +++ b/rust/fuzztest_macro/src/helpers.rs @@ -0,0 +1,26 @@ +use proc_macro2::Span; +use proc_macro2::TokenStream; +use proc_macro_crate::FoundCrate; +use quote::quote; +use syn::Ident; + +mod fn_item_utils; +mod fuzztest_domain; +pub mod test_registration; + +#[derive(Debug, Clone)] +struct TestFnArgument<'a> { + name: &'a syn::Ident, + ty: syn::Type, +} + +fn import_fuzztest_crate() -> TokenStream { + match proc_macro_crate::crate_name("fuzztest") { + Ok(FoundCrate::Itself) => quote! { crate }, + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, Span::call_site()); + quote! { #ident } + } + Err(_) => quote! { ::fuzztest }, + } +} diff --git a/rust/fuzztest_macro/src/helpers/fn_item_utils.rs b/rust/fuzztest_macro/src/helpers/fn_item_utils.rs new file mode 100644 index 000000000..0fd0f2cbe --- /dev/null +++ b/rust/fuzztest_macro/src/helpers/fn_item_utils.rs @@ -0,0 +1,233 @@ +use syn::parse_quote; +use syn::visit_mut::{self, VisitMut}; +use syn::BareFnArg; +use syn::Error; +use syn::FnArg; +use syn::Pat; +use syn::Signature; +use syn::TypeBareFn; + +use super::TestFnArgument; + +struct LifetimeReplacer<'a> { + user_value_lifetime_generic: &'a syn::Lifetime, +} + +impl<'a> VisitMut for LifetimeReplacer<'a> { + fn visit_lifetime_mut(&mut self, lifetime: &mut syn::Lifetime) { + // If the lifetime is `'static`, we don't want to replace it. + if lifetime.ident != "static" { + *lifetime = self.user_value_lifetime_generic.clone(); + } + visit_mut::visit_lifetime_mut(self, lifetime); + } +} + +/// Extracts the type of the arguments of a given function item (ie: a `syn::ItemFn`). +pub fn extract_test_function_arguments<'a>( + fn_signature: &'a Signature, + user_value_lifetime_generic: &syn::Lifetime, +) -> syn::Result>> { + fn_signature + .inputs + .iter() + .map(|arg| match arg { + FnArg::Receiver(_) => { + Err(Error::new_spanned(arg, "Receiver is not supported in property functions.")) + } + FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { + Pat::Ident(pat_ident) => { + let mut ty = pat_type.ty.as_ref().clone(); + let mut lifetime_replacer = LifetimeReplacer { user_value_lifetime_generic }; + lifetime_replacer.visit_type_mut(&mut ty); + Ok(TestFnArgument { name: &pat_ident.ident, ty }) + } + pat => Err(Error::new_spanned(pat, "Expected an identifier")), + }, + }) + .collect::>>() +} + +/// Extract a bare function pointer type (`syn::TypeBareFn``) from a given +/// signature (`syn::Signature`) +/// +/// For example, passing in a `syn::Signature` representing the function +/// `fn test_function(mut a: i32, b: std::string::String)` would yield the +/// following bare function pointer type `fn(i32, std::string::String)`. +/// +/// Example usage: +/// ```markdown +/// ``` +/// # use syn::parse_quote; +/// +/// let signature = parse_quote! { +/// fn test_function(mut a: i32, b: std::string::String) +/// }; +/// +/// assert_eq!(fn_sig_to_bare_type(&signature), parse_quote!(fn(i32, std::string::String))); +/// ``` +/// ``` +/// +pub fn fn_sig_to_bare_type(fn_sig: &Signature) -> syn::Result { + let generics = &fn_sig.generics; + if generics.type_params().count() > 0 || generics.const_params().count() > 0 { + return Err(Error::new_spanned( + generics, + "Property function should not have any Type or Const generics", + )); + } + + let bare_input_args = fn_sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Receiver(_) => { + Err(Error::new_spanned(arg, "Receiver is not supported in property functions.")) + } + FnArg::Typed(pat_type) => Ok(BareFnArg { + attrs: pat_type.attrs.clone(), + name: None, + ty: pat_type.ty.as_ref().clone(), + }), + }) + .collect::>>()?; + let fn_lifetime_generics = generics.lifetimes().collect::>(); + let unsafety = &fn_sig.unsafety; + let abi = &fn_sig.abi; + let variadic = &fn_sig.variadic; + let output = &fn_sig.output; + if fn_lifetime_generics.is_empty() { + Ok(parse_quote! { + #unsafety #abi fn (#(#bare_input_args),* #variadic) #output + }) + } else { + Ok(parse_quote! { + for< #(#fn_lifetime_generics),* > #unsafety #abi fn (#(#bare_input_args),* #variadic) #output + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::prelude::*; + + #[googletest::test] + fn test_extract_test_function_arguments_succeeds() { + let fn_item: Signature = syn::parse_quote! { + fn test_function(mut a: i32, b: std::string::String) + }; + let user_value_lifetime_generic = parse_quote!('user); + + let test_fn_arguments = + extract_test_function_arguments(&fn_item, &user_value_lifetime_generic); + + expect_that!( + test_fn_arguments, + ok(elements_are![ + matches_pattern!(TestFnArgument { + name: eq(&"e::format_ident!("a")), + ty: eq(&syn::parse_quote! { i32 }), + }), + matches_pattern!(TestFnArgument { + name: eq(&"e::format_ident!("b")), + ty: eq(&syn::parse_quote! { std::string::String }), + }) + ]) + ); + } + + #[googletest::test] + fn test_extract_test_function_arguments_with_lifetimes_succeeds() { + let fn_item: Signature = syn::parse_quote! { + fn test_function<'a, 'b>(mut a: i32, b: &'a str, c: &'b Vec<&'static u8>) + }; + let user_value_lifetime_generic = parse_quote!('user); + + let test_fn_arguments = + extract_test_function_arguments(&fn_item, &user_value_lifetime_generic); + + expect_that!( + test_fn_arguments, + ok(elements_are![ + matches_pattern!(TestFnArgument { + name: eq(&"e::format_ident!("a")), + ty: eq(&syn::parse_quote! { i32 }), + }), + matches_pattern!(TestFnArgument { + name: eq(&"e::format_ident!("b")), + ty: eq(&syn::parse_quote! { &'user str }), + }), + matches_pattern!(TestFnArgument { + name: eq(&"e::format_ident!("c")), + ty: eq(&syn::parse_quote! { &'user Vec<&'static u8> }), + }) + ]) + ); + } + + #[googletest::test] + fn test_extract_test_function_arguments_fails_with_non_ident_arg() { + let fn_item: Signature = syn::parse_quote! { + fn test_function( + MyStruct { a }: MyStruct, b: std::string::String) + }; + let user_value_lifetime_generic = parse_quote!('user); + + let test_fn_arguments = + extract_test_function_arguments(&fn_item, &user_value_lifetime_generic); + + expect_that!( + test_fn_arguments, + err(displays_as(contains_substring("Expected an identifier"))) + ); + } + + #[googletest::test] + fn test_extract_test_function_arguments_fails_with_receiver_arg() { + let fn_item: Signature = syn::parse_quote! { + fn test_function(&self, b: std::string::String) + }; + let user_value_lifetime_generic = parse_quote!('user); + + let test_fn_arguments = + extract_test_function_arguments(&fn_item, &user_value_lifetime_generic); + + expect_that!( + test_fn_arguments, + err(displays_as(contains_substring( + "Receiver is not supported in property functions." + ))) + ); + } + + #[googletest::test] + fn test_convert_fn_sig_to_bare_type_fn_succeeds() { + expect_that!( + fn_sig_to_bare_type(&parse_quote! { + fn test_function(a: i32, b: std::string::String) -> bool + }), + ok(eq(&parse_quote! {fn (i32, std::string::String) -> bool })) + ) + } + + #[googletest::test] + fn test_convert_fn_sig_with_no_return_type_to_bare_type_fn_succeeds() { + expect_that!( + fn_sig_to_bare_type(&parse_quote! { + fn test_function(a: i32, b: std::string::String) + }), + ok(eq(&parse_quote! {fn (i32, std::string::String) })) + ) + } + + #[googletest::test] + fn test_convert_fn_sig_with_lifetimes_to_bare_type_fn_succeeds() { + expect_that!( + fn_sig_to_bare_type(&parse_quote! { + fn test_function<'a, 'b>(a: std::vec::Vec<&'a i32>, b: Test<'b>) + }), + ok(eq(&parse_quote! {for<'a, 'b> fn (std::vec::Vec<&'a i32>, Test<'b>) })) + ) + } +} diff --git a/rust/fuzztest_macro/src/helpers/fuzztest_domain.rs b/rust/fuzztest_macro/src/helpers/fuzztest_domain.rs new file mode 100644 index 000000000..f3cb3cb71 --- /dev/null +++ b/rust/fuzztest_macro/src/helpers/fuzztest_domain.rs @@ -0,0 +1,162 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse_quote; +use syn::Generics; +use syn::Ident; + +use super::import_fuzztest_crate; +use super::TestFnArgument; + +/// Generates a wrapper domain that will contain all domains of the test +/// function arguments. +pub fn generate_fuzztest_domain<'a, 'b: 'a>( + domain_struct_name: &Ident, + test_fn_args: impl IntoIterator>, + user_value_lifetime_generic: &syn::Lifetime, +) -> (TokenStream, Vec) { + let crate_name = import_fuzztest_crate(); + let test_fn_args = test_fn_args.into_iter().collect::>(); + + let domain_generics = + (0..test_fn_args.len()).map(|idx| quote::format_ident!("T{idx}")).collect::>(); + + let corpus_domain_generics = domain_generics + .iter() + .map(|generic| parse_quote!(#generic::CorpusValue)) + .collect::>(); + let user_value_domain_generics = domain_generics + .iter() + .map(|generic| parse_quote!(#generic::UserValue<#user_value_lifetime_generic>)) + .collect::>(); + let mut generics: Generics = parse_quote! { < #(#domain_generics),* > }; + + let test_fn_args_with_generics = + test_fn_args.into_iter().zip(generics.type_params().cloned()).collect::>(); + + let mut field_names = vec![]; + let mut domain_field_types = vec![]; + + let where_clauses = generics.make_where_clause(); + for (TestFnArgument { ty, name }, syn::TypeParam { ident: domain_gen, .. }) in + &test_fn_args_with_generics + { + where_clauses.predicates.push( + parse_quote! { + for <#user_value_lifetime_generic> #domain_gen: #crate_name::domains::Domain = #ty > + }); + where_clauses.predicates.push(parse_quote! { #domain_gen::CorpusValue: #crate_name::serde::Serialize + #crate_name::serde::de::DeserializeOwned }); + field_names.push((*name).clone()); + domain_field_types.push(domain_gen); + } + + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let serde_crate_path = format!("{}", quote!(#crate_name::serde)); + + let domain_definition_tokens = quote! { + #[derive(#crate_name::serde::Serialize, #crate_name::serde::Deserialize, ::std::fmt::Debug, ::std::clone::Clone)] + #[serde(crate = #serde_crate_path)] + struct #domain_struct_name #ty_generics { + #(#field_names : #domain_field_types),* + } + + impl #impl_generics #crate_name::domains::Domain for #domain_struct_name #ty_generics #where_clause { + type UserValue<#user_value_lifetime_generic> = #domain_struct_name <#(#user_value_domain_generics),*>; + type CorpusValue = #domain_struct_name <#(#corpus_domain_generics),*>; + + fn init(&self, rng: &mut dyn ::fuzztest::reexports::rand::Rng) -> ::fuzztest::reexports::anyhow::Result { + Ok(#domain_struct_name { + #(#field_names: self.#field_names.init(rng)?),* + }) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn ::fuzztest::reexports::rand::Rng, + only_shrink: bool, + ) -> ::fuzztest::reexports::anyhow::Result<()> { + #( self.#field_names.mutate(&mut val.#field_names, rng, only_shrink)?; )* + Ok(()) + } + + fn get_user_value<'a>(&self, corpus_value: &'a Self::CorpusValue) -> ::fuzztest::reexports::anyhow::Result> { + Ok(#domain_struct_name { + #(#field_names: self.#field_names.get_user_value(&corpus_value.#field_names)?),* + }) + } + } + }; + (domain_definition_tokens, field_names) +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::prelude::*; + + #[googletest::test] + fn test_generate_fuzztest_domain() { + let (domain_definition_tokens, field_names) = generate_fuzztest_domain( + "e::format_ident!("__FuzzTestTestFuzzStateWrapper"), + &[ + TestFnArgument { + name: "e::format_ident!("a"), + ty: parse_quote!(&'user Vec<&'user i32>), + }, + TestFnArgument { + name: "e::format_ident!("b"), + ty: parse_quote!(std::string::String), + }, + ], + &parse_quote!('user), + ); + expect_that!( + field_names, + elements_are![eq("e::format_ident!("a")), eq("e::format_ident!("b"))] + ); + expect_that!(domain_definition_tokens.to_string(), eq( "e! { + #[derive(::fuzztest::serde::Serialize, ::fuzztest::serde::Deserialize, ::std::fmt::Debug, ::std::clone::Clone)] + #[serde(crate = ":: fuzztest :: serde")] + struct __FuzzTestTestFuzzStateWrapper { + a: T0, + b: T1 + } + + impl ::fuzztest::domains::Domain for __FuzzTestTestFuzzStateWrapper + where for < 'user > T0: ::fuzztest::domains::Domain = &'user Vec<&'user i32> >, + T0::CorpusValue: ::fuzztest::serde::Serialize + ::fuzztest::serde::de::DeserializeOwned, + for < 'user > T1: ::fuzztest::domains::Domain = std::string::String>, + T1::CorpusValue: ::fuzztest::serde::Serialize + ::fuzztest::serde::de::DeserializeOwned { + type UserValue<'user> = __FuzzTestTestFuzzStateWrapper, T1::UserValue<'user> >; + type CorpusValue = __FuzzTestTestFuzzStateWrapper; + + fn init(&self, rng: &mut dyn ::fuzztest::reexports::rand::Rng) -> ::fuzztest::reexports::anyhow::Result { + Ok(__FuzzTestTestFuzzStateWrapper { + a: self.a.init(rng)?, + b: self.b.init(rng)? + }) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn ::fuzztest::reexports::rand::Rng, + only_shrink: bool, + ) -> ::fuzztest::reexports::anyhow::Result<()> { + self.a.mutate(&mut val.a, rng, only_shrink)?; + self.b.mutate(&mut val.b, rng, only_shrink)?; + Ok(()) + } + + fn get_user_value<'a>(&self, corpus_value: &'a Self::CorpusValue) -> ::fuzztest::reexports::anyhow::Result> { + Ok(__FuzzTestTestFuzzStateWrapper { + a: self.a.get_user_value(&corpus_value.a)?, + b: self.b.get_user_value(&corpus_value.b)? + }) + } + } + } + .to_string()) + ); + } +} diff --git a/rust/fuzztest_macro/src/helpers/test_registration.rs b/rust/fuzztest_macro/src/helpers/test_registration.rs new file mode 100644 index 000000000..030908696 --- /dev/null +++ b/rust/fuzztest_macro/src/helpers/test_registration.rs @@ -0,0 +1,378 @@ +use convert_case::Case; +use convert_case::Casing; +use proc_macro2::Span; +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse_quote; +use syn::Expr; +use syn::Generics; +use syn::Ident; +use syn::Lifetime; +use syn::Signature; + +use super::fn_item_utils::extract_test_function_arguments; +use super::fn_item_utils::fn_sig_to_bare_type; +use super::fuzztest_domain::generate_fuzztest_domain; +use super::import_fuzztest_crate; +use super::TestFnArgument; + +const USER_VALUE_LIFETIME_GENERIC_NAME: &str = "'user"; + +#[derive(Clone, Debug)] +pub struct FuzzTestObjectDefinitionAndFactory { + pub fuzztest_object_factory_name: Ident, + pub fuzztest_object_tokenstream: TokenStream, +} + +#[derive(Clone, Debug)] +pub struct GTestDefinition { + pub gtest_tokens: TokenStream, +} + +/// A context struct holding the pre-computed information and token streams required +/// to generate the fuzz test registration, struct definitions, and integration with the test framework. +/// +/// This context is created once per property function. +pub struct FuzzTestRegistrationCtx<'a> { + test_fn_signature: &'a Signature, + test_fn_ident: &'a Ident, + prop_fn_ident: Ident, + test_fn_args: Vec>, + crate_name: TokenStream, + fuzz_test_domain_definition: TokenStream, + fuzz_test_domain_field_names: Vec, + fuzztest_struct_instance_tokens: TokenStream, + fuzz_test_struct_factory_fn_name: Ident, +} + +impl<'a> FuzzTestRegistrationCtx<'a> { + /// Creates a new registration context from a property function signature and its domain constructors. + /// + /// This method analyzes the inputs, derives necessary identifiers and lifetime generics, and + /// pre-computes the tokenstream for the fuzz test struct instance. + pub fn new( + test_fn_signature: &'a Signature, + domain_ctors: impl IntoIterator, + ) -> syn::Result { + let user_value_lifetime_generic = + Lifetime::new(USER_VALUE_LIFETIME_GENERIC_NAME, Span::call_site()); + + let test_fn_args = + extract_test_function_arguments(test_fn_signature, &user_value_lifetime_generic)?; + if test_fn_args.is_empty() { + return Err(syn::Error::new_spanned( + test_fn_signature, + "fuzztest requires at least one argument for the property function", + )); + } + + let test_fn_generics = &test_fn_signature.generics; + if test_fn_generics.type_params().count() > 0 || test_fn_generics.const_params().count() > 0 + { + return Err(syn::Error::new_spanned( + test_fn_generics, + "Property function should not have any Type or Const generics", + )); + } + + let test_fn_ident = &test_fn_signature.ident; + let prop_fn_ident = quote::format_ident!("__property_fn__{}", test_fn_ident); + let domain_ctors = domain_ctors.into_iter().collect::>(); + let fuzz_test_struct_name = + quote::format_ident!("__FuzzTest{}", test_fn_ident.to_string().to_case(Case::Pascal)); + let crate_name = import_fuzztest_crate(); + + let domain_struct_name = quote::format_ident!( + "__FuzzTest{}StateWrapper", + test_fn_ident.to_string().to_case(Case::Pascal) + ); + + let (fuzz_test_domain_definition, fuzz_test_domain_field_names) = generate_fuzztest_domain( + &domain_struct_name, + &test_fn_args, + &user_value_lifetime_generic, + ); + + let fuzztest_struct_instance_tokens = quote!( + #fuzz_test_struct_name { + domain: #domain_struct_name { + #(#fuzz_test_domain_field_names: #domain_ctors),* + }, + test_fn: #prop_fn_ident + } + ); + + let fuzz_test_struct_factory_fn_name = + quote::format_ident!("{}_factory", fuzz_test_struct_name); + + Ok(Self { + test_fn_signature, + test_fn_ident, + prop_fn_ident, + test_fn_args, + crate_name, + fuzz_test_domain_definition, + fuzz_test_domain_field_names, + fuzztest_struct_instance_tokens, + fuzz_test_struct_factory_fn_name, + }) + } + + pub fn prop_fn_ident(&self) -> &Ident { + &self.prop_fn_ident + } + + /// Generates a GoogleTest-compatible test function that wraps the fuzz test. + /// + /// This allows the fuzz test to be executed as a regular tests. + pub fn generate_gtest(&self) -> syn::Result { + let gtest_name = &self.test_fn_ident; + let crate_name = &self.crate_name; + let test_fn_name = self.test_fn_ident.to_string(); + let fuzz_test_factory = &self.fuzz_test_struct_factory_fn_name; + + let tokens = quote!( + #[::googletest::prelude::gtest] + fn #gtest_name() { + // Note: In this setup, `module_path!()` returns a path that starts with the crate name + // (e.g., `crate_name::module::path`). We want to strip this prefix to make the + // test name similar to standard `libtest` paths. + let full_test_name = concat!(module_path!(), "::", #test_fn_name); + let test_name = full_test_name + .split_once("::") + .expect("Crate name is always the first path segment") + .1; + let manager = #crate_name::worker::RustFuzzTestAdapterManager { + test_name, + fuzz_test_factory: #fuzz_test_factory, + }; + #crate_name::worker::process(manager); + } + ); + + Ok(GTestDefinition { gtest_tokens: tokens }) + } + + /// Generates the test `struct` that will contain the state of the test: + /// - the domain of the property function inputs + /// - the property function itself + /// + /// The generated test `struct` object will implement the FuzzTest trait. + pub fn generate_test_registration(&self) -> syn::Result { + let crate_name = &self.crate_name; + let user_value_lifetime_generic = + Lifetime::new(USER_VALUE_LIFETIME_GENERIC_NAME, Span::call_site()); + let test_fn_args = &self.test_fn_args; + let test_fn_args_len = test_fn_args.len(); + + let test_fn_type = fn_sig_to_bare_type(self.test_fn_signature)?; + + let test_fn_ident = self.test_fn_ident; + let fuzz_test_struct_name = + quote::format_ident!("__FuzzTest{}", test_fn_ident.to_string().to_case(Case::Pascal)); + let domain_struct_name = quote::format_ident!( + "__FuzzTest{}StateWrapper", + test_fn_ident.to_string().to_case(Case::Pascal) + ); + let test_fn_name = test_fn_ident.to_string(); + let fuzz_test_struct_factory_fn_name = &self.fuzz_test_struct_factory_fn_name; + + let domain_generics = + (0..test_fn_args_len).map(|idx| quote::format_ident!("T{idx}")).collect::>(); + let corpus_generics = domain_generics + .iter() + .map(|generic| parse_quote!(#generic::CorpusValue)) + .collect::>(); + + let mut generics: Generics = parse_quote! { < #(#domain_generics),* > }; + + let test_fn_args_with_generics = test_fn_args + .iter() + .zip(generics.type_params().cloned()) + .zip(corpus_generics.iter()) + .collect::>(); + + let where_clauses = generics.make_where_clause(); + for ((TestFnArgument { ty, .. }, syn::TypeParam { ident: domain_gen, .. }), corpus_gen) in + &test_fn_args_with_generics + { + where_clauses.predicates.push( + parse_quote! { + for <#user_value_lifetime_generic> #domain_gen: #crate_name::domains::Domain = #ty > + }); + where_clauses.predicates.push(parse_quote! { #corpus_gen: 'static }); + } + + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + let fuzztest_instance = &self.fuzztest_struct_instance_tokens; + let fuzz_test_domain_field_names = &self.fuzz_test_domain_field_names; + let fuzz_test_domain_definition = &self.fuzz_test_domain_definition; + + let fuzztest_struct_definition_and_factory = quote! { + #fuzz_test_domain_definition + + struct #fuzz_test_struct_name #generics { + domain: #domain_struct_name #generics, + test_fn: #test_fn_type + } + + impl #impl_generics #crate_name::internal::FuzzTest for #fuzz_test_struct_name #ty_generics #where_clause { + fn name(&self) -> &'static str { + #test_fn_name + } + fn activate(&mut self) { + todo!("Not implemented!") + } + fn mutate(&mut self) { + todo!("Not implemented!") + } + fn execute<'a>(&self, args: &'a #crate_name::domains::GenericCorpusValue) -> bool { + use #crate_name::domains::Domain; + + let wrapper = args + .downcast_ref::<#domain_struct_name<#(#corpus_generics),*>>() + .expect("Attempt to recover user value before testing failed."); + + let user_value = self.domain.get_user_value(wrapper).expect("Failed to get user value from corpus value"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (self.test_fn)(#(user_value.#fuzz_test_domain_field_names),* ) )); + + result.is_ok() + } + fn print_finding_report(&self) { + todo!("Not implemented!") + } + fn domains(&self) -> &dyn #crate_name::domains::GenericDomain { + &self.domain + } + } + + fn #fuzz_test_struct_factory_fn_name() -> #crate_name::internal::BoxedFuzzTest { + ::std::boxed::Box::new(#fuzztest_instance) + } + }; + + Ok(FuzzTestObjectDefinitionAndFactory { + fuzztest_object_factory_name: fuzz_test_struct_factory_fn_name.clone(), + fuzztest_object_tokenstream: fuzztest_struct_definition_and_factory, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::matchers; + use googletest::prelude::*; + + #[googletest::test] + fn test_generate_test_registration() { + let sig: Signature = parse_quote! { fn test_fuzz(a: i32, b: std::string::String) }; + let expr1: Expr = + parse_quote! { ::fuzztest::domains::arbitrary::Arbitrary::::default() }; + let expr2: Expr = + parse_quote! { ::fuzztest::domains::arbitrary::Arbitrary::::default() }; + let context = FuzzTestRegistrationCtx::new(&sig, [&expr1, &expr2]).unwrap(); + let FuzzTestObjectDefinitionAndFactory { + fuzztest_object_factory_name, + fuzztest_object_tokenstream, + } = context.generate_test_registration().unwrap(); + expect_that!( + fuzztest_object_factory_name, + eq("e::format_ident!("__FuzzTestTestFuzz_factory")) + ); + expect_that!( + fuzztest_object_tokenstream.to_string(), ends_with( quote! { + struct __FuzzTestTestFuzz { + domain: __FuzzTestTestFuzzStateWrapper, + test_fn: fn(i32, std::string::String) + } + + impl ::fuzztest::internal::FuzzTest for __FuzzTestTestFuzz + where for <'user> T0: ::fuzztest::domains::Domain = i32>, + T0::CorpusValue: 'static, + for <'user> T1: ::fuzztest::domains::Domain = std::string::String>, + T1::CorpusValue: 'static { + fn name(&self) -> &'static str { + "test_fuzz" + } + fn activate(&mut self) { + todo!("Not implemented!") + } + fn mutate(&mut self) { + todo!("Not implemented!") + } + fn execute<'a>(&self, args: &'a ::fuzztest::domains::GenericCorpusValue) -> bool { + use ::fuzztest::domains::Domain; + + let wrapper = args + .downcast_ref::<__FuzzTestTestFuzzStateWrapper>() + .expect("Attempt to recover user value before testing failed."); + + let user_value = self.domain.get_user_value(wrapper).expect("Failed to get user value from corpus value"); + // Safety: Data is not reused after the test. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (self.test_fn)(user_value.a, user_value.b) )); + + result.is_ok() + } + fn print_finding_report(&self) { + todo!("Not implemented!") + } + fn domains(&self) -> &dyn ::fuzztest::domains::GenericDomain { + &self.domain + } + } + + fn __FuzzTestTestFuzz_factory() -> ::fuzztest::internal::BoxedFuzzTest { + ::std::boxed::Box::new(__FuzzTestTestFuzz { + domain: __FuzzTestTestFuzzStateWrapper { + a: ::fuzztest::domains::arbitrary::Arbitrary::::default(), + b: ::fuzztest::domains::arbitrary::Arbitrary::::default() + }, + test_fn: __property_fn__test_fuzz + }) + } + } + .to_string()) + ); + } + + #[googletest::test] + fn test_generate_gtest() { + let sig: Signature = parse_quote! { fn test_fuzz(a: i32, b: std::string::String) }; + let expr1: Expr = + parse_quote! { ::fuzztest::domains::arbitrary::Arbitrary::::default() }; + let expr2: Expr = + parse_quote! { ::fuzztest::domains::arbitrary::Arbitrary::::default() }; + let context = FuzzTestRegistrationCtx::new(&sig, [&expr1, &expr2]).unwrap(); + let GTestDefinition { gtest_tokens } = context.generate_gtest().unwrap(); + + // test that the gtest was generated + expect_that!( + gtest_tokens.to_string(), + matchers::contains_substring( + quote! { + #[::googletest::prelude::gtest] + fn test_fuzz() + } + .to_string() + ) + ); + + // test that the test_name is the test path + expect_that!( + gtest_tokens.to_string(), + matchers::contains_substring( + quote! { + let full_test_name = concat!(module_path!(), "::", "test_fuzz"); + let test_name = full_test_name + .split_once("::") + .expect("Crate name is always the first path segment") + .1; + } + .to_string() + ) + ) + } +} diff --git a/rust/fuzztest_macro/src/lib.rs b/rust/fuzztest_macro/src/lib.rs new file mode 100644 index 000000000..19450ea66 --- /dev/null +++ b/rust/fuzztest_macro/src/lib.rs @@ -0,0 +1,126 @@ +/// Modules here cannot be public (ie: `pub`) because a proc-macro crate cannot export anything else +/// other than a proc-macro. That is to say, a function tagged with `#[proc_macro]`, +/// `#[proc_macro_attribute]`, `#[proc_macro_derive]` +mod helpers; + +use proc_macro::TokenStream; + +use quote::quote; +use syn::parse::{Parse, ParseStream, Parser}; +use syn::{parse_macro_input, Item}; + +use helpers::test_registration::FuzzTestObjectDefinitionAndFactory; +use helpers::test_registration::FuzzTestRegistrationCtx; +use helpers::test_registration::GTestDefinition; + +#[derive(Debug)] +struct FuzzTestArg { + _ident: syn::Ident, + _eq_token: syn::Token![=], + domain_ty: syn::Expr, +} + +impl Parse for FuzzTestArg { + fn parse(input: ParseStream) -> syn::Result { + Ok(Self { _ident: input.parse()?, _eq_token: input.parse()?, domain_ty: input.parse()? }) + } +} + +/// The `fuzztest` macro is used to generate and register a fuzz test object to be used by a fuzz +/// testing engine. +/// +/// The macro takes a list of arguments, where each argument is a domain object used to generate +/// values for the property function inputs. +/// +/// Example usage: +/// ```markdown +/// ``` +/// # use fuzztest::fuzztest; +/// # use fuzztest::domains::arbitrary::Arbitrary; +/// # use fuzztest::domains::range::InRange; +/// +/// #[fuzztest(a = Arbitrary::::default(), b = InRange::new(0, 10))] +/// fn fuzz_test_function(a: i32, b: i32) { +/// assert_eq!(a, b); +/// } +/// ``` +/// ``` +/// +#[proc_macro_attribute] +pub fn fuzztest(args: TokenStream, input: TokenStream) -> TokenStream { + let item = parse_macro_input!(input as Item); + + let syn::Item::Fn(mut test_fn) = item else { + return syn::Error::new_spanned(item, "not a function").into_compile_error().into(); + }; + + let original_ident = test_fn.sig.ident.clone(); + let fuzztest_name = original_ident.to_string(); + + // TODO(mathuxny-73): Support the case where there are no arguments. Probably we should return an error + // in this case as it may not make sense to have a FuzzTest without input. + let args = + syn::punctuated::Punctuated::::parse_terminated.parse(args); + + let args = match args { + Ok(args) => args, + Err(e) => return e.into_compile_error().into(), + }; + + let domain_ctors = args.iter().map(|arg| &arg.domain_ty); + + let fuzztest_registration_context = + match FuzzTestRegistrationCtx::new(&test_fn.sig, domain_ctors) { + Ok(fuzztest_registration_context) => fuzztest_registration_context, + Err(e) => return e.into_compile_error().into(), + }; + + let FuzzTestObjectDefinitionAndFactory { + fuzztest_object_factory_name, + fuzztest_object_tokenstream, + } = match fuzztest_registration_context.generate_test_registration() { + Ok(fuzztest_object) => fuzztest_object, + Err(e) => return e.into_compile_error().into(), + }; + + let GTestDefinition { gtest_tokens } = match fuzztest_registration_context.generate_gtest() { + Ok(gtest_tokens) => gtest_tokens, + Err(e) => return e.into_compile_error().into(), + }; + + test_fn.sig.ident = fuzztest_registration_context.prop_fn_ident().clone(); + + let fuzztest_mod_name = quote::format_ident!("__fuzztest_mod__{}", original_ident); + + // TODO(mathuxny-73): Extract the current module path from the invocation of the macro + // (ie: from `item.attrs`) + let tokens = quote! { + #[allow(non_snake_case)] + mod #fuzztest_mod_name { + use super::*; + + #test_fn + + #fuzztest_object_tokenstream + + const _: () = { + static FUZZTEST_INFO: ::fuzztest::internal::FuzzTestInfo = + ::fuzztest::internal::FuzzTestInfo { + name: #fuzztest_name, + file: file!(), + line: line!(), + }; + + ::fuzztest::reexports::inventory::submit! { + ::fuzztest::internal::FuzzTestRegistration { + info: &FUZZTEST_INFO, + factory: #fuzztest_object_factory_name + } + } + }; + + #gtest_tokens + } + }; + tokens.into() +} diff --git a/rust/fuzztest_macro/src/test_main.rs b/rust/fuzztest_macro/src/test_main.rs new file mode 100644 index 000000000..f01ede343 --- /dev/null +++ b/rust/fuzztest_macro/src/test_main.rs @@ -0,0 +1,4 @@ +// This file exists only as a workaround. It serves as the crate root for the `fuzztest_macro_test` +// BUILD target. +// This file should not be included in the `fuzztest_macro` crate. +mod helpers; diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 000000000..0c3034f35 --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,9 @@ +# This file is used to configure the Rust toolchain for FuzzTest. +# +# The channel is set to nightly as the fuzztest crate makes use of the compiler +# sanitizers, which are not yet part of the stable Rust toolchain. +# See https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html +# +# See https://rust-lang.github.io/rustup/concepts/toolchains.html +[toolchain] +channel = "nightly" diff --git a/rust/src/crash_handler.rs b/rust/src/crash_handler.rs new file mode 100644 index 000000000..5dddf7018 --- /dev/null +++ b/rust/src/crash_handler.rs @@ -0,0 +1,38 @@ +#[cfg(any(sanitize = "address", sanitize = "memory"))] +mod callbacks { + use std::ffi::{c_char, CStr}; + + unsafe extern "C" { + pub safe fn __sanitizer_set_death_callback(callback: Option); + + pub safe fn __asan_get_report_description() -> *const c_char; + } + + pub extern "C" fn sanitizer_death_callback() { + use crate::worker; + + let (description, signature) = if cfg!(sanitize = "address") { + let char_ptr = __asan_get_report_description(); + // Safety: `ptr` points to a valid null terminated string. + let signature_cstr = unsafe { CStr::from_ptr(char_ptr) }; + ( + "Property function ran but address sanitizer caught a bug", + signature_cstr.to_str().unwrap_or("ASan crash"), + ) + } else { + ("Property function ran but a sanitizer caught a bug", "Sanitizer crash") + }; + worker::try_emit_finding(description, signature); + } +} + +/// Be able to emit failures before exiting fully from the process for non-unwinding panics and/or +/// unrecoverable crashes. +pub fn register_crash_handler() { + // TODO(yamilmorales): Consider allowing more sanitizers here, and find some other way to + // recognize sanitizers if this feature is not stabilized by the time we need to support Cargo. + #[cfg(any(sanitize = "address", sanitize = "memory"))] + { + callbacks::__sanitizer_set_death_callback(Some(callbacks::sanitizer_death_callback)); + } +} diff --git a/rust/src/domains.rs b/rust/src/domains.rs new file mode 100644 index 000000000..d8451d7c9 --- /dev/null +++ b/rust/src/domains.rs @@ -0,0 +1,180 @@ +pub mod arbitrary; +pub mod range; +pub mod tuple_of; +pub mod utility; + +use anyhow; +use anyhow::Context; + +/// Type alias for a type-erased corpus value. +pub type GenericCorpusValue = Box; + +/// Type alias for a type-erased user value. +pub type GenericUserValue = Box; + +/// A trait for types that represent a set of values that an input can take. +/// +/// Domain types are used to represent the domain of values that a fuzz test input can take +/// throughout the fuzzing process. +/// Domain types are used by the fuzzing engine through the APIs defined in this trait. +/// +/// ## Serialization/Deserialization of CorpusValue and UserValue relationship. +/// +/// The values drawn from a domain are always of type `CorpusValue`. These values are the +/// internal representation of the value and they are not directly usable by the fuzz property +/// function. +/// +/// The `get_user_value` method is used to retrieve the user value from the corpus value. For +/// example, if the domain outputs an `&str` then the CorpusValue could be a `String` and the +/// `get_user_value` method would be used to retrieve the `&str` from the `String`. +/// +/// The `parse_corpus` (resp. `serialize_corpus`) method is used deserialize (resp. serialize) +/// the corpus value from (to) a slice of bytes (resp. a vector of bytes). +/// +/// ### Relationship diagram +/// +/// The methods below are responsible for transforming between `UserValue`, `CorpusValue`, and the +/// serialized representation of `CorpusValue`. Here's a quick overview: +/// +/// ```text +/// +-- get_user_value() <---+ +-- parse_corpus() <---+ +/// | | | | +/// v | v | +/// UserValue<'a> CorpusValue &[u8] +/// | ^ +/// | | +/// +-> serialize_corpus() + +/// ``` +pub trait Domain { + /// The type of the values that the domain outputs. This should of the same type as the + /// parameter of the fuzz property function. + type UserValue<'user>; + /// The type of the corpus from which the values of type UserValue are drawn. + /// For example, if the domain should output an `&str` then the UserValue should be `&str` and + /// the CorpusValue could the owned data structured that the `&str` points to (eg: String). + /// The CorpusValue type should implement `serde::Serialize` and `serde::de::DeserializeOwned`. + type CorpusValue: ::serde::Serialize + ::serde::de::DeserializeOwned; + + /// Initializes a new value drawn from the domain. + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result; + + /// Mutates the value in `val` to a new value drawn from the domain. + /// + /// If `only_shrink` is `true`, then the mutation must not increase the size of the corpus + /// value. Otherwise, the mutation can both shrink and grow the corpus value. + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()>; + + /// Retrieves a UserValue from a given CorpusValue. + /// + /// This is used to convert the corpus value into the user value that can then be passed to the + /// fuzz property function. + fn get_user_value<'a>( + &self, + corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result>; + + /// Turns a slice of bytes into `CorpusValue`. + /// + /// By default, it uses Postcard to deserialize the data. + fn parse_corpus(&self, data: &[u8]) -> anyhow::Result { + postcard::from_bytes(data).context("Failed to deserialize corpus value from bytes") + } + + /// Serializes `corpus_value` to a Vec of bytes (ie, Vec). + /// + /// By default, it uses Postcard to serialize the data. + fn serialize_corpus(&self, corpus_value: &Self::CorpusValue) -> anyhow::Result> { + postcard::to_stdvec(corpus_value).context("Failed to serialize corpus value to bytes") + } +} + +/// A type-erased interface for Domain types. +/// +/// This trait is used to expose a common interface for Domain types to the fuzzing engine through +/// the `FuzzTest` trait. +/// The methods of this trait are similar to that of the `Domain` trait, but they operate on +/// a type-erased `GenericCorpusValue` instead of an explicit type. +pub trait GenericDomain { + /// Initializes a new value drawn from the domain. + /// + /// See `Domain::init` for more details. + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result; + + /// Mutates the value in `val` to a new value drawn from the domain. + /// + /// See `Domain::mutate` for more details. + fn mutate( + &self, + val: &mut GenericCorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()>; + + /// Parses a slice of bytes into a `GenericCorpusValue`. + /// + /// See `Domain::parse_corpus` for more details. + fn parse_corpus(&self, data: &[u8]) -> anyhow::Result; + + /// Serializes a `GenericCorpusValue` to a Vec of bytes (ie, Vec). + /// + /// See `Domain::serialize_corpus` for more details. + fn serialize_corpus(&self, val: &GenericCorpusValue) -> anyhow::Result>; +} + +/// Blanket implementation of the `GenericDomain` trait for types implementing the `Domain` trait. +/// +/// This implementation is a simple pass-through implementation that uses the methods of the +/// `Domain` trait to implement the `GenericDomain` trait. +impl GenericDomain for D +where + D: Domain, + D::CorpusValue: 'static, +{ + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(Box::new(self.init(rng)?)) + } + + /// Retrieves the underlying `Domain`'s `CorpusValue` from the `GenericCorpusValue` and calls + /// the underlying `Domain::mutate` method. + /// + /// If the `GenericCorpusValue` cannot be downcasted to the underlying `Domain`'s `CorpusValue` + /// then an Error is returned. + /// + /// See `GenericDomain::mutate` for more details. + fn mutate( + &self, + val: &mut GenericCorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + self.mutate( + val.downcast_mut().context("Failed to retrieve the Corpus Value")?, + rng, + only_shrink, + ) + } + + /// Calls the underlying `Domain::parse_corpus` method with the `data` and wraps the resulting + /// `CorpusValue` in a `GenericCorpusValue`. + /// + /// See `GenericDomain::parse_corpus` for more details. + fn parse_corpus(&self, data: &[u8]) -> anyhow::Result { + Ok(Box::new(self.parse_corpus(data)?)) + } + + /// Retrieves the underlying `Domain`'s `CorpusValue` from the `GenericCorpusValue` and calls + /// the underlying `Domain::serialize_corpus` method. + /// + /// If the `GenericCorpusValue` cannot be downcasted to the underlying `Domain`'s `CorpusValue` + /// then an Error is returned. + /// + /// See `GenericDomain::serialize_corpus` for more details. + fn serialize_corpus(&self, val: &GenericCorpusValue) -> anyhow::Result> { + self.serialize_corpus(val.downcast_ref().context("Failed to retrieve the Corpus Value")?) + } +} diff --git a/rust/src/domains/arbitrary.rs b/rust/src/domains/arbitrary.rs new file mode 100644 index 000000000..07d7db580 --- /dev/null +++ b/rust/src/domains/arbitrary.rs @@ -0,0 +1,763 @@ +use super::utility::choose_value; +use super::utility::mutate_integer; +use super::utility::shrink_towards; +use super::Domain; + +use anyhow; +use rand::RngExt; + +/// Generates arbitrary values of type `T`. +/// +/// This domain is used to generate values of a specific type without any constraints +/// beyond the type's intrinsic range. For example, `Arbitrary` generates `i32` +/// values spanning the whole `i32` domain `[i32::MIN, i32::MAX]`. +/// +/// Example usage: +/// ``` +/// # use fuzztest::domains::Domain; +/// # use fuzztest::domains::arbitrary::Arbitrary; +/// # use rand::rngs::SmallRng; +/// # use rand::SeedableRng; +/// +/// let arbitrary_i32 = Arbitrary::::default(); +/// let mut rng = SmallRng::seed_from_u64(73); +/// +/// let sample = arbitrary_i32.init(&mut rng); +/// assert!(sample.is_ok()); +/// ``` + +pub struct Arbitrary { + _phantom: std::marker::PhantomData, +} + +// We cannot just use `#[derive(Default)]` because `T` might not be `Default`. +impl Default for Arbitrary { + fn default() -> Self { + Self { _phantom: std::marker::PhantomData } + } +} + +impl Arbitrary { + /// Creates a new `Arbitrary` domain for the given type `T`. + pub fn new() -> Self { + Self { _phantom: std::marker::PhantomData } + } +} + +impl Domain for Arbitrary { + type UserValue<'user> = bool; + type CorpusValue = bool; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(rng.random()) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + // Convention taken from the C++ fuzztest library. + *val = false; + } else { + *val = rng.random(); + } + Ok(()) + } + + fn get_user_value<'a>( + &self, + corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(*corpus_value) + } +} + +macro_rules! impl_domain_for_integer { + // We need an additional "integer type" for this, because + // rand::distr::Distribution<> is not implemented for StandardUniform for isize and usize. + // so we perform the generation using integer types and cast to the size types if needed. + ($ty:ty, $int_ty:ty) => { + impl Domain for Arbitrary<$ty> { + type UserValue<'user> = $ty; + type CorpusValue = $ty; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + // We generate a the equivalent integer type so this works for size types. + let val: $int_ty = choose_value(rng); + Ok(val as $ty) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + *val = shrink_towards(rng, *val as $int_ty, 0) as $ty; + } else { + let original_val = *val; + loop { + *val = mutate_integer(rng, *val as $int_ty, 5, None, None) as $ty; + if *val != original_val { + break; + } + } + } + Ok(()) + } + + fn get_user_value<'a>( + &self, + corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(*corpus_value) + } + } + }; +} + +impl_domain_for_integer!(i8, i8); +impl_domain_for_integer!(u8, u8); +impl_domain_for_integer!(i16, i16); +impl_domain_for_integer!(u16, u16); +impl_domain_for_integer!(i32, i32); +impl_domain_for_integer!(u32, u32); +impl_domain_for_integer!(i64, i64); +impl_domain_for_integer!(u64, u64); +impl_domain_for_integer!(i128, i128); +impl_domain_for_integer!(u128, u128); +impl_domain_for_integer!(isize, i64); +impl_domain_for_integer!(usize, u64); + +macro_rules! impl_domain_for_float { + ($ty:ty) => { + impl Domain for Arbitrary<$ty> { + type UserValue<'user> = $ty; + type CorpusValue = $ty; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(choose_value(rng)) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + // Shrink only finite, non-terminal values. + if val.is_finite() && *val != 0.0 { + *val = shrink_towards(rng, *val, 0.0); + } + return Ok(()); + } + // Non-shrinking mutation. + let original_val = *val; + loop { + // Loop to ensure mutation + if !val.is_finite() { + // NaN or Infinity + *val = self.init(rng)?; + } else { + match rng.random_range(0..4) { + 0 => *val /= 2.0, + 1 => *val = -*val, + 2 => *val += 1.0, + 3 => *val *= 3.0, + _ => unreachable!(), + } + } + + if (val.is_nan() && original_val.is_nan()) || *val == original_val { + continue; + } + break; + } + Ok(()) + } + + fn get_user_value<'a>( + &self, + corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(*corpus_value) + } + } + }; +} + +impl_domain_for_float!(f32); +impl_domain_for_float!(f64); + +const SURROGATE_START: u32 = 0xD800; +const SURROGATE_END: u32 = 0xDFFF; +const MAX_VALID_CODEPOINT: u32 = 0x10FFFF; +const NUM_VALID_CODEPOINTS: u32 = MAX_VALID_CODEPOINT - (SURROGATE_END - SURROGATE_START); + +/// Maps a `char` to a `u32` in a contiguous range. +/// +/// Rust's `char` type represents a Unicode Scalar Value, which means it cannot be a +/// surrogate code point (U+D800 to U+DFFF). This function maps `char` values to a +/// contiguous integer range by shifting down the code points that are above the +/// surrogate range. This is useful for mutation strategies like random walks over +/// a dense integer space. +fn map_char_to_int(c: char) -> u32 { + let val = c as u32; + if val >= SURROGATE_START { + val - (SURROGATE_END - SURROGATE_START + 1) + } else { + val + } +} + +/// Maps a `u32` from the contiguous range back to a `char`. +/// +/// This is the inverse of `map_char_to_int`. It converts an integer from the +/// dense range back to a `char`. Integers that fall within the range previously +/// occupied by surrogates are shifted up to their correct code points. +fn map_int_to_char(u: u32) -> char { + assert!( + u < NUM_VALID_CODEPOINTS, + "input to map_int_to_char out of range: got {}, expected value < {}", + u, + NUM_VALID_CODEPOINTS + ); + let val = if u >= SURROGATE_START { u + (SURROGATE_END - SURROGATE_START + 1) } else { u }; + std::char::from_u32(val).unwrap() +} + +impl Domain for Arbitrary { + type UserValue<'user> = char; + type CorpusValue = char; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(choose_value(rng)) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + // Shrink towards the null character (0 as char). + *val = shrink_towards(rng, *val, 0 as char); + } else { + let original_val = *val; + loop { + // Map the current char to a u32 in a contiguous range. + let mapped_val = map_char_to_int(*val); + // Mutate the u32 value within the valid range of mapped code points. + let mutated_mapped_val = + mutate_integer(rng, mapped_val, 5, Some(0), Some(NUM_VALID_CODEPOINTS - 1)); + // Map the mutated u32 back to a char. + *val = map_int_to_char(mutated_mapped_val); + if *val != original_val { + break; + } + } + } + Ok(()) + } + + fn get_user_value<'a>( + &self, + corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(*corpus_value) + } +} + +impl Domain for Arbitrary<()> { + type UserValue<'user> = (); + type CorpusValue = (); + + fn init(&self, _rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(()) + } + + fn mutate( + &self, + _val: &mut Self::CorpusValue, + _rng: &mut dyn rand::Rng, + _only_shrink: bool, + ) -> anyhow::Result<()> { + // No possible mutation for the unit type + Ok(()) + } + + fn get_user_value<'a>( + &self, + _corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::domains::utility::SpecialValues; + use num_traits::{Float, One, Zero}; + use rand::distr::uniform::SampleUniform; + use rand::distr::{Distribution, StandardUniform}; + use rand::rngs::{SmallRng, SysRng}; + use rand::SeedableRng; + + use crate::domains::Domain; + + use super::*; + + type CorpusValueForArbitrary = as Domain>::CorpusValue; + + fn get_rng() -> SmallRng { + SmallRng::try_from_rng(&mut SysRng).unwrap() + } + + /// Trait to be able to treat numerical types uniformly w.r.t. finiteness and shrinking target. + trait NumTraitsExtended { + fn is_finite_value(&self) -> bool; + fn is_at_shrink_target(&self) -> bool; + } + + macro_rules! impl_num_traits_extended_for_floats { + ($($ty:ty),*) => { + $( + impl NumTraitsExtended for $ty { + fn is_finite_value(&self) -> bool { + self.is_finite() + } + fn is_at_shrink_target(&self) -> bool { + // We arbitrarily use EPSILON^2 as the accuracy margin for approximate equality. + self.abs() < <$ty>::EPSILON * <$ty>::EPSILON + } + } + )* + }; + } + + impl_num_traits_extended_for_floats!(f32, f64); + + macro_rules! impl_num_traits_extended_for_non_floats { + ($($ty:ty),*) => { + $( + impl NumTraitsExtended for $ty { + fn is_finite_value(&self) -> bool { + true + } + fn is_at_shrink_target(&self) -> bool { + *self == <$ty>::default() + } + } + )* + }; + } + + impl_num_traits_extended_for_non_floats!( + i8, + i16, + i32, + i64, + i128, + isize, + u8, + u16, + u32, + u64, + u128, + usize, + bool, + char, + () + ); + + fn test_arbitrary_mutations_change_value() + where + for<'user> Arbitrary: Domain = T>, + CorpusValueForArbitrary: + std::fmt::Debug + Default + Clone + Copy + PartialOrd + PartialEq + 'static, + { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + let mut value = domain.init(&mut rng).unwrap(); + + // Mutation is guaranteed to change the value. + for _ in 0..100 { + let original_value = value; + domain.mutate(&mut value, &mut rng, false).unwrap(); + assert_ne!( + value, + original_value, + "Mutation did not change the value for type {}", + std::any::type_name::() + ); + } + } + + fn test_arbitrary_mutations_generates_diverse_values() + where + for<'user> Arbitrary: Domain = T>, + CorpusValueForArbitrary: std::fmt::Debug + + Default + + Clone + + Copy + + PartialOrd + + PartialEq + + Eq + + std::hash::Hash + + 'static, + { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + for _ in 0..100 { + let mut value = domain.init(&mut rng).unwrap(); + let mut values = HashSet::new(); + values.insert(value); + + for _ in 0..100 { + domain.mutate(&mut value, &mut rng, false).unwrap(); + values.insert(value); + } + assert!( + values.len() >= 50, + "Mutation did not generate diverse values for type {}: found {} distinct values, expected >= 50", + std::any::type_name::(), + values.len() + ); + } + } + + fn test_arbitrary_shrinking_reduces_and_terminates() + where + for<'user> Arbitrary: Domain = T>, + CorpusValueForArbitrary: std::fmt::Debug + + Default + + Clone + + Copy + + PartialOrd + + PartialEq + + NumTraitsExtended + + 'static, + { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + + // Get a value that is not the shrink target + let shrink_target = CorpusValueForArbitrary::::default(); + let mut value; + loop { + value = domain.init(&mut rng).unwrap(); + if !value.is_at_shrink_target() && value.is_finite_value() { + break; + } + } + + for _ in 0..1000 { + let prev_value = value; + domain.mutate(&mut value, &mut rng, true).unwrap(); + + if value.is_at_shrink_target() { + // Ensure that once the shrink target is reached, further shrinking doesn't change it. + domain.mutate(&mut value, &mut rng, true).unwrap(); + assert!( + value.is_at_shrink_target(), + "Shrinking past the target changed the value for type {}", + std::any::type_name::() + ); + return; + } + + // Check progress + if prev_value > shrink_target { + assert!( + value < prev_value, + "Value should decrease when shrinking towards target from above. Type: {}, Prev: {:?}, Current: {:?}", + std::any::type_name::(), + prev_value, + value + ); + assert!( + value >= shrink_target, + "Value should not shrink past the target from above. Type: {}, Prev: {:?}, Current: {:?}, Target: {:?}", + std::any::type_name::(), + prev_value, + value, + shrink_target + ); + } else { + // prev_value < shrink_target + assert!( + value > prev_value, + "Value should increase when shrinking towards target from below. Type: {}, Prev: {:?}, Current: {:?}", + std::any::type_name::(), + prev_value, + value + ); + assert!( + value <= shrink_target, + "Value should not shrink past the target from below. Type: {}, Prev: {:?}, Current: {:?}, Target: {:?}", + std::any::type_name::(), + prev_value, + value, + shrink_target + ); + } + } + + panic!( + "Shrinking did not terminate in 1000 iterations for type {}", + std::any::type_name::() + ); + } + + fn test_arbitrary() + where + for<'user> Arbitrary: Domain = T>, + CorpusValueForArbitrary: std::fmt::Debug + + Default + + Clone + + Copy + + PartialOrd + + PartialEq + + Eq + + std::hash::Hash + + NumTraitsExtended + + 'static, + { + test_arbitrary_mutations_change_value::(); + test_arbitrary_mutations_generates_diverse_values::(); + test_arbitrary_shrinking_reduces_and_terminates::(); + } + + #[test] + fn test_i8() { + test_arbitrary::(); + } + #[test] + fn test_u8() { + test_arbitrary::(); + } + #[test] + fn test_i16() { + test_arbitrary::(); + } + #[test] + fn test_u16() { + test_arbitrary::(); + } + #[test] + fn test_i32() { + test_arbitrary::(); + } + #[test] + fn test_u32() { + test_arbitrary::(); + } + #[test] + fn test_i64() { + test_arbitrary::(); + } + #[test] + fn test_u64() { + test_arbitrary::(); + } + #[test] + fn test_i128() { + test_arbitrary::(); + } + #[test] + fn test_u128() { + test_arbitrary::(); + } + #[test] + fn test_isize() { + test_arbitrary::(); + } + #[test] + fn test_usize() { + test_arbitrary::(); + } + + #[test] + fn test_char() { + test_arbitrary::(); + } + + fn test_bool_shrink() { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + let mut value = true; + domain.mutate(&mut value, &mut rng, true).unwrap(); + assert_eq!(value, false); + domain.mutate(&mut value, &mut rng, true).unwrap(); + assert_eq!(value, false); + } + + #[test] + fn test_bool() { + test_arbitrary_shrinking_reduces_and_terminates::(); + test_bool_shrink(); + } + #[test] + fn test_unit() { + let mut rng = get_rng(); + let domain = Arbitrary::<()>::default(); + + // init() always returns () + assert_eq!(domain.init(&mut rng).unwrap(), ()); + + // mutate (non-shrinking) always returns () + let mut value = (); + domain.mutate(&mut value, &mut rng, false).unwrap(); + assert_eq!(value, ()); + + // mutate (shrinking) always returns () + let mut value = (); + domain.mutate(&mut value, &mut rng, true).unwrap(); + assert_eq!(value, ()); + } + + fn test_float_special_value_shrink() + where + for<'user> Arbitrary: Domain = T>, + CorpusValueForArbitrary: + Float + SampleUniform + std::fmt::Display + std::fmt::Debug + SpecialValues + 'static, + StandardUniform: Distribution, + { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + + // Positive. + let mut v = CorpusValueForArbitrary::::one(); + let original_v = v; + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert!( + v >= CorpusValueForArbitrary::::zero() && v < original_v, + "Shrinking {} to {}", + original_v, + v + ); + + // Negative. + let mut v = -CorpusValueForArbitrary::::one(); + let original_v = v; + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert!( + v > original_v && v <= CorpusValueForArbitrary::::zero(), + "Shrinking {} to {}", + original_v, + v + ); + + // Zero. + let mut v = CorpusValueForArbitrary::::zero(); + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert_eq!(v, CorpusValueForArbitrary::::zero()); + + let mut v = CorpusValueForArbitrary::::neg_zero(); + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert_eq!(v, CorpusValueForArbitrary::::neg_zero()); + + // NaN / Inf are not changed by this shrink implementation. + let mut v = CorpusValueForArbitrary::::nan(); + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert!(v.is_nan(), "NaN should remain NaN"); + + let mut v = CorpusValueForArbitrary::::infinity(); + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert!(v.is_infinite() && v.is_sign_positive(), "Infinity should remain Infinity"); + + let mut v = CorpusValueForArbitrary::::neg_infinity(); + domain.mutate(&mut v, &mut rng, true).unwrap(); + assert!(v.is_infinite() && v.is_sign_negative(), "Neg Infinity should remain Neg Infinity"); + } + + #[test] + fn test_f32() { + test_arbitrary_mutations_change_value::(); + test_arbitrary_shrinking_reduces_and_terminates::(); + test_float_special_value_shrink::(); + } + + #[test] + fn test_f64() { + test_arbitrary_mutations_change_value::(); + test_arbitrary_shrinking_reduces_and_terminates::(); + test_float_special_value_shrink::(); + } + + #[test] + fn test_char_mutate_boundaries() { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + let mut val = '\u{0000}'; + domain.mutate(&mut val, &mut rng, false).unwrap(); + assert!(val >= '\u{0000}'); + + val = '\u{10FFFF}'; + domain.mutate(&mut val, &mut rng, false).unwrap(); + assert!(val <= '\u{10FFFF}'); + + val = '\u{D7FF}'; // Before surrogate + domain.mutate(&mut val, &mut rng, false).unwrap(); + + val = '\u{E000}'; // After surrogate + domain.mutate(&mut val, &mut rng, false).unwrap(); + } + + #[test] + fn test_char_mapping() { + // Test boundary conditions and values around the surrogate range + assert_eq!(map_char_to_int('\u{0000}'), 0); + assert_eq!(map_int_to_char(0), '\u{0000}'); + + let before_surrogate = SURROGATE_START - 1; + assert_eq!( + map_char_to_int(std::char::from_u32(before_surrogate).unwrap()), + before_surrogate + ); + assert_eq!( + map_int_to_char(before_surrogate), + std::char::from_u32(before_surrogate).unwrap() + ); + + let after_surrogate = SURROGATE_END + 1; + let mapped_after_surrogate = map_char_to_int(std::char::from_u32(after_surrogate).unwrap()); + assert_eq!(mapped_after_surrogate, SURROGATE_START); + assert_eq!(map_int_to_char(SURROGATE_START), std::char::from_u32(after_surrogate).unwrap()); + + assert_eq!(map_char_to_int('\u{10FFFF}'), NUM_VALID_CODEPOINTS - 1); + assert_eq!(map_int_to_char(NUM_VALID_CODEPOINTS - 1), '\u{10FFFF}'); + } + + #[test] + fn test_char_shrink_to_null() { + for _ in 0..10 { + let domain = Arbitrary::::default(); + let mut rng = get_rng(); + let mut value = domain.init(&mut rng).unwrap(); + + let mut iterations = 0; + const MAX_ITERATIONS: u32 = 100_000; + + while value != '\0' && iterations < MAX_ITERATIONS { + domain.mutate(&mut value, &mut rng, true).unwrap(); + // Ensure that the value is always a valid char after mutation. + assert!(std::char::from_u32(value as u32).is_some()); + iterations += 1; + } + assert_eq!( + value, '\0', + "Char did not shrink to null within {} iterations. Final value: {}", + MAX_ITERATIONS, value + ); + } + } +} diff --git a/rust/src/domains/range.rs b/rust/src/domains/range.rs new file mode 100644 index 000000000..fd44e20e6 --- /dev/null +++ b/rust/src/domains/range.rs @@ -0,0 +1,85 @@ +use super::Domain; + +use anyhow; +use rand::distr::uniform::SampleUniform; +use rand::distr::uniform::UniformSampler; + +/// Generates values of type `T` in a given range. +/// +/// For example, `InRange::new(0, 100)` generates integer +/// values from the inclusive range `[0, 100]`. +/// +/// Example usage: +/// ``` +/// # use fuzztest::domains::Domain; +/// # use fuzztest::domains::range::InRange; +/// # use rand::prelude::*; +/// +/// let range_i32 = InRange::new(21i32, 73); +/// let sample = range_i32.init(&mut rand::rng()); +/// +/// assert!(sample.is_ok()); +/// let sample = sample.unwrap(); +/// assert!(sample >= 21); +/// assert!(sample <= 73); +/// ``` +pub struct InRange { + lower: T, + upper: T, +} + +impl InRange { + pub fn new(lower: i32, upper: i32) -> Self { + Self { lower, upper } + } + + pub fn get_in_range(&self, rng: &mut dyn rand::Rng) -> i32 { + ::Sampler::sample_single(self.lower, self.upper, rng) + .expect("Failed to sample from Uniform distribution") + } +} + +impl Domain for InRange { + type UserValue<'user> = i32; + type CorpusValue = i32; + + fn init(&self, rng: &mut dyn rand::Rng) -> anyhow::Result { + Ok(self.get_in_range(rng)) + } + + fn mutate( + &self, + val: &mut Self::CorpusValue, + rng: &mut dyn rand::Rng, + only_shrink: bool, + ) -> anyhow::Result<()> { + if only_shrink { + *val -= 1; + } else { + *val = self.get_in_range(rng); + } + Ok(()) + } + + fn get_user_value<'a>( + &self, + corpus_value: &'a Self::CorpusValue, + ) -> anyhow::Result> { + Ok(*corpus_value) + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + + use super::*; + + #[test] + fn test_in_range() { + let in_range = InRange::::new(0, 100); + let mut rng = rand::rngs::StdRng::from_seed([73; 32]); + let in_range_val = in_range.get_in_range(&mut rng); + assert!(in_range_val >= 0 && in_range_val <= 100); + } +} diff --git a/rust/src/domains/tuple_of.rs b/rust/src/domains/tuple_of.rs new file mode 100644 index 000000000..ac639645a --- /dev/null +++ b/rust/src/domains/tuple_of.rs @@ -0,0 +1,6 @@ +// TODO(yamilmorales, tinmar, mathuxny-73): Replace generated wrapper domain (to deduplicate +// implementations for tests with the same number of parameters) with these, meaning we should +// `impl Domain` on these tuples. + +pub type TupleOf1 = (T0,); +pub type TupleOf2 = (T0, T1); diff --git a/rust/src/domains/utility.rs b/rust/src/domains/utility.rs new file mode 100644 index 000000000..e785f6329 --- /dev/null +++ b/rust/src/domains/utility.rs @@ -0,0 +1,404 @@ +use std::cmp::Ordering; + +use num_traits::PrimInt; +use rand::distr::uniform::SampleUniform; +use rand::distr::{Distribution, StandardUniform}; +use rand::RngExt; + +/// Shrinks a `val` towards a `target` value. +/// +/// The new value is chosen uniformly from the range between `val` and `target`. +/// - If `val > target`, the range is `[target, val)`. +/// - If `val < target`, the range is `(val, target]`. +/// - If `val == target`, `val` is returned. +/// +/// # Arguments +/// * `rng`: Random number generator. +/// * `val`: The current value to shrink. +/// * `target`: The value to shrink towards. +pub fn shrink_towards(rng: &mut R, val: T, target: T) -> T +where + T: SampleUniform + PartialOrd + Copy + std::fmt::Display, +{ + match val.partial_cmp(&target) { + Some(Ordering::Equal) => val, + Some(Ordering::Less) => { + // val < target + // Sample from (val, target]. + // To do that, we sample from [val, target) and return target if we draw val. + let sample = rng.random_range(val..target); + if sample == val { + target + } else { + sample + } + } + Some(Ordering::Greater) => { + // val > target + // Sample from [target, val). + rng.random_range(target..val) + } + None => val, // Cannot compare, return original + } +} + +/// Mutates an integer value using one of several strategies. +/// +/// The mutation strategies are: +/// 1. Random Walk: Add/subtract a small value within the given `range`. +/// 2. Bit Flip: Flip a single random bit. +/// 3. Random Choice: Replace with a new completely random value. +/// +/// # Arguments +/// * `rng`: Random number generator. +/// * `val`: The integer value to mutate. +/// * `range`: The maximum +/- delta for the random walk. +/// * `min_value`: Optional minimum boundary for mutation. If `None`, `T::min_value()` is used. +/// * `max_value`: Optional maximum boundary for mutation. If `None`, `T::max_value()` is used. +// REQUIRES: range > 0 +// REQUIRES: min_value <= max_value (after unwrapping) +// TODO(b/405382579): Add dictionary-based mutation strategies similar to C++ implementation +// (See `third_party/googlefuzztest/internal/domains/value_mutation_helpers.h`) +pub fn mutate_integer( + rng: &mut R, + val: T, + range: T, + min_value: Option, + max_value: Option, +) -> T +where + T: PrimInt + SampleUniform + std::fmt::Display, +{ + assert!(range > T::zero(), "mutate_integer: range value cannot be <= 0: {range}"); + + let min_val = min_value.unwrap_or(T::min_value()); + let max_val = max_value.unwrap_or(T::max_value()); + assert!( + min_val < max_val, + "mutate_integer: min_val must be strictly smaller than max_val: min={min_val}, max={max_val}" + ); + + match rng.random_range(0..3) { + 0 => { + // 1/3 chance: Random Walk + // Establish a window around the current value. If val > max_val, we should walk + // around max_val instead to bring the value back into a valid range. + let center = val.clamp(min_val, max_val); + let lo = center.saturating_sub(range).max(min_val); + let hi = center.saturating_add(range).min(max_val); + + // Sample uniformly within the clamped window [lo, hi]. + rng.random_range(lo..=hi) + } + 1 => { + // 1/3 chance: Flip a random bit + let num_bits = std::mem::size_of::() * 8; + let bit_index = rng.random_range(0..num_bits); + let mask = T::one() << bit_index; + let result = val ^ mask; + if result < min_val || result > max_val { + val + } else { + result + } + } + _ => { + // 1/3 chance: Replace with a new random value + rng.random_range(min_val..=max_val) + } + } +} + +pub trait SpecialValues: Sized + Copy { + fn get() -> &'static [Self]; +} + +macro_rules! impl_special_values_signed { + ($($ty:ty),*) => { + $( + impl SpecialValues for $ty { + fn get() -> &'static [Self] { + &[0, 1, -1, <$ty>::MIN, <$ty>::MAX] + } + } + )* + }; +} + +macro_rules! impl_special_values_unsigned { + ($($ty:ty),*) => { + $( + impl SpecialValues for $ty { + fn get() -> &'static [Self] { + &[0, 1, <$ty>::MAX, <$ty>::MAX >> 1] + } + } + )* + }; +} + +impl_special_values_signed!(i8, i16, i32, i64, i128); +impl_special_values_unsigned!(u8, u16, u32, u64, u128); + +macro_rules! impl_special_values_float { + ($($ty:ty),*) => { + $( + impl SpecialValues for $ty { + fn get() -> &'static [Self] { + &[0.0, -0.0, 1.0, -1.0, <$ty>::MIN, <$ty>::MAX, <$ty>::INFINITY, <$ty>::NEG_INFINITY, <$ty>::NAN] + } + } + )* + }; +} +impl_special_values_float!(f32, f64); + +impl SpecialValues for char { + fn get() -> &'static [Self] { + &[ + '\0', // Null + 'a', // Common ASCII + '~', // Last printable ASCII + '\n', // Newline + ' ', // Space + '\u{0080}', // Start of multi-byte UTF-8 + '\u{D7FF}', // Before surrogate range + '\u{E000}', // After surrogate range + '\u{10FFFF}', // Max valid Unicode scalar value + ] + } +} + +/// Chooses a value of type `T`, potentially from a set of "special values". +/// +/// This function has a higher chance of returning one of the predefined "special values" +/// for the type `T` (as defined by the `SpecialValues` trait). Otherwise, it generates +/// a completely random value of type `T`. +/// +/// # Arguments +/// * `rng`: Random number generator. +/// +/// # Type Parameters +/// * `T`: The type of the value to choose. Must implement `SpecialValues` and +/// `StandardUniform` must be able to generate values of type `T`. +pub fn choose_value(rng: &mut R) -> T +where + T: SpecialValues + 'static, + StandardUniform: Distribution, +{ + let special_values = T::get(); + let index = rng.random_range(0..=special_values.len()); + if index < special_values.len() { + special_values[index] + } else { + rng.random() + } +} + +/// Mutates a float value. +pub fn mutate_float( + rng: &mut R, + val: &mut T, + only_shrink: bool, + _range: Option<(T, T)>, // TODO: Implement range support for floats. +) -> anyhow::Result<()> +where + T: num_traits::Float + SampleUniform + std::fmt::Display + Copy + SpecialValues + 'static, + StandardUniform: Distribution, +{ + if only_shrink { + // Shrink only finite, non-terminal values. + if val.is_finite() && *val != T::zero() { + *val = shrink_towards(rng, *val, T::zero()); + } + return Ok(()); + } + // Non-shrinking mutation. + let original_val = *val; + loop { + // Loop to ensure mutation + if !val.is_finite() { + // NaN or Infinity + *val = choose_value(rng); + } else { + match rng.random_range(0..4) { + 0 => *val = *val / (T::one() + T::one()), + 1 => *val = -*val, + 2 => *val = *val + T::one(), + 3 => *val = *val * (T::one() + T::one() + T::one()), + _ => unreachable!(), + } + } + + if (val.is_nan() && original_val.is_nan()) || *val == original_val { + continue; + } + break; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::{ + rngs::{SmallRng, SysRng}, + SeedableRng, + }; + + fn get_rng() -> SmallRng { + SmallRng::try_from_rng(&mut SysRng).unwrap() + } + + fn check_shrink_towards(smaller: T, larger: T) + where + T: SampleUniform + PartialOrd + Copy + std::fmt::Display + std::fmt::Debug + PartialEq, + { + let mut rng = get_rng(); + + // val > target (e.g. larger -> smaller) + // Range should be [smaller, larger) + let mut results = Vec::new(); + for _ in 0..100 { + let sample = shrink_towards(&mut rng, larger, smaller); + assert!(sample >= smaller && sample < larger); + if !results.contains(&sample) { + results.push(sample); + } + } + assert!(results.len() > 1); + + // val < target (e.g. smaller -> larger) + // Range should be (smaller, larger] + let mut results = Vec::new(); + for _ in 0..100 { + let sample = shrink_towards(&mut rng, smaller, larger); + assert!(sample > smaller && sample <= larger); + if !results.contains(&sample) { + results.push(sample); + } + } + assert!(results.len() > 1); + + // identical target + let sample = shrink_towards(&mut rng, smaller, smaller); + assert_eq!(sample, smaller); + } + + #[test] + fn test_shrink_towards_all_types() { + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5, 10); + check_shrink_towards::(5.0, 10.0); + check_shrink_towards::(5.0, 10.0); + } + + fn check_mutate_integer() + where + T: PrimInt + SampleUniform + std::fmt::Display + std::fmt::Debug + SpecialValues + 'static, + StandardUniform: Distribution, + { + let mut rng = get_rng(); + + let min_val = T::zero(); + let max_val = T::one() + T::one() + T::one() + T::one() + T::one(); + let range = T::one() + T::one(); + + // Bounded mutation stays in bounds + for _ in 0..100 { + let val: T = choose_value(&mut rng).clamp(min_val, max_val); + let next = mutate_integer(&mut rng, val, range, Some(min_val), Some(max_val)); + assert!( + next >= min_val && next <= max_val, + "mutate_integer bounded range check failed for value {} not in [{}, {}]", + next, + min_val, + max_val + ); + } + + // Unbounded mutation changes value + let mut val = T::zero(); + let mut changed = false; + for _ in 0..100 { + let next = mutate_integer(&mut rng, val, range, None, None); + if next != val { + changed = true; + break; + } + val = next; + } + assert!(changed, "mutate_integer did not change value"); + } + + #[test] + fn test_mutate_integer_all_types() { + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + check_mutate_integer::(); + } + + fn check_mutate_float() + where + T: num_traits::Float + + SampleUniform + + std::fmt::Display + + std::fmt::Debug + + Copy + + SpecialValues + + 'static, + StandardUniform: Distribution, + { + let mut rng = get_rng(); + + // Mutation changes value + let mut val = T::one(); + let mut changed = false; + for _ in 0..100 { + let mut next = val; + mutate_float(&mut rng, &mut next, false, None).unwrap(); + // Floats might mutate to NaN + if next != val || (next.is_nan() && !val.is_nan()) { + changed = true; + if next.is_nan() { + val = T::one(); + } else { + val = next; + } + } + } + assert!(changed, "mutate_float did not change value"); + + // Shrinking + let mut val = T::one() + T::one() + T::one(); + let original_abs = val.abs(); + for _ in 0..10 { + let mut next = val; + mutate_float(&mut rng, &mut next, true, None).unwrap(); + assert!(next.abs() <= val.abs(), "mutate_float shrink increased absolute value"); + val = next; + } + assert!(val.abs() < original_abs, "mutate_float did not shrink value"); + } + + #[test] + fn test_mutate_float_all_types() { + check_mutate_float::(); + check_mutate_float::(); + } +} diff --git a/rust/src/internal.rs b/rust/src/internal.rs new file mode 100644 index 000000000..d440b687f --- /dev/null +++ b/rust/src/internal.rs @@ -0,0 +1,57 @@ +use super::domains::GenericCorpusValue; +use super::domains::GenericDomain; +use std::collections::HashMap; +use std::sync::LazyLock; + +/// A trait implemented by types used to Fuzz a given property function. +/// +/// When a test is annotated with the `fuzztest` macro attribute, that test will be wrapped in a +/// FuzzTestObject that implements this trait. The FuzzTestObject is created by the macro and holds, +/// among others, the property function and the domains of its arguments. +/// The FuzzTestObject is then manipulated by Fuzzing Test Engine through the APIs of this trait. +pub trait FuzzTest { + // TODO(tinmar): refine arguments as we get a clearer picture of + // what the dispatcher interface looks like + fn name(&self) -> &'static str; + fn activate(&mut self); + fn mutate(&mut self); + /// Executes the property function with the given arguments + /// (will attempt to downcast to actual user values). + /// + /// Returns `true` if the property function holds, `false` if it crashes. + fn execute<'a>(&self, args: &'a GenericCorpusValue) -> bool; + fn print_finding_report(&self); + fn domains(&self) -> &dyn GenericDomain; +} + +/// Identifies the property function of a fuzz test. +/// +/// `FuzzTestInfo` is instantiated by the `fuzztest` macro which populates the fields with the +/// appropriate values. +pub struct FuzzTestInfo { + pub name: &'static str, + pub file: &'static str, + pub line: u32, +} + +pub type BoxedFuzzTest = Box; + +pub struct FuzzTestRegistration { + pub info: &'static FuzzTestInfo, + pub factory: fn() -> BoxedFuzzTest, +} + +inventory::collect!(FuzzTestRegistration); + +pub static FUZZ_TEST_NAME_TO_FACTORY: LazyLock BoxedFuzzTest>> = + LazyLock::new(|| { + inventory::iter + .into_iter() + .map(|&FuzzTestRegistration { info, factory }| (info.name, factory)) + .collect() + }); + +pub struct InputStateAndDomain { + pub input_state: Option, + pub domain: D, +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 000000000..b447424de --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,20 @@ +#![feature(cfg_sanitize)] + +mod crash_handler; +pub mod domains; +pub mod internal; +pub mod worker; + +pub use fuzztest_macro::fuzztest; +pub use serde; + +pub mod options; +pub use options::get_fuzztest_options; +pub use options::FuzzTestOptions; + +// Re-export helper crates used in macro expansion to decouple downstream Cargo.toml files. +pub mod reexports { + pub use anyhow; + pub use inventory; + pub use rand; +} diff --git a/rust/src/options.rs b/rust/src/options.rs new file mode 100644 index 000000000..e94889944 --- /dev/null +++ b/rust/src/options.rs @@ -0,0 +1,783 @@ +use crate::internal::FuzzTestRegistration; +use ::engine::engine_ffi; +use anyhow::Context; +use clap::{Parser, ValueEnum}; +use humantime::Duration; +use std::ffi::CString; +use std::ffi::OsString; +use std::path::Path; +use std::sync::OnceLock; +use tempfile::{NamedTempFile, TempDir}; + +/// Time budget calculation type for replay corpus mode. +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TimeBudgetType { + #[default] + PerTest, + Total, +} + +/// Command-line and environment variable options parsed for the FuzzTest harness. +#[derive(Parser, Debug, Clone, Default)] +pub struct FuzzTestOptions { + /// The working root directory. + #[arg(env = "FUZZTEST_WORKDIR_ROOT", long)] + pub workdir_root: Option, + + /// The duration for which each test should be fuzzed. + #[arg(env = "FUZZTEST_FUZZ_FOR", long)] + pub fuzz_for: Option, + + /// If true, subprocess logs are printed after every batch. Note that crash logs are always printed + /// regardless of this flag's value. + #[arg(env = "FUZZTEST_PRINT_SUBPROCESS_LOG", long)] + pub print_subprocess_log: bool, + + /// Number of parallel jobs to run. + #[arg(env = "FUZZTEST_JOBS", long)] + pub jobs: Option, + + /// The crash ID to be replayed from the corpus database. + /// + /// If set, `corpus_db` must also be specified. This mode retrieves the crashing input + /// associated with the given ID from the database and executes the property function. + #[arg(env = "FUZZTEST_REPLAY_ID", long, requires = "corpus_db")] + pub replay_id: Option, + + /// Replay all crashing inputs from the corpus database. + #[arg(env = "FUZZTEST_REPLAY_FINDINGS", long)] + pub replay_findings: bool, + + /// Replay the corpus for a specified duration. + #[arg(env = "FUZZTEST_REPLAY_CORPUS_FOR", long)] + pub replay_corpus_for: Option, + + /// Time budget calculation type for replay corpus mode. + #[arg(env = "FUZZTEST_TIME_BUDGET_TYPE", long, value_enum, default_value_t = TimeBudgetType::PerTest)] + pub time_budget_type: TimeBudgetType, + + /// The path to the corpus database. + /// + /// If set to non-empty, updates/queries the corpus database that contains coverage, + /// regression, and crashing inputs for each test binary and fuzz test. + #[arg(env = "FUZZTEST_CORPUS_DB", long)] + pub corpus_db: Option, +} + +impl FuzzTestOptions { + /// Evaluates the raw options and maps them to a strongly-typed domain `ExecutionMode`. + /// + /// This decouples raw environment/CLI option parsing from execution mode validation. + pub fn execution_mode(&self) -> ExecutionMode { + if let Some(replay_corpus_for) = self.replay_corpus_for { + return ExecutionMode::ReplayCorpus(ReplayCorpusOptions { + replay_corpus_for, + time_budget_type: self.time_budget_type, + }); + } + + if self.replay_findings { + return ExecutionMode::ReplayAllCrashes; + } + + if let Some(replay_id) = &self.replay_id { + return ExecutionMode::ReplayCrash(ReplayCrashOptions { replay_id: replay_id.clone() }); + } + + if let Some(duration) = self.fuzz_for { + return ExecutionMode::Fuzz(FuzzOptions { + fuzz_for: FuzzFor::Duration(duration), + jobs: self.jobs.clone(), + }); + } + + ExecutionMode::SmokeTest + } +} + +/// Returns a lazily-initialized static reference to the global `FuzzTestOptions`. +pub fn get_fuzztest_options() -> &'static FuzzTestOptions { + static OPTIONS: OnceLock = 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 evnisage + // support for passing flags on cli as the Rust's libtest harness does not support custom + // flags. + OPTIONS.get_or_init(|| FuzzTestOptions::parse_from(std::iter::empty::())) +} + +/// Strongly-typed domain execution mode for test runs. +/// +/// This enum represents the parsed high-level user intent derived from `FuzzTestOptions`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecutionMode { + /// Smoke test execution (regular unit test fallback). + SmokeTest, + + /// Fuzzing mode. + Fuzz(FuzzOptions), + + /// Replay a specific crash ID from the corpus database. + ReplayCrash(ReplayCrashOptions), + + /// Replay all crashing inputs stored in the corpus database. + ReplayAllCrashes, + + /// Replay corpus inputs for a specified duration. + ReplayCorpus(ReplayCorpusOptions), +} + +/// Mode-specific options for continuous fuzzing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FuzzOptions { + pub fuzz_for: FuzzFor, + + /// If `jobs` is `None`, we won't specify the number of jobs while invoking Centipede and it will + /// use its own default value. + pub jobs: Option, +} + +/// The duration or limit for fuzzing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FuzzFor { + /// Fuzz indefinitely until it is manually stopped or a crash is found. + Indefinitely, + + /// Fuzz for a specific duration. + Duration(Duration), +} + +/// Mode-specific options for replaying a specific database crash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplayCrashOptions { + pub replay_id: String, +} + +/// Mode-specific options for replaying corpus for a duration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplayCorpusOptions { + pub replay_corpus_for: Duration, + pub time_budget_type: TimeBudgetType, +} + +impl ExecutionMode { + /// Prepares the concrete `ExecutionAction` payload for execution by building any required + /// `CentipedeArgs` or temporary runtime assets. + pub fn prepare_action( + &self, + options: &FuzzTestOptions, + current_test_name: Option<&str>, + ) -> anyhow::Result { + match self { + ExecutionMode::SmokeTest => Ok(ExecutionAction::SmokeTest), + ExecutionMode::Fuzz(_) + | ExecutionMode::ReplayCrash(_) + | ExecutionMode::ReplayCorpus(_) => { + 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 }) + } + } + } +} + +/// 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, + + // 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, + + // FFI-compatible views into the arguments in `_c_strings`. These are passed across + // the FFI boundary to Centipede. + views: Vec, +} + +impl CentipedeArgs { + fn new(args: Vec, temp_workdir: Option) -> 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: Option<&str>, + list_file_path: Option<&Path>, + ) -> anyhow::Result> { + 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 = std::env::args().next().context("while attempting to get argv[0]")?; + + if let Some(test_name) = current_test_name { + add_arg(format!("--binary={argv0} {test_name} --exact"))?; + let normalized_test_name = test_name.replace("::", "."); + add_arg(format!("--test_name={normalized_test_name}"))?; + } else { + // We append the filter for fuzztests mod so that only fuzztests are executed and not unit tests. + add_arg(format!("--binary={argv0} __fuzztest_mod__"))?; + } + + // Binary identifier is the same as the binary path (stripped of leading slash) + let binary_id = argv0.strip_prefix('/').unwrap_or(&argv0); + add_arg(format!("--fuzztest_binary_identifier={binary_id}"))?; + + add_arg("--populate_binary_info=false".to_string())?; + add_arg("--fork_server=false".to_string())?; + add_arg("--persistent_mode=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 { + FuzzFor::Indefinitely => { + // TODO(the-shank): add support for indefinite fuzzing + todo!("not yet supported"); + } + FuzzFor::Duration(duration) => { + add_arg(format!("--stop_after={duration}"))?; + if let Some(jobs) = fuzz_opts.jobs { + add_arg(format!("--j={jobs}"))?; + } + } + } + } + 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) => { + let time_limit = match replay_corpus_opts.time_budget_type { + TimeBudgetType::PerTest => replay_corpus_opts.replay_corpus_for, + TimeBudgetType::Total => { + let num_tests = inventory::iter::().count(); + if num_tests == 0 { + replay_corpus_opts.replay_corpus_for + } else { + (*replay_corpus_opts.replay_corpus_for.as_ref() / (num_tests as u32)) + .into() + } + } + }; + 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())?; + add_arg(format!("--fuzztest_time_limit_per_test={time_limit}"))?; + } + ExecutionMode::SmokeTest => unreachable!(), + } + + Ok(Some(Self::new(args, opt_workdir))) + } + + /// Generates the default environment variable diff list. + /// + /// This replicates the logic in `GetEnvDiffForBinaries` from + /// `third_party/googlefuzztest/internal/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 { + let mut 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: Option<&str>) -> ExecutionAction { + determine_execution_action_internal(get_fuzztest_options(), current_test_name) +} + +fn determine_execution_action_internal( + options: &FuzzTestOptions, + current_test_name: Option<&str>, +) -> ExecutionAction { + options + .execution_mode() + .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)> { + 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), Some(workdir_root), None)) + } else { + let temp_dir = + TempDir::new().context("while attempting to create temporary working directory")?; + Ok((Some(corpus_db), 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))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::prelude::*; + use std::ffi::OsString; + + #[gtest] + fn test_fuzz_options_parsing_env() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_FUZZ_FOR", "5s"); + } + + let options = FuzzTestOptions::parse_from(std::iter::empty::()); + + expect_true!(options.fuzz_for.is_some()); + expect_that!(options.execution_mode(), matches_pattern!(ExecutionMode::Fuzz(_))); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_FUZZ_FOR"); + } + } + + #[gtest] + fn test_determine_execution_action_smoke_test() { + let options = FuzzTestOptions::default(); + let action = determine_execution_action_internal(&options, Some("my_mod::my_test")); + assert!(matches!(action, ExecutionAction::SmokeTest)); + } + + #[gtest] + fn test_determine_execution_action_standalone() { + let expected_duration = "10s".parse().unwrap(); + let options = FuzzTestOptions { fuzz_for: Some(expected_duration), ..Default::default() }; + let action = determine_execution_action_internal(&options, Some("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, Some("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 = argv0.strip_prefix('/').unwrap_or(&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, Some("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, Some("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 options = + FuzzTestOptions { fuzz_for: Some("1s".parse().unwrap()), ..Default::default() }; + let action = determine_execution_action_internal(&options, Some("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".to_string()), + ..Default::default() + }; + let action = determine_execution_action_internal(&options, Some("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".to_string()), + ..Default::default() + }; + let action = determine_execution_action_internal(&options, Some("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, Some("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")); + } + + #[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, Some("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::().count(); + let expected_limit = if num_tests == 0 { + expected_duration + } else { + (*expected_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_no_test_name() { + 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, None); + + 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.contains("__fuzztest_mod__"))); + 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("--test_name="))); + } + + #[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".to_string()), + 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".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_true!(workdir_root.is_none()); + expect_true!(workdir.is_some()); + Ok(()) + } + + #[gtest] + fn test_replay_id_requires_corpus_db() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_ID", "my_crash_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_REPLAY_ID"); + } + + 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() { + // SAFETY: Testing environment parsing in single-threaded context. + unsafe { + std::env::set_var("FUZZTEST_REPLAY_ID", "my_crash_123"); + std::env::set_var("FUZZTEST_CORPUS_DB", "/tmp/corpus_db"); + } + + let result = FuzzTestOptions::try_parse_from(std::iter::empty::()); + + // SAFETY: Cleaning up environment variables. + unsafe { + std::env::remove_var("FUZZTEST_REPLAY_ID"); + std::env::remove_var("FUZZTEST_CORPUS_DB"); + } + + let options = + result.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("/tmp/corpus_db"))); + } +} diff --git a/rust/src/worker.rs b/rust/src/worker.rs new file mode 100644 index 000000000..6c0aa47bc --- /dev/null +++ b/rust/src/worker.rs @@ -0,0 +1,759 @@ +use super::domains::GenericCorpusValue; +use super::internal::{BoxedFuzzTest, FuzzTest}; +use super::options::{self, CentipedeArgs, ExecutionAction}; +use ::engine::engine_ffi; +use ::engine::{ + BytesSink, CoverageDomainRegistry, DiagnosticSink, ExecuteContext, FeedbackSink, InputSink, +}; +use spin::Mutex; +use std::ffi::CString; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::LazyLock; + +/// The DiagnosticSink provided by the engine while creating the adapter. +/// +/// Storing the `DiagnosticSink` in this global is necessary because C death callbacks (e.g., +/// sanitizer traps in `crash_handler.rs`) do not have access to the adapter instance and must rely +/// on a global lookup to report crashes. +static DIAGNOSTIC_SINK: Mutex> = Mutex::new(None); + +/// A global execution context token. +/// +/// This is set to `Some(ExecuteContext)` when the fuzz test is currently executing +/// (inside the `execute` method of `RustFuzzTestAdapter`) and `None` otherwise. +/// +/// The inner ExecuteContext is used as a witness token to prove to the fuzzing engine that findings +/// are being emitted during the execution of a test case. +/// A global is necessary because C death callbacks (e.g. sanitizer traps in `crash_handler.rs`) do +/// not have access to the adapter instance. +static IS_TEST_EXECUTING: Mutex> = Mutex::new(None); + +/// Sets the global DiagnosticSink. +pub(crate) fn set_diagnostic_sink(sink: DiagnosticSink) { + let mut guard = DIAGNOSTIC_SINK.lock(); + *guard = Some(sink); +} + +/// Clears the global DiagnosticSink, setting it to None. +pub(crate) fn clear_diagnostic_sink() { + let mut guard = DIAGNOSTIC_SINK.lock(); + *guard = None; +} + +/// Emits an error into the DiagnosticSink if the DiagnosticSink is set. +/// This function blocks till it can get the lock on the global DiagnosticSink. +pub(crate) fn emit_error(message: &str) { + DIAGNOSTIC_SINK.lock().as_ref().map(|sink| sink.emit_error(message)); +} + +/// Emits a finding into the DiagnosticSink if the DiagnosticSink is set. +/// This function blocks till it can get the lock on the global DiagnosticSink. +pub(crate) fn emit_finding(description: &str, signature: &str) { + let guard = IS_TEST_EXECUTING.lock(); + let token = guard.as_ref().expect("emit_finding called when test is not executing"); + DIAGNOSTIC_SINK + .lock() + .as_ref() + .expect("since the test is executing, the diagnostic sink will definitely be present") + .emit_finding(token, description, signature); +} + +/// Emits a finding into the DiagnosticSink if the DiagnosticSink is set, and we are in the Execute +/// callback context. It only tries once to get the lock. The return value indicates if the finding +/// was successfully emitted. +/// +/// This function return false if: +/// - it could not get the lock on DiagnosticSink. +/// - it got the lock but the global DiagnosticSink was not set. +/// - diagnostic sink was set but the test was not executing. +#[allow(dead_code)] +pub(crate) fn try_emit_finding(description: &str, signature: &str) -> bool { + if let Some(guard) = DIAGNOSTIC_SINK.try_lock() { + if let Some(sink) = guard.as_ref() { + if let Some(token) = IS_TEST_EXECUTING.lock().as_ref() { + sink.emit_finding(token, description, signature); + return true; + } + } + } + false +} + +// GenericCorpusValue (Box) does not implement Clone. +// We clone by serializing and deserializing through the domain. +fn clone_corpus_value( + domain: &dyn super::domains::GenericDomain, + val: &GenericCorpusValue, +) -> anyhow::Result { + let serialized = domain.serialize_corpus(val)?; + domain.parse_corpus(&serialized) +} + +// We double-box the input because `GenericCorpusValue` is a fat pointer (`Box`), +// which cannot be directly represented as a single `usize` in `FuzzTestInputHandle` for FFI. +// By boxing it again, we get a thin pointer (`Box>`) that can be safely cast +// to `usize`. +// The memory is reclaimed when the engine calls `free_input`, which reconstructs the outer +// box using `Box::from_raw` and drops it, thereby dropping the inner box and the value. +fn pack_input(input: GenericCorpusValue) -> engine_ffi::FuzzTestInputHandle { + let ptr = Box::into_raw(Box::new(input)); + engine_ffi::FuzzTestInputHandle(ptr as usize) +} + +pub struct RustFuzzTestAdapter { + fuzz_test: BoxedFuzzTest, +} + +impl RustFuzzTestAdapter { + pub fn set_up_coverage_domains(&self, registry: &mut CoverageDomainRegistry) { + registry.set_up_sancov_domains(); + } + + pub fn get_preset_seed_inputs(&self, _sink: &mut InputSink) { + // TODO(the-shank): implement this once the macro has support for specifying preset seed + // inputs + } + + pub fn get_random_seed_input(&self, sink: &mut InputSink) { + match self.fuzz_test.domains().init(&mut rand::rng()) { + Ok(val) => { + sink.emit(pack_input(val)); + } + Err(e) => { + emit_error(&format!("Failed to initialize random seed: {:?}", e)); + } + } + } + + pub fn mutate(&self, origin: &GenericCorpusValue, shrink: bool, sink: &mut InputSink) { + let mut mutant = match clone_corpus_value(self.fuzz_test.domains(), origin) { + Ok(val) => val, + Err(e) => { + emit_error(&format!("Failed to clone input for mutation: {:?}", e)); + return; + } + }; + + if let Err(e) = self.fuzz_test.domains().mutate(&mut mutant, &mut rand::rng(), shrink) { + emit_error(&format!("Failed to mutate: {:?}", e)); + return; + } + + sink.emit(pack_input(mutant)); + } + + pub fn cross_over( + &self, + origin: &GenericCorpusValue, + _other: &GenericCorpusValue, + sink: &mut InputSink, + ) { + // TODO(the-shank): implement cross_over. Currently we are just calling mutate. + self.mutate(origin, false, sink); + } + + pub fn execute(&self, input: &GenericCorpusValue, sink: &mut FeedbackSink) { + // TODO(the-shank): modify this in order to support persistent mode and batch execution. + static CLEAR_STARTUP_COVERAGE: AtomicBool = AtomicBool::new(true); + coverage::prepare_coverage(CLEAR_STARTUP_COVERAGE.swap(false, Ordering::AcqRel)); + + // Store the execution context token in the global `IS_TEST_EXECUTING` + // to allow reporting findings during this execution. + // SAFETY: We are currently in the `execute` callback of `RustFuzzTestAdapter`. + *IS_TEST_EXECUTING.lock() = Some(unsafe { ExecuteContext::new() }); + + let result = self.fuzz_test.execute(input); + + coverage::post_process_coverage(false); + + sink.emit_sancov_features(); + + if !result { + emit_finding("Property function ran but crashed.", "Unwinding panic"); + } + + *IS_TEST_EXECUTING.lock() = None; + } + + pub fn serialize_input_content(&self, input: &GenericCorpusValue, sink: &mut BytesSink) { + match self.fuzz_test.domains().serialize_corpus(input) { + Ok(serialized) => { + sink.emit(&serialized); + } + Err(e) => { + emit_error(&format!("Failed to serialize input: {:?}", e)); + } + } + } + + pub fn deserialize_input_content(&self, content: &[u8], sink: &mut InputSink) { + match self.fuzz_test.domains().parse_corpus(content) { + Ok(val) => { + sink.emit(pack_input(val)); + } + Err(e) => { + emit_error(&format!("Failed to deserialize input: {:?}", e)); + } + } + } + + pub fn serialize_input_metadata(&self, _input: &GenericCorpusValue, _sink: &mut BytesSink) { + // TODO(the-shank): to be implemented + } + + pub fn update_input_metadata(&self, _metadata: &[u8], _input: &mut GenericCorpusValue) { + // TODO(the-shank): to be implemented + } + + pub fn free_input(&self, input: engine_ffi::FuzzTestInputHandle) { + if input.0 != 0 { + // SAFETY: The engine guarantees `input` was created by `deserialize_input_content_callback` + // (or `emit` in `InputSink`) and has not been freed yet. + unsafe { + let _ = Box::from_raw(input.0 as *mut GenericCorpusValue); + } + } + } +} + +pub struct RustFuzzTestAdapterManager { + pub test_name: &'static str, + pub fuzz_test_factory: fn() -> BoxedFuzzTest, +} + +impl RustFuzzTestAdapterManager { + pub fn get_binary_id(&self, sink: &mut BytesSink) { + static ARGV0: LazyLock = LazyLock::new(|| { + CString::new( + Path::new(&std::env::args().nth(0).unwrap()) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(""), + ) + .expect("Failed to create CString") + }); + sink.emit(ARGV0.as_bytes()); + } + + // Replaces "::" with "." to avoid parsing issues in Centipede. Centipede uses ":" as a + // separator in CENTIPEDE_RUNNER_FLAGS and its parser replaces all ":" with "\0", which would + // truncate Rust paths. + pub fn get_test_name(&self, sink: &mut BytesSink) { + let name = self.test_name.replace("::", "."); + sink.emit(name.as_bytes()); + } + + pub fn construct_adapter(&self) -> RustFuzzTestAdapter { + let fuzz_test = (self.fuzz_test_factory)(); + RustFuzzTestAdapter { fuzz_test } + } + + pub fn construct_fuzz_test(&self) -> BoxedFuzzTest { + (self.fuzz_test_factory)() + } +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapterManager` passed during initialization. +/// * `sink` is a valid pointer to a `FuzzTestBytesSink` whose lifetime extends for the duration +/// of this call. +pub unsafe extern "C" fn get_binary_id_callback( + ctx: *mut engine_ffi::FuzzTestAdapterManagerCtx, + sink: *const engine_ffi::FuzzTestBytesSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapterManager` + // passed during initialization. + let manager = unsafe { &*(ctx as *const RustFuzzTestAdapterManager) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to a `FuzzTestBytesSink` + // whose lifetime extends for the duration of this call. + let mut safe_sink = unsafe { BytesSink::from_raw(sink) }; + manager.get_binary_id(&mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapterManager` passed during initialization. +/// * `sink` is a valid pointer to a `FuzzTestBytesSink` whose lifetime extends for the duration +/// of this call. +pub unsafe extern "C" fn get_test_name_callback( + ctx: *mut engine_ffi::FuzzTestAdapterManagerCtx, + sink: *const engine_ffi::FuzzTestBytesSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapterManager` + // passed during initialization. + let manager = unsafe { &*(ctx as *const RustFuzzTestAdapterManager) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to a `FuzzTestBytesSink` + // whose lifetime extends for the duration of this call. + let mut safe_sink = unsafe { BytesSink::from_raw(sink) }; + manager.get_test_name(&mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapterManager` passed during initialization. +/// * `diagnostic_sink` is a valid pointer to a `FuzzTestDiagnosticSink` whose lifetime extends +/// until `FreeCtx` is called on the adapter. +/// * `adapter_out` is a valid pointer to write the output `FuzzTestAdapter`. +pub unsafe extern "C" fn construct_adapter_callback( + ctx: *mut engine_ffi::FuzzTestAdapterManagerCtx, + diagnostic_sink: *const engine_ffi::FuzzTestDiagnosticSink, + adapter_out: *mut engine_ffi::FuzzTestAdapter, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapterManager` + // passed during initialization. + let manager = unsafe { &*(ctx as *const RustFuzzTestAdapterManager) }; + + // SAFETY: The engine guarantees `diagnostic_sink` is a valid pointer to a `FuzzTestDiagnosticSink` + // whose lifetime extends until `FreeCtx` is called on the adapter. + let safe_sink = unsafe { DiagnosticSink::from_raw(diagnostic_sink) }; + + set_diagnostic_sink(safe_sink); + + // NOTE: `safe_sink` is not passed to `construct_adapter` or stored in `RustFuzzTestAdapter`. + // Instead, it is maintained in the global `DIAGNOSTIC_SINK` mutex via `set_diagnostic_sink`. + // This is necessary because C death callbacks (e.g., sanitizer traps in `crash_handler.rs`) do not + // have access to the adapter `self` pointer and must rely on a global lookup to report crashes. + let adapter = manager.construct_adapter(); + let boxed_adapter = Box::new(adapter); + // SAFETY: The engine guarantees `adapter_out` is a valid pointer to write the output adapter. + // The returned raw pointer `ctx` will be managed and eventually freed by `free_ctx_callback`. + unsafe { + *adapter_out = engine_ffi::FuzzTestAdapter { + ctx: Box::into_raw(boxed_adapter) as *mut engine_ffi::FuzzTestAdapterCtx, + set_up_coverage_domains: Some(set_up_coverage_domains_callback), + get_preset_seed_inputs: Some(get_preset_seed_inputs_callback), + get_random_seed_input: Some(get_random_seed_input_callback), + mutate: Some(mutate_callback), + cross_over: Some(cross_over_callback), + execute: Some(execute_callback), + serialize_input_content: Some(serialize_input_content_callback), + deserialize_input_content: Some(deserialize_input_content_callback), + serialize_input_metadata: Some(serialize_input_metadata_callback), + update_input_metadata: Some(update_input_metadata_callback), + free_input: Some(free_input_callback), + free_ctx: Some(free_ctx_callback), + }; + } +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `registry` is a valid pointer to `FuzzTestCoverageDomainRegistry`. +pub unsafe extern "C" fn set_up_coverage_domains_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + registry: *const engine_ffi::FuzzTestCoverageDomainRegistry, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `registry` is a valid pointer to `FuzzTestCoverageDomainRegistry`. + let mut registry = unsafe { CoverageDomainRegistry::from_raw(registry) }; + adapter.set_up_coverage_domains(&mut registry); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `sink` is a valid pointer to `FuzzTestInputSink`. +pub unsafe extern "C" fn get_preset_seed_inputs_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + sink: *const engine_ffi::FuzzTestInputSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestInputSink`. + let mut safe_sink = unsafe { InputSink::from_raw(sink) }; + adapter.get_preset_seed_inputs(&mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `sink` is a valid pointer to `FuzzTestInputSink`. +pub unsafe extern "C" fn get_random_seed_input_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + sink: *const engine_ffi::FuzzTestInputSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestInputSink`. + let mut safe_sink = unsafe { InputSink::from_raw(sink) }; + adapter.get_random_seed_input(&mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `origin` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework. +/// * `sink` is a valid pointer to `FuzzTestInputSink`. +pub unsafe extern "C" fn mutate_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + origin: engine_ffi::FuzzTestInputHandle, + shrink: std::ffi::c_int, + sink: *const engine_ffi::FuzzTestInputSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `origin` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework. + let origin_ref = unsafe { &*(origin.0 as *const GenericCorpusValue) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestInputSink`. + let mut safe_sink = unsafe { InputSink::from_raw(sink) }; + adapter.mutate(origin_ref, shrink != 0, &mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `origin` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework. +/// * `other` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework. +/// * `sink` is a valid pointer to `FuzzTestInputSink`. +pub unsafe extern "C" fn cross_over_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + origin: engine_ffi::FuzzTestInputHandle, + other: engine_ffi::FuzzTestInputHandle, + sink: *const engine_ffi::FuzzTestInputSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `origin` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework. + let origin_ref = unsafe { &*(origin.0 as *const GenericCorpusValue) }; + // SAFETY: The engine guarantees `other` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework. + let other_ref = unsafe { &*(other.0 as *const GenericCorpusValue) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestInputSink`. + let mut safe_sink = unsafe { InputSink::from_raw(sink) }; + adapter.cross_over(origin_ref, other_ref, &mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `input` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework. +/// * `sink` is a valid pointer to `FuzzTestFeedbackSink`. +pub unsafe extern "C" fn execute_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + input: engine_ffi::FuzzTestInputHandle, + sink: *const engine_ffi::FuzzTestFeedbackSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `input` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework. + let input_ref = unsafe { &*(input.0 as *const GenericCorpusValue) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestFeedbackSink`. + let mut safe_sink = unsafe { FeedbackSink::from_raw(sink) }; + + adapter.execute(input_ref, &mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `input` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework. +/// * `sink` is a valid pointer to `FuzzTestBytesSink`. +pub unsafe extern "C" fn serialize_input_content_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + input: engine_ffi::FuzzTestInputHandle, + sink: *const engine_ffi::FuzzTestBytesSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `input` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework. + let input_ref = unsafe { &*(input.0 as *const GenericCorpusValue) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestBytesSink`. + let mut safe_sink = unsafe { BytesSink::from_raw(sink) }; + adapter.serialize_input_content(input_ref, &mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `content` is a valid pointer to `FuzzTestBytesView` containing serialized input content. +/// * `sink` is a valid pointer to `FuzzTestInputSink`. +pub unsafe extern "C" fn deserialize_input_content_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + content: *const engine_ffi::FuzzTestBytesView, + sink: *const engine_ffi::FuzzTestInputSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `content` is a valid pointer to `FuzzTestBytesView` + // containing serialized input content. + let bytes = unsafe { &*(*content).to_bytes() }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestInputSink`. + let mut safe_sink = unsafe { InputSink::from_raw(sink) }; + adapter.deserialize_input_content(bytes, &mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `input` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework. +/// * `sink` is a valid pointer to `FuzzTestBytesSink`. +pub unsafe extern "C" fn serialize_input_metadata_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + input: engine_ffi::FuzzTestInputHandle, + sink: *const engine_ffi::FuzzTestBytesSink, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `input` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework. + let input_ref = unsafe { &*(input.0 as *const GenericCorpusValue) }; + // SAFETY: The engine guarantees `sink` is a valid pointer to `FuzzTestBytesSink`. + let mut safe_sink = unsafe { BytesSink::from_raw(sink) }; + adapter.serialize_input_metadata(input_ref, &mut safe_sink); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `metadata` is a valid pointer to `FuzzTestBytesView` containing serialized input metadata. +/// * `input` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework, and the engine guarantees exclusive access to it for the call duration. +pub unsafe extern "C" fn update_input_metadata_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + metadata: *const engine_ffi::FuzzTestBytesView, + input: engine_ffi::FuzzTestInputHandle, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + // SAFETY: The engine guarantees `metadata` is a valid pointer to `FuzzTestBytesView` + // containing serialized input metadata. + let bytes = unsafe { &*(*metadata).to_bytes() }; + // SAFETY: The engine guarantees `input` is a valid `FuzzTestInputHandle` pointing to + // a heap-allocated `GenericCorpusValue` managed by the framework, and provides exclusive + // access to it. + let input_ref = unsafe { &mut *(input.0 as *mut GenericCorpusValue) }; + adapter.update_input_metadata(bytes, input_ref); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +/// * `input` is a valid `FuzzTestInputHandle` pointing to a heap-allocated `GenericCorpusValue` +/// managed by the framework that has not been freed yet. +pub unsafe extern "C" fn free_input_callback( + ctx: *mut engine_ffi::FuzzTestAdapterCtx, + input: engine_ffi::FuzzTestInputHandle, +) { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let adapter = unsafe { &*(ctx as *const RustFuzzTestAdapter) }; + adapter.free_input(input); +} + +/// # Safety +/// +/// The caller must ensure that: +/// * `ctx` is a valid pointer to the `RustFuzzTestAdapter` created by `construct_adapter_callback`. +pub unsafe extern "C" fn free_ctx_callback(ctx: *mut engine_ffi::FuzzTestAdapterCtx) { + if !ctx.is_null() { + // SAFETY: The engine guarantees `ctx` is a valid pointer to the `RustFuzzTestAdapter` + // created by `construct_adapter_callback`. + let ptr = ctx as *mut RustFuzzTestAdapter; + let _ = unsafe { Box::from_raw(ptr) }; + + clear_diagnostic_sink(); + } +} + +pub fn run_smoke_test(fuzztest: &dyn FuzzTest) { + let start_time = std::time::Instant::now(); + + // TODO(the-shank): these should be configurable externally. + let smoke_test_duration = std::time::Duration::from_secs(1); + let only_shrink = false; + + // TODO(the-shank): the rng seed should be configurable + let mut rng = rand::rng(); + + let mut generic_corpus_value = + fuzztest.domains().init(&mut rng).expect("failed to get initial corpus value"); + + let result = fuzztest.execute(&generic_corpus_value); + // DISCUSS: probably need a more "googletest" approach to handling the result here? + assert!(result); + + while start_time.elapsed() < smoke_test_duration { + fuzztest + .domains() + .mutate(&mut generic_corpus_value, &mut rng, only_shrink) + .expect("failed to mutate corpus value"); + let result = fuzztest.execute(&generic_corpus_value); + // DISCUSS: probably need a more "googletest" approach to handling the result here? + assert!(result); + } +} + +pub enum WorkerStatus { + Success, + Failure, +} + +/// Processes and executes a fuzz test using the provided [`RustFuzzTestAdapterManager`]. +/// +/// This function serves as the primary entry point for running a fuzz test. It manages test +/// execution across two operational modes: +/// +/// 1. Worker Mode: Registers signal/sanitizer crash handlers and attempts to hand off execution to +/// the external fuzzing engine (e.g., Centipede) via [`maybe_run_as_worker`]. +/// - If the binary was spawned as a worker process by the fuzzing engine, control remains in the +/// engine loop until complete. +/// - Returns cleanly on [`WorkerStatus::Success`], or panics on [`WorkerStatus::Failure`] to +/// signal test failure to the harness. +/// 2. Smoke Test Mode: If worker mode is not active (e.g., during standard `blaze test` or +/// `cargo test` unit test runs), falls back to executing a short local smoke test using sample +/// inputs and mutation iterations to verify property function sanity. +pub fn process(manager: RustFuzzTestAdapterManager) { + super::crash_handler::register_crash_handler(); + + // box it to get a stable heap address. + let manager = Box::new(manager); + + if let Some(status) = maybe_run_as_worker(&manager) { + match status { + WorkerStatus::Success => { + // do nothing, the harness would continue to the next test + return; + } + WorkerStatus::Failure => { + // DISCUSS: in this case, is there a better way to "exit" the unit test? + panic!("FuzzTest worker reported failure") + } + } + } + + // worker mode was not run -- determine what to do next. + match options::determine_execution_action(Some(manager.test_name)) { + ExecutionAction::Standalone(centipede_args) => { + let status = run_standalone_mode(&manager, centipede_args); + if status == engine_ffi::FuzzTestControllerStatus::Failure { + panic!("FuzzTest controller reported failure"); + } + } + ExecutionAction::ReplayAllCrashes { args, list_file } => { + let status = run_standalone_mode(&manager, args); + if status == engine_ffi::FuzzTestControllerStatus::Failure { + panic!("FuzzTest controller reported failure"); + } + + // Now read the file and replay each crash + if let Ok(contents) = std::fs::read_to_string(list_file.path()) { + let options = options::get_fuzztest_options(); + for crash_id in contents.lines() { + let crash_id = crash_id.trim(); + if crash_id.is_empty() { + continue; + } + let replay_mode = + options::ExecutionMode::ReplayCrash(options::ReplayCrashOptions { + replay_id: crash_id.to_string(), + }); + if let Ok(Some(replay_args)) = CentipedeArgs::from_execution_mode( + &replay_mode, + options, + Some(manager.test_name), + None, + ) { + // We ignore the result here because replaying a crash is expected to fail + // (report failure). We want to continue replaying other crashes. + let _ = run_standalone_mode(&manager, replay_args); + } + } + } + } + ExecutionAction::SmokeTest => { + let fuzz_test = manager.construct_fuzz_test(); + run_smoke_test(fuzz_test.as_ref()); + } + } +} + +fn make_ffi_manager(manager: &RustFuzzTestAdapterManager) -> engine_ffi::FuzzTestAdapterManager { + let ctx = + manager as *const RustFuzzTestAdapterManager as *mut engine_ffi::FuzzTestAdapterManagerCtx; + + engine_ffi::FuzzTestAdapterManager { + ctx, + get_binary_id: Some(get_binary_id_callback), + get_test_name: Some(get_test_name_callback), + construct_adapter: Some(construct_adapter_callback), + } +} + +/// Attempts to run the fuzz test in Centipede worker mode using the FFI engine interface. +/// +/// This function constructs a C-compatible [`engine_ffi::FuzzTestAdapterManager`] wrapping the given `manager` +/// with required callback function pointers (`get_binary_id_callback`, `get_test_name_callback`, `construct_adapter_callback`), +/// and invokes [`engine_ffi::FuzzTestWorkerMaybeRun`]. +/// This function constructs a C-compatible [`engine_ffi::FuzzTestAdapterManager`] wrapping +/// the given `manager` with required callback function pointers (`get_binary_id_callback`, +/// `get_test_name_callback`, `construct_adapter_callback`), and invokes +/// [`engine_ffi::FuzzTestWorkerMaybeRun`]. +/// +/// # Returns +/// - `Some(WorkerStatus::Success)`: The process ran in worker mode and completed successfully. +/// - `Some(WorkerStatus::Failure)`: The process ran in worker mode and encountered a failure. +/// - `None`: Worker mode was not requested (`kFuzzTestWorkerNotRequired`). The caller should fall +/// back to standalone smoke-test execution. +fn maybe_run_as_worker(manager: &RustFuzzTestAdapterManager) -> Option { + let ffi_manager = make_ffi_manager(manager); + + // SAFETY: `manager` is a valid heap-allocated `RustFuzzTestAdapterManager`, + // and `ffi_manager` contains valid C-compatible function pointer callbacks. + let status = unsafe { engine_ffi::FuzzTestWorkerMaybeRun(&ffi_manager) }; + + match status { + engine_ffi::FuzzTestWorkerStatus::Success => Some(WorkerStatus::Success), + engine_ffi::FuzzTestWorkerStatus::Failure => Some(WorkerStatus::Failure), + engine_ffi::FuzzTestWorkerStatus::NotRequired => None, + } +} + +fn run_standalone_mode( + manager: &RustFuzzTestAdapterManager, + centipede_args: CentipedeArgs, +) -> engine_ffi::FuzzTestControllerStatus { + let ffi_manager = make_ffi_manager(manager); + + // SAFETY: `ffi_manager` wraps `manager` which is guaranteed to be alive. `centipede_args` is + // alive until the end of this function, since we have the ownership, ensuring that the + // pointers passed to `FuzzTestControllerRun` remain valid. + unsafe { engine_ffi::FuzzTestControllerRun(&ffi_manager, ¢ipede_args.as_bytes_views()) } +} diff --git a/rust/tests/macro_compiles.rs b/rust/tests/macro_compiles.rs new file mode 100644 index 000000000..a294458e5 --- /dev/null +++ b/rust/tests/macro_compiles.rs @@ -0,0 +1,10 @@ +use fuzztest::domains::arbitrary::Arbitrary; +use fuzztest::fuzztest; + +#[fuzztest(_a = Arbitrary::::default())] +fn fuzztest_macro_compiles(_a: i32) {} + +#[fuzztest(_a = Arbitrary::::default(), _b = Arbitrary::::default())] +fn fuzztest_macro_compiles_with_two_args(_a: i32, _b: i32) {} + +fn main() {} diff --git a/rust/tests/trybuild.rs b/rust/tests/trybuild.rs new file mode 100644 index 000000000..c2d9ebd3d --- /dev/null +++ b/rust/tests/trybuild.rs @@ -0,0 +1,5 @@ +#[test] +fn fuzztest() { + let t = trybuild::TestCases::new(); + t.pass("tests/macro_compiles.rs"); +}