From 136d2a2a2520c2e6c79b961a2568ba423ce41d84 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 10:35:44 -0400 Subject: [PATCH] Add build plumbing for libFuzzer+ASan fuzz build, addresses #283 - CMakeLists MCRF_FUZZER option (clang-only, -fsanitize=fuzzer-no-link) - Makefile fuzz-build/fuzz/fuzz-long/fuzz-repro/clean-fuzz targets - CommandLineParser -- passthrough after --exec for forwarding libFuzzer argv - McRFPy_API: forward script_args to sys.argv in --exec mode so atheris.Setup() sees libFuzzer flags; set sys.argv[0] to the exec script path to match Python script-mode conventions - .gitignore build-fuzz/ and corpora/crashes dirs Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 +++ CMakeLists.txt | 10 +++++++ Makefile | 57 ++++++++++++++++++++++++++++++++++++++- src/CommandLineParser.cpp | 13 +++++++++ src/McRFPy_API.cpp | 10 +++++++ 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0abd0da..39c46e1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ dist/ !CLAUDE.md !README.md !tests/ + +# Fuzzing build artifacts and runtime data (build-fuzz matched by build* above) +tests/fuzz/corpora/ +tests/fuzz/crashes/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e545777..1603b5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,7 @@ option(MCRF_SANITIZE_THREAD "Build with ThreadSanitizer" OFF) option(MCRF_DEBUG_PYTHON "Link against debug CPython from __lib_debug/" OFF) option(MCRF_FREE_THREADED_PYTHON "Link against free-threaded CPython (python3.14t)" OFF) option(MCRF_WASM_DEBUG "Build WASM with DWARF debug info and source maps" OFF) +option(MCRF_FUZZER "Build with libFuzzer coverage instrumentation for atheris" OFF) # Validate mutually exclusive sanitizers if(MCRF_SANITIZE_ADDRESS AND MCRF_SANITIZE_THREAD) @@ -333,6 +334,15 @@ if(MCRF_SANITIZE_THREAD) -fsanitize=thread) endif() +if(MCRF_FUZZER) + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "MCRF_FUZZER=ON requires Clang. Invoke with CC=clang-18 CXX=clang++-18.") + endif() + message(STATUS "libFuzzer coverage instrumentation enabled (atheris harness)") + target_compile_options(mcrogueface PRIVATE -fsanitize=fuzzer-no-link) + target_link_options(mcrogueface PRIVATE -fsanitize=fuzzer-no-link) +endif() + # Enable Py_DEBUG when linking against debug CPython (matches pydebug ABI) if(MCRF_DEBUG_PYTHON OR MCRF_FREE_THREADED_PYTHON) target_compile_definitions(mcrogueface PRIVATE Py_DEBUG) diff --git a/Makefile b/Makefile index 16f4d34..fa61f49 100644 --- a/Makefile +++ b/Makefile @@ -114,6 +114,61 @@ asan-test: asan UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" \ python3 run_tests.py -v --sanitizer +# Fuzzing targets (clang + ASan + libFuzzer + atheris) +FUZZ_TARGETS := grid_entity property_types anim_timer_scene maps_procgen fov pathfinding_behavior +FUZZ_SECONDS ?= 30 + +fuzz-build: + @echo "Building McRogueFace with libFuzzer + ASan (clang-18)..." + @mkdir -p build-fuzz + @cd build-fuzz && CC=clang-18 CXX=clang++-18 cmake .. \ + -DCMAKE_BUILD_TYPE=Debug \ + -DMCRF_DEBUG_PYTHON=ON \ + -DMCRF_SANITIZE_ADDRESS=ON \ + -DMCRF_SANITIZE_UNDEFINED=ON \ + -DMCRF_FUZZER=ON \ + -DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld && make -j$(JOBS) + @if [ ! -f __lib/Python/Lib/site-packages/atheris/__init__.py ]; then \ + echo "NOTE: atheris not installed. Run tools/install_atheris.sh"; \ + fi + @echo "Fuzz build complete! Output: build-fuzz/mcrogueface" + +fuzz: fuzz-build + @for t in $(FUZZ_TARGETS); do \ + if [ ! -f tests/fuzz/fuzz_$$t.py ]; then \ + echo "SKIP: tests/fuzz/fuzz_$$t.py does not exist yet"; \ + continue; \ + fi; \ + echo "=== fuzzing $$t for $(FUZZ_SECONDS)s ==="; \ + mkdir -p tests/fuzz/corpora/$$t tests/fuzz/crashes; \ + ( cd build-fuzz && MCRF_LIB_DIR=../__lib_debug PYTHONMALLOC=malloc \ + ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1" \ + ./mcrogueface --headless --exec ../tests/fuzz/fuzz_$$t.py -- \ + -max_total_time=$(FUZZ_SECONDS) \ + -artifact_prefix=../tests/fuzz/crashes/$$t- \ + ../tests/fuzz/corpora/$$t ../tests/fuzz/seeds/$$t ) || exit 1; \ + done + +fuzz-long: fuzz-build + @test -n "$(TARGET)" || (echo "Usage: make fuzz-long TARGET= SECONDS="; exit 1) + @mkdir -p tests/fuzz/corpora/$(TARGET) tests/fuzz/crashes + @( cd build-fuzz && MCRF_LIB_DIR=../__lib_debug PYTHONMALLOC=malloc \ + ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1" \ + ./mcrogueface --headless --exec ../tests/fuzz/fuzz_$(TARGET).py -- \ + -max_total_time=$(or $(SECONDS),3600) \ + -artifact_prefix=../tests/fuzz/crashes/$(TARGET)- \ + ../tests/fuzz/corpora/$(TARGET) ../tests/fuzz/seeds/$(TARGET) ) + +fuzz-repro: + @test -n "$(TARGET)" || (echo "Usage: make fuzz-repro TARGET= CRASH="; exit 1) + @test -n "$(CRASH)" || (echo "Usage: make fuzz-repro TARGET= CRASH="; exit 1) + @( cd build-fuzz && MCRF_LIB_DIR=../__lib_debug PYTHONMALLOC=malloc \ + ./mcrogueface --headless --exec ../tests/fuzz/fuzz_$(TARGET).py -- ../$(CRASH) ) + +clean-fuzz: + @echo "Cleaning fuzz build and corpora..." + @rm -rf build-fuzz tests/fuzz/corpora tests/fuzz/crashes + tsan: @echo "Building McRogueFace with TSan + free-threaded Python..." @echo "NOTE: Requires free-threaded debug Python built with:" @@ -169,7 +224,7 @@ analyze: clean-debug: @echo "Cleaning debug/sanitizer builds..." - @rm -rf build-debug build-asan build-tsan + @rm -rf build-debug build-asan build-tsan build-fuzz # Packaging targets using tools/package.sh package-windows-light: windows diff --git a/src/CommandLineParser.cpp b/src/CommandLineParser.cpp index 1ba1ab4..343c7c3 100644 --- a/src/CommandLineParser.cpp +++ b/src/CommandLineParser.cpp @@ -121,6 +121,19 @@ CommandLineParser::ParseResult CommandLineParser::parse(McRogueFaceConfig& confi config.exec_scripts.push_back(argv[current_arg]); config.python_mode = true; current_arg++; + // Look for `--` passthrough marker: everything after it becomes script_args + // (forwarded to sys.argv so fuzzers/libFuzzer can receive their flags). + int scan = current_arg; + while (scan < argc) { + if (std::string(argv[scan]) == "--") { + for (int i = scan + 1; i < argc; i++) { + config.script_args.push_back(argv[i]); + } + current_arg = argc; + break; + } + scan++; + } continue; } diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index fb2554e..22388ab 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -1027,6 +1027,16 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config) std::wstring warg(arg.begin(), arg.end()); argv_storage.push_back(warg); } + } else if (!config.exec_scripts.empty()) { + // --exec mode: argv[0] = last exec script path so sys.argv[0] matches the running + // script, mirroring normal Python script-mode behavior. Any script_args populated + // via the `--` passthrough marker (see CommandLineParser) become argv[1:] so + // fuzzers/libFuzzer can receive their flags through atheris.Setup(sys.argv, ...). + argv_storage.push_back(config.exec_scripts.back().wstring()); + for (const auto& arg : config.script_args) { + std::wstring warg(arg.begin(), arg.end()); + argv_storage.push_back(warg); + } } else { // Interactive mode or no script: argv[0] = "" argv_storage.push_back(L"");