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:
John McCardle 2026-04-10 11:05:04 -04:00
commit 90a2945a9f
18 changed files with 602 additions and 18 deletions

View file

@ -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)

View file

@ -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
View file

@ -0,0 +1,3 @@
corpora/
crashes/
__pycache__/

90
tests/fuzz/README.md Normal file
View 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)

View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View file

View file

View file

View file