Add native libFuzzer fuzz harness for Python API, addresses #283
Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1ce38b587b
commit
90a2945a9f
18 changed files with 602 additions and 18 deletions
|
|
@ -338,9 +338,57 @@ 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)
|
||||
message(STATUS "Building mcrfpy_fuzz harness (libFuzzer + ASan + UBSan)")
|
||||
|
||||
set(MCRF_FUZZ_SOURCES ${SOURCES})
|
||||
list(REMOVE_ITEM MCRF_FUZZ_SOURCES ${CMAKE_SOURCE_DIR}/src/main.cpp)
|
||||
list(APPEND MCRF_FUZZ_SOURCES ${CMAKE_SOURCE_DIR}/tests/fuzz/fuzz_common.cpp)
|
||||
|
||||
add_executable(mcrfpy_fuzz ${MCRF_FUZZ_SOURCES})
|
||||
target_compile_definitions(mcrfpy_fuzz PRIVATE NO_SDL MCRF_FUZZ_HARNESS)
|
||||
if(MCRF_DEBUG_PYTHON OR MCRF_FREE_THREADED_PYTHON)
|
||||
target_compile_definitions(mcrfpy_fuzz PRIVATE Py_DEBUG)
|
||||
endif()
|
||||
if(MCRF_FREE_THREADED_PYTHON)
|
||||
target_compile_definitions(mcrfpy_fuzz PRIVATE Py_GIL_DISABLED)
|
||||
endif()
|
||||
if(MCRF_HEADLESS)
|
||||
target_compile_definitions(mcrfpy_fuzz PRIVATE MCRF_HEADLESS)
|
||||
endif()
|
||||
if(MCRF_SDL2)
|
||||
target_compile_definitions(mcrfpy_fuzz PRIVATE MCRF_SDL2)
|
||||
endif()
|
||||
target_include_directories(mcrfpy_fuzz PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/tests/fuzz)
|
||||
target_compile_options(mcrfpy_fuzz PRIVATE
|
||||
-fsanitize=fuzzer-no-link,address,undefined
|
||||
-fno-sanitize=function,vptr
|
||||
-fno-omit-frame-pointer -g -O1)
|
||||
target_link_options(mcrfpy_fuzz PRIVATE
|
||||
-fsanitize=fuzzer,address,undefined
|
||||
-fno-sanitize=function,vptr)
|
||||
target_link_libraries(mcrfpy_fuzz ${LINK_LIBS})
|
||||
|
||||
# Copy Python runtime + assets next to mcrfpy_fuzz so the embedded
|
||||
# interpreter finds the stdlib and default_font/default_texture load.
|
||||
add_custom_command(TARGET mcrfpy_fuzz POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
${CMAKE_SOURCE_DIR}/__lib $<TARGET_FILE_DIR:mcrfpy_fuzz>/lib
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
${CMAKE_SOURCE_DIR}/assets $<TARGET_FILE_DIR:mcrfpy_fuzz>/assets)
|
||||
if(MCRF_DEBUG_PYTHON)
|
||||
add_custom_command(TARGET mcrfpy_fuzz POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_SOURCE_DIR}/__lib_debug/libpython3.14.so.1.0
|
||||
$<TARGET_FILE_DIR:mcrfpy_fuzz>/lib/libpython3.14.so.1.0
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_SOURCE_DIR}/__lib_debug/libpython3.14.so.1.0
|
||||
$<TARGET_FILE_DIR:mcrfpy_fuzz>/lib/libpython3.14d.so.1.0
|
||||
COMMAND ${CMAKE_COMMAND} -E create_symlink
|
||||
libpython3.14d.so.1.0
|
||||
$<TARGET_FILE_DIR:mcrfpy_fuzz>/lib/libpython3.14d.so)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Enable Py_DEBUG when linking against debug CPython (matches pydebug ABI)
|
||||
|
|
|
|||
43
Makefile
43
Makefile
|
|
@ -114,12 +114,29 @@ asan-test: asan
|
|||
UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" \
|
||||
python3 run_tests.py -v --sanitizer
|
||||
|
||||
# Fuzzing targets (clang + ASan + libFuzzer + atheris)
|
||||
# Fuzzing targets (clang-18 + libFuzzer + ASan + UBSan).
|
||||
# Design: ONE instrumented executable `mcrfpy_fuzz` that embeds CPython,
|
||||
# registers the mcrfpy module, and dispatches each libFuzzer iteration to
|
||||
# a Python `fuzz_one_input(data)` function loaded from the script named by
|
||||
# the MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code
|
||||
# where all the #258-#278 bugs live. No atheris dependency.
|
||||
FUZZ_TARGETS := grid_entity property_types anim_timer_scene maps_procgen fov pathfinding_behavior
|
||||
FUZZ_SECONDS ?= 30
|
||||
|
||||
# Shared env for running the fuzz binary. PYTHONHOME points at the build-fuzz
|
||||
# copy of the bundled stdlib (post-build copied into build-fuzz/lib/).
|
||||
# ASAN_OPTIONS: leak detection disabled because libFuzzer intentionally holds
|
||||
# inputs for its corpus; abort_on_error ensures crashes are loud and repro-able.
|
||||
define FUZZ_ENV
|
||||
MCRF_LIB_DIR=../__lib_debug \
|
||||
PYTHONMALLOC=malloc \
|
||||
PYTHONHOME=../__lib/Python \
|
||||
ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1:print_stacktrace=1" \
|
||||
UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
|
||||
endef
|
||||
|
||||
fuzz-build:
|
||||
@echo "Building McRogueFace with libFuzzer + ASan (clang-18)..."
|
||||
@echo "Building mcrfpy_fuzz with libFuzzer + ASan (clang-18)..."
|
||||
@mkdir -p build-fuzz
|
||||
@cd build-fuzz && CC=clang-18 CXX=clang++-18 cmake .. \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
|
|
@ -127,11 +144,8 @@ fuzz-build:
|
|||
-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"
|
||||
-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld && make -j$(JOBS) mcrfpy_fuzz
|
||||
@echo "Fuzz build complete! Output: build-fuzz/mcrfpy_fuzz"
|
||||
|
||||
fuzz: fuzz-build
|
||||
@for t in $(FUZZ_TARGETS); do \
|
||||
|
|
@ -141,9 +155,8 @@ fuzz: fuzz-build
|
|||
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 -- \
|
||||
( cd build-fuzz && $(FUZZ_ENV) MCRF_FUZZ_TARGET=$$t \
|
||||
./mcrfpy_fuzz \
|
||||
-max_total_time=$(FUZZ_SECONDS) \
|
||||
-artifact_prefix=../tests/fuzz/crashes/$$t- \
|
||||
../tests/fuzz/corpora/$$t ../tests/fuzz/seeds/$$t ) || exit 1; \
|
||||
|
|
@ -151,10 +164,10 @@ fuzz: fuzz-build
|
|||
|
||||
fuzz-long: fuzz-build
|
||||
@test -n "$(TARGET)" || (echo "Usage: make fuzz-long TARGET=<name> SECONDS=<n>"; exit 1)
|
||||
@test -f tests/fuzz/fuzz_$(TARGET).py || (echo "No target: tests/fuzz/fuzz_$(TARGET).py"; 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 -- \
|
||||
@( cd build-fuzz && $(FUZZ_ENV) MCRF_FUZZ_TARGET=$(TARGET) \
|
||||
./mcrfpy_fuzz \
|
||||
-max_total_time=$(or $(SECONDS),3600) \
|
||||
-artifact_prefix=../tests/fuzz/crashes/$(TARGET)- \
|
||||
../tests/fuzz/corpora/$(TARGET) ../tests/fuzz/seeds/$(TARGET) )
|
||||
|
|
@ -162,8 +175,8 @@ fuzz-long: fuzz-build
|
|||
fuzz-repro:
|
||||
@test -n "$(TARGET)" || (echo "Usage: make fuzz-repro TARGET=<name> CRASH=<path>"; exit 1)
|
||||
@test -n "$(CRASH)" || (echo "Usage: make fuzz-repro TARGET=<name> CRASH=<path>"; exit 1)
|
||||
@( cd build-fuzz && MCRF_LIB_DIR=../__lib_debug PYTHONMALLOC=malloc \
|
||||
./mcrogueface --headless --exec ../tests/fuzz/fuzz_$(TARGET).py -- ../$(CRASH) )
|
||||
@( cd build-fuzz && $(FUZZ_ENV) MCRF_FUZZ_TARGET=$(TARGET) \
|
||||
./mcrfpy_fuzz ../$(CRASH) )
|
||||
|
||||
clean-fuzz:
|
||||
@echo "Cleaning fuzz build and corpora..."
|
||||
|
|
|
|||
3
tests/fuzz/.gitignore
vendored
Normal file
3
tests/fuzz/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
corpora/
|
||||
crashes/
|
||||
__pycache__/
|
||||
90
tests/fuzz/README.md
Normal file
90
tests/fuzz/README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# McRogueFace Python API fuzzing harness (#283)
|
||||
|
||||
Native clang+libFuzzer+ASan harness that drives the `mcrfpy` Python API from
|
||||
Python fuzz targets. libFuzzer instruments the C++ engine code (where all the
|
||||
#258-#278 bugs live); Python drives the fuzzing logic through a simple byte
|
||||
consumer. No atheris dependency — Python-level coverage would add nothing here
|
||||
because the bugs live below the API boundary.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `clang-18`, `clang++-18`, `lld-18` on `PATH` (Debian: `apt install clang-18 lld-18`)
|
||||
- `libclang_rt.fuzzer-18-dev` (for `-fsanitize=fuzzer`) — verify with
|
||||
`clang-18 -print-file-name=libclang_rt.fuzzer-x86_64.a`
|
||||
- Debug CPython built per top-level CLAUDE.md (`tools/build_debug_python.sh`)
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make fuzz-build
|
||||
```
|
||||
|
||||
Produces `build-fuzz/mcrfpy_fuzz`, a single libFuzzer-linked executable. All
|
||||
six fuzz targets share this binary — target selection is by env var.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
make fuzz # 30s smoke on each of 6 targets
|
||||
make fuzz FUZZ_SECONDS=300 # 5min each
|
||||
make fuzz-long TARGET=grid_entity SECONDS=3600 # 1hr on one target
|
||||
make fuzz-repro TARGET=grid_entity CRASH=tests/fuzz/crashes/grid_entity-abc123
|
||||
make clean-fuzz # Wipe build-fuzz/, corpora/, crashes/
|
||||
```
|
||||
|
||||
Corpora live under `tests/fuzz/corpora/<target>/` (gitignored — libFuzzer
|
||||
grows these), crashes under `tests/fuzz/crashes/` (gitignored — triage
|
||||
dir). Seed inputs committed to `tests/fuzz/seeds/<target>/` are read-only.
|
||||
|
||||
## Targets
|
||||
|
||||
| Script | Surface | Hunts |
|
||||
|---|---|---|
|
||||
| `fuzz_grid_entity.py` | EntityCollection append/remove/insert/extend/slice across differently-sized grids, `entity.die` during iteration | #258-#263, #273, #274 |
|
||||
| `fuzz_property_types.py` | Random property get/set with type confusion on Frame/Caption/Sprite/Entity/Grid/TileLayer/ColorLayer | #267, #268, #272 |
|
||||
| `fuzz_anim_timer_scene.py` | Animation + Timer state machine, Frame reparenting, scene swap in callbacks | #269, #270, #275, #277 |
|
||||
| `fuzz_maps_procgen.py` | HeightMap/DiscreteMap ops and conversions, NoiseSource.sample, BSP.to_heightmap | new |
|
||||
| `fuzz_fov.py` | grid.compute_fov + is_in_fov, transparent toggling | new |
|
||||
| `fuzz_pathfinding_behavior.py` | DijkstraMap, grid.step, entity behavior fields | #273-adjacent |
|
||||
|
||||
Any target not yet implemented is a stub that still compiles and runs cleanly
|
||||
— `make fuzz` reports it as a no-op.
|
||||
|
||||
## Adding a new target
|
||||
|
||||
1. Add `<name>` to `FUZZ_TARGETS` in the Makefile.
|
||||
2. Create `tests/fuzz/fuzz_<name>.py` defining `fuzz_one_input(data: bytes) -> None`.
|
||||
3. Create `tests/fuzz/seeds/<name>/.gitkeep` so the seed dir exists.
|
||||
4. Import `ByteStream` and `EXPECTED_EXCEPTIONS` from `fuzz_common`. Wrap the
|
||||
fuzz body in `try: ... except EXPECTED_EXCEPTIONS: pass` so Python noise
|
||||
doesn't pollute libFuzzer output — real bugs come from ASan/UBSan.
|
||||
|
||||
No C++ code changes are needed to add a target. The harness loads
|
||||
`fuzz_<MCRF_FUZZ_TARGET>.py` by name at init time.
|
||||
|
||||
## Triage
|
||||
|
||||
A crash in `tests/fuzz/crashes/` is a file containing the exact bytes that
|
||||
triggered it. Reproduce with `make fuzz-repro TARGET=<name> CRASH=<path>`.
|
||||
The binary will rerun ONCE against that input and ASan will print the stack.
|
||||
Useful ASan tweaks when investigating:
|
||||
|
||||
```sh
|
||||
ASAN_OPTIONS="detect_leaks=0:symbolize=1:print_stacktrace=1" \
|
||||
./build-fuzz/mcrfpy_fuzz path/to/crash_input
|
||||
```
|
||||
|
||||
If the crash reproduces a known fixed issue (#258-#278), delete the crash file
|
||||
and move on. If it's new, file a Gitea issue with the crash file attached and
|
||||
apply appropriate `system:*` and `priority:*` labels per CLAUDE.md.
|
||||
|
||||
## CI integration
|
||||
|
||||
Not wired into `tests/run_tests.py`. Fuzz runs are non-deterministic and too
|
||||
long for normal suite runs. Follow-up issue will add a scheduled weekly job.
|
||||
|
||||
## References
|
||||
|
||||
- Plan: `/home/john/.claude/plans/abundant-gliding-hummingbird.md`
|
||||
- libFuzzer: https://llvm.org/docs/LibFuzzer.html
|
||||
- Bug inventory: #279 (meta), #258-#278 (individual bugs)
|
||||
21
tests/fuzz/fuzz_anim_timer_scene.py
Normal file
21
tests/fuzz/fuzz_anim_timer_scene.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""fuzz_anim_timer_scene - stub. Wave 2 agent W6 will implement.
|
||||
|
||||
Target bugs: #269 (PythonObjectCache race), #270 (GridLayer dangling),
|
||||
#275 (UIEntity missing tp_dealloc), #277 (GridChunk dangling). Random
|
||||
animation create/step/callback, timer start/stop/pause/resume/restart,
|
||||
Frame nesting and reparenting, scene swap mid-callback.
|
||||
|
||||
Contract: define fuzz_one_input(data: bytes) -> None.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
||||
|
||||
|
||||
def fuzz_one_input(data):
|
||||
stream = ByteStream(data)
|
||||
try:
|
||||
mcrfpy.Scene("anim_stub")
|
||||
except EXPECTED_EXCEPTIONS:
|
||||
pass
|
||||
190
tests/fuzz/fuzz_common.cpp
Normal file
190
tests/fuzz/fuzz_common.cpp
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// libFuzzer entry points for McRogueFace Python API fuzzing (#283).
|
||||
//
|
||||
// One executable per process, one libFuzzer main, one embedded CPython.
|
||||
// The active target is selected by MCRF_FUZZ_TARGET env var (e.g.
|
||||
// "grid_entity"). On LLVMFuzzerInitialize we bootstrap Python, register
|
||||
// the mcrfpy built-in module, import tests/fuzz/fuzz_<target>.py and
|
||||
// resolve its `fuzz_one_input(data: bytes)` callable. On each
|
||||
// LLVMFuzzerTestOneInput iteration we call it with the raw bytes.
|
||||
//
|
||||
// libFuzzer instruments the C++ engine code, so when Python operations
|
||||
// drive mcrfpy into new C++ branches, libFuzzer sees the new edges and
|
||||
// keeps the input. Python-level exceptions are swallowed here — only
|
||||
// ASan/UBSan signal real bugs.
|
||||
|
||||
#include <Python.h>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
#include "GameEngine.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include "McRogueFaceConfig.h"
|
||||
#include "PyFont.h"
|
||||
#include "PyTexture.h"
|
||||
|
||||
namespace {
|
||||
|
||||
PyObject* g_target_fn = nullptr; // fuzz_one_input callable from target module
|
||||
PyObject* g_common_mod = nullptr; // fuzz_common Python module (for safe_reset)
|
||||
GameEngine* g_engine = nullptr; // kept alive so mcrfpy can reach engine state
|
||||
std::string g_target_name;
|
||||
|
||||
[[noreturn]] void fatal(const std::string& msg) {
|
||||
std::cerr << "[fuzz] FATAL: " << msg << std::endl;
|
||||
if (PyErr_Occurred()) {
|
||||
PyErr_Print();
|
||||
}
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
std::string get_target_name_or_die() {
|
||||
const char* t = std::getenv("MCRF_FUZZ_TARGET");
|
||||
if (!t || !*t) {
|
||||
fatal("MCRF_FUZZ_TARGET env var not set. "
|
||||
"Expected one of: grid_entity, property_types, anim_timer_scene, "
|
||||
"maps_procgen, fov, pathfinding_behavior.");
|
||||
}
|
||||
return std::string(t);
|
||||
}
|
||||
|
||||
// Walk up from cwd looking for tests/fuzz/fuzz_common.py. When invoked from
|
||||
// build-fuzz/ that's one level up; this also works if someone runs the binary
|
||||
// from the repo root or a sibling directory.
|
||||
std::string find_tests_fuzz_dir() {
|
||||
namespace fs = std::filesystem;
|
||||
fs::path cwd = fs::current_path();
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
fs::path candidate = cwd / "tests" / "fuzz";
|
||||
if (fs::exists(candidate / "fuzz_common.py")) {
|
||||
return candidate.string();
|
||||
}
|
||||
if (!cwd.has_parent_path() || cwd == cwd.parent_path()) {
|
||||
break;
|
||||
}
|
||||
cwd = cwd.parent_path();
|
||||
}
|
||||
fatal("Could not locate tests/fuzz/fuzz_common.py relative to cwd. "
|
||||
"Run the fuzzer from build-fuzz/ or repo root.");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" int LLVMFuzzerInitialize(int* /*argc*/, char*** /*argv*/) {
|
||||
g_target_name = get_target_name_or_die();
|
||||
const std::string fuzz_dir = find_tests_fuzz_dir();
|
||||
|
||||
McRogueFaceConfig config;
|
||||
config.headless = true;
|
||||
config.audio_enabled = false;
|
||||
config.python_mode = true;
|
||||
config.exec_scripts.push_back(
|
||||
std::filesystem::path(fuzz_dir) / ("fuzz_" + g_target_name + ".py"));
|
||||
|
||||
// mcrfpy expects an engine instance to exist — several code paths reach
|
||||
// back into GameEngine via a global pointer. We never call engine->run().
|
||||
g_engine = new GameEngine(config);
|
||||
|
||||
PyStatus status = McRFPy_API::init_python_with_config(config);
|
||||
if (PyStatus_Exception(status)) {
|
||||
fatal("Py_InitializeFromConfig failed");
|
||||
}
|
||||
|
||||
McRFPy_API::mcrf_module = PyImport_ImportModule("mcrfpy");
|
||||
if (!McRFPy_API::mcrf_module) {
|
||||
fatal("Could not import mcrfpy");
|
||||
}
|
||||
|
||||
// Load default_font/default_texture as main.cpp does. Assets must be
|
||||
// reachable from cwd — CMake post-build copies them to build-fuzz/assets.
|
||||
try {
|
||||
McRFPy_API::default_font =
|
||||
std::make_shared<PyFont>("assets/JetbrainsMono.ttf");
|
||||
McRFPy_API::default_texture = std::make_shared<PyTexture>(
|
||||
"assets/kenney_tinydungeon.png", 16, 16);
|
||||
PyObject_SetAttrString(McRFPy_API::mcrf_module, "default_font",
|
||||
McRFPy_API::default_font->pyObject());
|
||||
PyObject_SetAttrString(McRFPy_API::mcrf_module, "default_texture",
|
||||
McRFPy_API::default_texture->pyObject());
|
||||
} catch (...) {
|
||||
std::cerr << "[fuzz] WARN: default_font/default_texture load failed "
|
||||
<< "(cwd=" << std::filesystem::current_path() << "). "
|
||||
<< "Targets that touch defaults may raise." << std::endl;
|
||||
}
|
||||
|
||||
// Prepend fuzz_dir to sys.path so both fuzz_common and the target module
|
||||
// resolve without packaging.
|
||||
PyObject* sys_path = PySys_GetObject("path");
|
||||
if (!sys_path) {
|
||||
fatal("sys.path not accessible");
|
||||
}
|
||||
PyObject* py_fuzz_dir = PyUnicode_FromString(fuzz_dir.c_str());
|
||||
PyList_Insert(sys_path, 0, py_fuzz_dir);
|
||||
Py_DECREF(py_fuzz_dir);
|
||||
|
||||
g_common_mod = PyImport_ImportModule("fuzz_common");
|
||||
if (!g_common_mod) {
|
||||
fatal("Could not import fuzz_common");
|
||||
}
|
||||
|
||||
const std::string target_module_name = "fuzz_" + g_target_name;
|
||||
PyObject* target_mod = PyImport_ImportModule(target_module_name.c_str());
|
||||
if (!target_mod) {
|
||||
fatal("Could not import " + target_module_name);
|
||||
}
|
||||
|
||||
g_target_fn = PyObject_GetAttrString(target_mod, "fuzz_one_input");
|
||||
Py_DECREF(target_mod);
|
||||
if (!g_target_fn || !PyCallable_Check(g_target_fn)) {
|
||||
fatal("Target module missing callable fuzz_one_input(data: bytes)");
|
||||
}
|
||||
|
||||
std::cerr << "[fuzz] initialized target=" << g_target_name
|
||||
<< " fuzz_dir=" << fuzz_dir << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
|
||||
// Drop leaked state from the previous iteration. safe_reset failures are
|
||||
// non-fatal — keep the fuzz loop going.
|
||||
PyObject* safe_reset_fn = PyObject_GetAttrString(g_common_mod, "safe_reset");
|
||||
if (safe_reset_fn) {
|
||||
PyObject* r = PyObject_CallNoArgs(safe_reset_fn);
|
||||
Py_XDECREF(r);
|
||||
Py_DECREF(safe_reset_fn);
|
||||
if (PyErr_Occurred()) {
|
||||
PyErr_Clear();
|
||||
}
|
||||
}
|
||||
|
||||
PyObject* py_data = PyBytes_FromStringAndSize(
|
||||
reinterpret_cast<const char*>(data), static_cast<Py_ssize_t>(size));
|
||||
if (!py_data) {
|
||||
PyErr_Clear();
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* args = PyTuple_Pack(1, py_data);
|
||||
Py_DECREF(py_data);
|
||||
if (!args) {
|
||||
PyErr_Clear();
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* result = PyObject_Call(g_target_fn, args, nullptr);
|
||||
Py_DECREF(args);
|
||||
|
||||
if (!result) {
|
||||
// Python-level exception — target's try/except should swallow the usual
|
||||
// suspects (TypeError, ValueError, etc.). Anything reaching here is
|
||||
// either unexpected or a deliberate re-raise; clear and move on. Real
|
||||
// bugs come from ASan/UBSan, not Python tracebacks.
|
||||
PyErr_Clear();
|
||||
} else {
|
||||
Py_DECREF(result);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
114
tests/fuzz/fuzz_common.py
Normal file
114
tests/fuzz/fuzz_common.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Shared helpers for McRogueFace native libFuzzer fuzz targets (#283).
|
||||
|
||||
Every fuzz target imports from this module. Stable contract:
|
||||
- ByteStream: deterministically consume fuzzer bytes into typed values
|
||||
- safe_reset(): clear mcrfpy global state between iterations
|
||||
- EXPECTED_EXCEPTIONS: tuple of Python-level exceptions to swallow
|
||||
|
||||
The C++ harness (tests/fuzz/fuzz_common.cpp) calls safe_reset() before
|
||||
each target invocation and catches any exception that escapes. Targets
|
||||
should wrap their work in `try: ... except EXPECTED_EXCEPTIONS: pass`
|
||||
so Python noise doesn't pollute the libFuzzer output.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
EXPECTED_EXCEPTIONS = (
|
||||
TypeError,
|
||||
ValueError,
|
||||
AttributeError,
|
||||
IndexError,
|
||||
KeyError,
|
||||
OverflowError,
|
||||
RuntimeError,
|
||||
NotImplementedError,
|
||||
StopIteration,
|
||||
)
|
||||
|
||||
|
||||
class ByteStream:
|
||||
"""Deterministic byte-to-value converter.
|
||||
|
||||
Replaces atheris.FuzzedDataProvider for our native libFuzzer harness.
|
||||
Running out of bytes is silently tolerated: consumers get 0/empty/False,
|
||||
so a short input still produces a valid (if shallow) iteration.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buf", "_pos")
|
||||
|
||||
def __init__(self, data):
|
||||
self._buf = data
|
||||
self._pos = 0
|
||||
|
||||
@property
|
||||
def remaining(self):
|
||||
return max(0, len(self._buf) - self._pos)
|
||||
|
||||
def take(self, n):
|
||||
if self._pos >= len(self._buf) or n <= 0:
|
||||
return b""
|
||||
end = min(len(self._buf), self._pos + n)
|
||||
out = self._buf[self._pos:end]
|
||||
self._pos = end
|
||||
return out
|
||||
|
||||
def u8(self):
|
||||
b = self.take(1)
|
||||
return b[0] if b else 0
|
||||
|
||||
def u16(self):
|
||||
b = self.take(2)
|
||||
return int.from_bytes(b.ljust(2, b"\x00"), "little", signed=False)
|
||||
|
||||
def u32(self):
|
||||
b = self.take(4)
|
||||
return int.from_bytes(b.ljust(4, b"\x00"), "little", signed=False)
|
||||
|
||||
def int_in_range(self, lo, hi):
|
||||
if hi <= lo:
|
||||
return lo
|
||||
span = hi - lo + 1
|
||||
if span <= 256:
|
||||
return lo + (self.u8() % span)
|
||||
if span <= 65536:
|
||||
return lo + (self.u16() % span)
|
||||
return lo + (self.u32() % span)
|
||||
|
||||
def float_in_range(self, lo, hi):
|
||||
f = self.u32() / 4294967296.0
|
||||
return lo + f * (hi - lo)
|
||||
|
||||
def bool(self):
|
||||
return (self.u8() & 1) == 1
|
||||
|
||||
def pick_one(self, seq):
|
||||
if not seq:
|
||||
return None
|
||||
return seq[self.int_in_range(0, len(seq) - 1)]
|
||||
|
||||
def ascii_str(self, max_len=16):
|
||||
n = self.int_in_range(0, max_len)
|
||||
raw = self.take(n)
|
||||
return "".join(chr(c) for c in raw if 32 <= c < 127)
|
||||
|
||||
|
||||
def safe_reset():
|
||||
"""Reset mcrfpy global state between fuzz iterations.
|
||||
|
||||
Stops all timers (they hold callback refs and can fire mid-mutation)
|
||||
and installs a fresh empty scene so the prior iteration's UI tree is
|
||||
released. Failures here are tolerated — the C++ harness catches them.
|
||||
"""
|
||||
try:
|
||||
timers = list(mcrfpy.timers) if hasattr(mcrfpy, "timers") else []
|
||||
for t in timers:
|
||||
try:
|
||||
t.stop()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcrfpy.current_scene = mcrfpy.Scene("fuzz_reset")
|
||||
except Exception:
|
||||
pass
|
||||
20
tests/fuzz/fuzz_fov.py
Normal file
20
tests/fuzz/fuzz_fov.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""fuzz_fov - stub. Wave 2 agent W8 will implement.
|
||||
|
||||
Target: grid.compute_fov() with random origin/radius/algorithm, toggling
|
||||
grid.at(x,y).transparent mid-run, grid.is_in_fov() queries on invalid coords.
|
||||
|
||||
Contract: define fuzz_one_input(data: bytes) -> None.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
||||
|
||||
|
||||
def fuzz_one_input(data):
|
||||
stream = ByteStream(data)
|
||||
try:
|
||||
g = mcrfpy.Grid(grid_size=(8, 8))
|
||||
g.compute_fov((0, 0), radius=3)
|
||||
except EXPECTED_EXCEPTIONS:
|
||||
pass
|
||||
23
tests/fuzz/fuzz_grid_entity.py
Normal file
23
tests/fuzz/fuzz_grid_entity.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""fuzz_grid_entity - stub. Wave 2 agent W4 will implement.
|
||||
|
||||
Target bugs: #258-#263 (gridstate overflow on entity transfer between
|
||||
differently-sized grids), #273 (entity.die during iteration), #274
|
||||
(set_grid spatial hash). See /home/john/.claude/plans/abundant-gliding-hummingbird.md.
|
||||
|
||||
Contract: define fuzz_one_input(data: bytes) -> None. The C++ harness
|
||||
(tests/fuzz/fuzz_common.cpp) calls this for every libFuzzer iteration.
|
||||
Use ByteStream to consume bytes. Wrap work in try/except EXPECTED_EXCEPTIONS.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
||||
|
||||
|
||||
def fuzz_one_input(data):
|
||||
stream = ByteStream(data)
|
||||
try:
|
||||
# Minimal smoke: create one grid so the harness verifies end to end.
|
||||
mcrfpy.Grid(grid_size=(4, 4))
|
||||
except EXPECTED_EXCEPTIONS:
|
||||
pass
|
||||
22
tests/fuzz/fuzz_maps_procgen.py
Normal file
22
tests/fuzz/fuzz_maps_procgen.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""fuzz_maps_procgen - stub. Wave 2 agent W7 will implement.
|
||||
|
||||
Target: HeightMap and DiscreteMap are the standardized abstract data
|
||||
containers; fuzz them exhaustively and fuzz the one-directional conversions
|
||||
(NoiseSource.sample -> HeightMap, BSP.to_heightmap, DiscreteMap.from_heightmap,
|
||||
DiscreteMap.to_heightmap). Using HM/DM as the interface layer covers every
|
||||
downstream procgen system without having to fuzz each one.
|
||||
|
||||
Contract: define fuzz_one_input(data: bytes) -> None.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
||||
|
||||
|
||||
def fuzz_one_input(data):
|
||||
stream = ByteStream(data)
|
||||
try:
|
||||
mcrfpy.HeightMap(size=(4, 4))
|
||||
except EXPECTED_EXCEPTIONS:
|
||||
pass
|
||||
20
tests/fuzz/fuzz_pathfinding_behavior.py
Normal file
20
tests/fuzz/fuzz_pathfinding_behavior.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""fuzz_pathfinding_behavior - stub. Wave 2 agent W9 will implement.
|
||||
|
||||
Target: grid.get_dijkstra_map() with random roots and collide sets,
|
||||
.path_from/.step_from/.to_heightmap(), grid.step() with random turn_order,
|
||||
entity step/default_behavior/target_label mutation.
|
||||
|
||||
Contract: define fuzz_one_input(data: bytes) -> None.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
||||
|
||||
|
||||
def fuzz_one_input(data):
|
||||
stream = ByteStream(data)
|
||||
try:
|
||||
mcrfpy.Grid(grid_size=(6, 6))
|
||||
except EXPECTED_EXCEPTIONS:
|
||||
pass
|
||||
20
tests/fuzz/fuzz_property_types.py
Normal file
20
tests/fuzz/fuzz_property_types.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""fuzz_property_types - stub. Wave 2 agent W5 will implement.
|
||||
|
||||
Target bugs: #267 (PyObject_GetAttrString reference leaks), #268
|
||||
(sfVector2f_to_PyObject NULL deref), #272 (UniformCollection unchecked
|
||||
weak_ptr). Random property get/set with type confusion across all UI types.
|
||||
|
||||
Contract: define fuzz_one_input(data: bytes) -> None.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
||||
|
||||
|
||||
def fuzz_one_input(data):
|
||||
stream = ByteStream(data)
|
||||
try:
|
||||
mcrfpy.Frame(pos=(0, 0), size=(10, 10))
|
||||
except EXPECTED_EXCEPTIONS:
|
||||
pass
|
||||
0
tests/fuzz/seeds/anim_timer_scene/.gitkeep
Normal file
0
tests/fuzz/seeds/anim_timer_scene/.gitkeep
Normal file
0
tests/fuzz/seeds/fov/.gitkeep
Normal file
0
tests/fuzz/seeds/fov/.gitkeep
Normal file
0
tests/fuzz/seeds/grid_entity/.gitkeep
Normal file
0
tests/fuzz/seeds/grid_entity/.gitkeep
Normal file
0
tests/fuzz/seeds/maps_procgen/.gitkeep
Normal file
0
tests/fuzz/seeds/maps_procgen/.gitkeep
Normal file
0
tests/fuzz/seeds/pathfinding_behavior/.gitkeep
Normal file
0
tests/fuzz/seeds/pathfinding_behavior/.gitkeep
Normal file
0
tests/fuzz/seeds/property_types/.gitkeep
Normal file
0
tests/fuzz/seeds/property_types/.gitkeep
Normal file
Loading…
Add table
Add a link
Reference in a new issue