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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue