McRogueFace/tests/fuzz/fuzz_common.py
John McCardle 90a2945a9f 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>
2026-04-10 11:05:04 -04:00

114 lines
3.2 KiB
Python

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