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>
114 lines
3.2 KiB
Python
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
|