From 90a2945a9f9e04318989bf626437617c19f50f40 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 11:05:04 -0400 Subject: [PATCH] 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_.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) --- CMakeLists.txt | 54 ++++- Makefile | 43 ++-- tests/fuzz/.gitignore | 3 + tests/fuzz/README.md | 90 +++++++++ tests/fuzz/fuzz_anim_timer_scene.py | 21 ++ tests/fuzz/fuzz_common.cpp | 190 ++++++++++++++++++ tests/fuzz/fuzz_common.py | 114 +++++++++++ tests/fuzz/fuzz_fov.py | 20 ++ tests/fuzz/fuzz_grid_entity.py | 23 +++ tests/fuzz/fuzz_maps_procgen.py | 22 ++ tests/fuzz/fuzz_pathfinding_behavior.py | 20 ++ tests/fuzz/fuzz_property_types.py | 20 ++ tests/fuzz/seeds/anim_timer_scene/.gitkeep | 0 tests/fuzz/seeds/fov/.gitkeep | 0 tests/fuzz/seeds/grid_entity/.gitkeep | 0 tests/fuzz/seeds/maps_procgen/.gitkeep | 0 .../fuzz/seeds/pathfinding_behavior/.gitkeep | 0 tests/fuzz/seeds/property_types/.gitkeep | 0 18 files changed, 602 insertions(+), 18 deletions(-) create mode 100644 tests/fuzz/.gitignore create mode 100644 tests/fuzz/README.md create mode 100644 tests/fuzz/fuzz_anim_timer_scene.py create mode 100644 tests/fuzz/fuzz_common.cpp create mode 100644 tests/fuzz/fuzz_common.py create mode 100644 tests/fuzz/fuzz_fov.py create mode 100644 tests/fuzz/fuzz_grid_entity.py create mode 100644 tests/fuzz/fuzz_maps_procgen.py create mode 100644 tests/fuzz/fuzz_pathfinding_behavior.py create mode 100644 tests/fuzz/fuzz_property_types.py create mode 100644 tests/fuzz/seeds/anim_timer_scene/.gitkeep create mode 100644 tests/fuzz/seeds/fov/.gitkeep create mode 100644 tests/fuzz/seeds/grid_entity/.gitkeep create mode 100644 tests/fuzz/seeds/maps_procgen/.gitkeep create mode 100644 tests/fuzz/seeds/pathfinding_behavior/.gitkeep create mode 100644 tests/fuzz/seeds/property_types/.gitkeep diff --git a/CMakeLists.txt b/CMakeLists.txt index 1603b5e..f2826db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 $/lib + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/assets $/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 + $/lib/libpython3.14.so.1.0 + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_SOURCE_DIR}/__lib_debug/libpython3.14.so.1.0 + $/lib/libpython3.14d.so.1.0 + COMMAND ${CMAKE_COMMAND} -E create_symlink + libpython3.14d.so.1.0 + $/lib/libpython3.14d.so) + endif() endif() # Enable Py_DEBUG when linking against debug CPython (matches pydebug ABI) diff --git a/Makefile b/Makefile index fa61f49..dd2db94 100644 --- a/Makefile +++ b/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= SECONDS="; 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= 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) ) + @( cd build-fuzz && $(FUZZ_ENV) MCRF_FUZZ_TARGET=$(TARGET) \ + ./mcrfpy_fuzz ../$(CRASH) ) clean-fuzz: @echo "Cleaning fuzz build and corpora..." diff --git a/tests/fuzz/.gitignore b/tests/fuzz/.gitignore new file mode 100644 index 0000000..134ecd0 --- /dev/null +++ b/tests/fuzz/.gitignore @@ -0,0 +1,3 @@ +corpora/ +crashes/ +__pycache__/ diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..a7c0190 --- /dev/null +++ b/tests/fuzz/README.md @@ -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//` (gitignored — libFuzzer +grows these), crashes under `tests/fuzz/crashes/` (gitignored — triage +dir). Seed inputs committed to `tests/fuzz/seeds//` 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 `` to `FUZZ_TARGETS` in the Makefile. +2. Create `tests/fuzz/fuzz_.py` defining `fuzz_one_input(data: bytes) -> None`. +3. Create `tests/fuzz/seeds//.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_.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= CRASH=`. +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) diff --git a/tests/fuzz/fuzz_anim_timer_scene.py b/tests/fuzz/fuzz_anim_timer_scene.py new file mode 100644 index 0000000..451477f --- /dev/null +++ b/tests/fuzz/fuzz_anim_timer_scene.py @@ -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 diff --git a/tests/fuzz/fuzz_common.cpp b/tests/fuzz/fuzz_common.cpp new file mode 100644 index 0000000..fa42b9a --- /dev/null +++ b/tests/fuzz/fuzz_common.cpp @@ -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_.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 +#include +#include +#include +#include +#include +#include + +#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("assets/JetbrainsMono.ttf"); + McRFPy_API::default_texture = std::make_shared( + "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(data), static_cast(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; +} diff --git a/tests/fuzz/fuzz_common.py b/tests/fuzz/fuzz_common.py new file mode 100644 index 0000000..651aeb9 --- /dev/null +++ b/tests/fuzz/fuzz_common.py @@ -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 diff --git a/tests/fuzz/fuzz_fov.py b/tests/fuzz/fuzz_fov.py new file mode 100644 index 0000000..cd8e4b9 --- /dev/null +++ b/tests/fuzz/fuzz_fov.py @@ -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 diff --git a/tests/fuzz/fuzz_grid_entity.py b/tests/fuzz/fuzz_grid_entity.py new file mode 100644 index 0000000..402ab8e --- /dev/null +++ b/tests/fuzz/fuzz_grid_entity.py @@ -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 diff --git a/tests/fuzz/fuzz_maps_procgen.py b/tests/fuzz/fuzz_maps_procgen.py new file mode 100644 index 0000000..7aaf1dd --- /dev/null +++ b/tests/fuzz/fuzz_maps_procgen.py @@ -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 diff --git a/tests/fuzz/fuzz_pathfinding_behavior.py b/tests/fuzz/fuzz_pathfinding_behavior.py new file mode 100644 index 0000000..dcf88c0 --- /dev/null +++ b/tests/fuzz/fuzz_pathfinding_behavior.py @@ -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 diff --git a/tests/fuzz/fuzz_property_types.py b/tests/fuzz/fuzz_property_types.py new file mode 100644 index 0000000..9b4495b --- /dev/null +++ b/tests/fuzz/fuzz_property_types.py @@ -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 diff --git a/tests/fuzz/seeds/anim_timer_scene/.gitkeep b/tests/fuzz/seeds/anim_timer_scene/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/fov/.gitkeep b/tests/fuzz/seeds/fov/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/grid_entity/.gitkeep b/tests/fuzz/seeds/grid_entity/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/maps_procgen/.gitkeep b/tests/fuzz/seeds/maps_procgen/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/pathfinding_behavior/.gitkeep b/tests/fuzz/seeds/pathfinding_behavior/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/property_types/.gitkeep b/tests/fuzz/seeds/property_types/.gitkeep new file mode 100644 index 0000000..e69de29