2026-04-10 11:18:01 -04:00
|
|
|
"""fuzz_pathfinding_behavior - Wave 2 W9 target (#283).
|
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
|
|
|
|
2026-04-10 11:18:01 -04:00
|
|
|
Fuzzes the pathfinding + agent-behavior surface:
|
|
|
|
|
- grid.get_dijkstra_map(root, diagonal_cost, collide) with bad inputs
|
|
|
|
|
- DijkstraMap.distance / path_from / step_from / to_heightmap
|
|
|
|
|
- grid.step(n=, turn_order=) driving behavior callbacks that mutate the
|
|
|
|
|
entity list mid-iteration (adjacent to #273 die-during-iteration)
|
|
|
|
|
- entity.step / turn_order / default_behavior / target_label /
|
|
|
|
|
sight_radius / move_speed mutation
|
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
|
|
|
|
2026-04-10 11:18:01 -04:00
|
|
|
API verified against src/UIGridPathfinding.cpp and src/UIEntity.cpp:
|
|
|
|
|
- get_dijkstra_map signature: (root, diagonal_cost=1.41, collide=<label_str|None>)
|
|
|
|
|
-- `collide` is a label STRING, not a set of coords.
|
|
|
|
|
- DijkstraMap.path_from returns an AStarPath (walk/peek/remaining), not list.
|
|
|
|
|
- step callback signature is `callback(trigger, data)` (two args).
|
|
|
|
|
- grid.step signature: (n=1, turn_order=<int filter|None>). turn_order is an
|
|
|
|
|
integer filter (-1 = all), not a list of entities.
|
|
|
|
|
- set_default_behavior / set_turn_order / set_sight_radius accept any int
|
|
|
|
|
without range validation, so they will NOT raise on negatives -- that's
|
|
|
|
|
fine for fuzzing, we just exercise them.
|
|
|
|
|
- Entity constructor accepts `grid_pos=(x, y)` and `grid=<Grid>` kwargs.
|
|
|
|
|
|
|
|
|
|
Contract: define fuzz_one_input(data: bytes) -> None. The C++ harness
|
|
|
|
|
(tests/fuzz/fuzz_common.cpp) calls this for every libFuzzer iteration.
|
|
|
|
|
Wraps work in try/except EXPECTED_EXCEPTIONS so Python noise doesn't
|
|
|
|
|
pollute libFuzzer output -- only ASan/UBSan report real bugs.
|
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
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import mcrfpy
|
|
|
|
|
|
|
|
|
|
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 11:18:01 -04:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Step callbacks
|
|
|
|
|
#
|
|
|
|
|
# Signature is callback(trigger, data) -- verified against
|
|
|
|
|
# tests/unit/entity_step_callback_test.py and src/UIGridPyMethods.cpp:708
|
|
|
|
|
# (fireStepCallback uses PyObject_CallFunction(callback, "OO", ...)).
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _noop_step(trigger, data):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _die_step(entity_ref):
|
|
|
|
|
"""Factory: returns a callback that calls entity.die() (targets #273)."""
|
|
|
|
|
def cb(trigger, data):
|
|
|
|
|
try:
|
|
|
|
|
entity_ref.die()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return cb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _move_step(entity_ref, tx, ty):
|
|
|
|
|
"""Factory: returns a callback that moves the entity to (tx, ty)."""
|
|
|
|
|
def cb(trigger, data):
|
|
|
|
|
try:
|
|
|
|
|
entity_ref.grid_pos = (tx, ty)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return cb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _spawn_step(grid_ref, tx, ty, sink):
|
|
|
|
|
"""Factory: returns a callback that creates another entity on the grid."""
|
|
|
|
|
def cb(trigger, data):
|
|
|
|
|
try:
|
|
|
|
|
ne = mcrfpy.Entity(grid_pos=(tx, ty), grid=grid_ref)
|
|
|
|
|
sink.append(ne)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return cb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helpers for consuming fuzz bytes into position tuples.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rand_coord(stream, w, h, oob_chance=True):
|
|
|
|
|
"""Return an (x, y) tuple. 1/8 chance of out-of-bounds when oob_chance."""
|
|
|
|
|
if oob_chance and (stream.u8() & 0x07) == 0:
|
|
|
|
|
# Occasionally OOB
|
|
|
|
|
return (
|
|
|
|
|
stream.int_in_range(-4, w + 4),
|
|
|
|
|
stream.int_in_range(-4, h + 4),
|
|
|
|
|
)
|
|
|
|
|
return (stream.int_in_range(0, w - 1), stream.int_in_range(0, h - 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_entity(stream, entities):
|
|
|
|
|
if not entities:
|
|
|
|
|
return None
|
|
|
|
|
return stream.pick_one(entities)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Op dispatch
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NUM_OPS = 17
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dispatch(op, stream, state):
|
|
|
|
|
grid = state["grid"]
|
|
|
|
|
entities = state["entities"]
|
|
|
|
|
w = state["w"]
|
|
|
|
|
h = state["h"]
|
|
|
|
|
|
|
|
|
|
if op == 0:
|
|
|
|
|
# Op 0: create an entity at a random position, cap at 8
|
|
|
|
|
if len(entities) >= 8:
|
|
|
|
|
return
|
|
|
|
|
pos = _rand_coord(stream, w, h, oob_chance=False)
|
|
|
|
|
e = mcrfpy.Entity(grid_pos=pos, grid=grid)
|
|
|
|
|
entities.append(e)
|
|
|
|
|
|
|
|
|
|
elif op == 1:
|
|
|
|
|
# Op 1: set entity.step = noop
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
e.step = _noop_step
|
|
|
|
|
|
|
|
|
|
elif op == 2:
|
|
|
|
|
# Op 2: set entity.step = die()-callback (iteration-safety stress)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
e.step = _die_step(e)
|
|
|
|
|
|
|
|
|
|
elif op == 3:
|
|
|
|
|
# Op 3: set entity.step = move callback
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
tx, ty = _rand_coord(stream, w, h, oob_chance=True)
|
|
|
|
|
e.step = _move_step(e, tx, ty)
|
|
|
|
|
|
|
|
|
|
elif op == 4:
|
|
|
|
|
# Op 4: set entity.step = spawn callback (grows entity list in-callback)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
tx, ty = _rand_coord(stream, w, h, oob_chance=False)
|
|
|
|
|
e.step = _spawn_step(grid, tx, ty, entities)
|
|
|
|
|
|
|
|
|
|
elif op == 5:
|
|
|
|
|
# Op 5: mutate turn_order (including 0 = skip, negatives)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
e.turn_order = stream.int_in_range(-5, 10)
|
|
|
|
|
|
|
|
|
|
elif op == 6:
|
|
|
|
|
# Op 6: mutate default_behavior (including out-of-range)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
e.default_behavior = stream.int_in_range(-2, 15)
|
|
|
|
|
|
|
|
|
|
elif op == 7:
|
|
|
|
|
# Op 7: mutate target_label (ascii or None)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
if stream.bool():
|
|
|
|
|
e.target_label = None
|
|
|
|
|
else:
|
|
|
|
|
e.target_label = stream.ascii_str(max_len=8)
|
|
|
|
|
|
|
|
|
|
elif op == 8:
|
|
|
|
|
# Op 8: mutate sight_radius (including negative, huge)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
e.sight_radius = stream.int_in_range(-5, 100)
|
|
|
|
|
|
|
|
|
|
elif op == 9:
|
|
|
|
|
# Op 9: mutate move_speed (0 = instant, nonsense floats)
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
e.move_speed = stream.float_in_range(-1.0, 5.0)
|
|
|
|
|
|
|
|
|
|
elif op == 10:
|
|
|
|
|
# Op 10: get_dijkstra_map with coord root (in-bounds usually, OOB occasionally)
|
|
|
|
|
root = _rand_coord(stream, w, h, oob_chance=True)
|
|
|
|
|
state["djm"] = grid.get_dijkstra_map(root=root)
|
|
|
|
|
|
|
|
|
|
elif op == 11:
|
|
|
|
|
# Op 11: get_dijkstra_map with entity root
|
|
|
|
|
e = _pick_entity(stream, entities)
|
|
|
|
|
if e is not None:
|
|
|
|
|
state["djm"] = grid.get_dijkstra_map(root=e)
|
|
|
|
|
|
|
|
|
|
elif op == 12:
|
|
|
|
|
# Op 12: get_dijkstra_map with a diagonal_cost (negative/huge/nonsense)
|
|
|
|
|
root = _rand_coord(stream, w, h, oob_chance=False)
|
|
|
|
|
dc = stream.float_in_range(-2.0, 10.0)
|
|
|
|
|
state["djm"] = grid.get_dijkstra_map(root=root, diagonal_cost=dc)
|
|
|
|
|
|
|
|
|
|
elif op == 13:
|
|
|
|
|
# Op 13: get_dijkstra_map with a collide label (string or None)
|
|
|
|
|
root = _rand_coord(stream, w, h, oob_chance=False)
|
|
|
|
|
if stream.bool():
|
|
|
|
|
label = None
|
|
|
|
|
else:
|
|
|
|
|
label = stream.ascii_str(max_len=6)
|
|
|
|
|
state["djm"] = grid.get_dijkstra_map(root=root, collide=label)
|
|
|
|
|
|
|
|
|
|
elif op == 14:
|
|
|
|
|
# Op 14: query djm.distance(pos) including OOB
|
|
|
|
|
djm = state["djm"]
|
|
|
|
|
if djm is not None:
|
|
|
|
|
q = _rand_coord(stream, w, h, oob_chance=True)
|
|
|
|
|
djm.distance(q)
|
|
|
|
|
|
|
|
|
|
elif op == 15:
|
|
|
|
|
# Op 15: query djm.path_from(pos) -- returns AStarPath, exercise it
|
|
|
|
|
djm = state["djm"]
|
|
|
|
|
if djm is not None:
|
|
|
|
|
q = _rand_coord(stream, w, h, oob_chance=True)
|
|
|
|
|
path = djm.path_from(q)
|
|
|
|
|
# Exercise AStarPath properties
|
|
|
|
|
if path is not None:
|
|
|
|
|
try:
|
|
|
|
|
_ = path.remaining
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
_ = path.origin
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
_ = path.destination
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
# Walk a few steps if available
|
|
|
|
|
for _ in range(stream.int_in_range(0, 4)):
|
|
|
|
|
try:
|
|
|
|
|
path.walk()
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
elif op == 16:
|
|
|
|
|
# Op 16: query djm.step_from(pos) and djm.to_heightmap()
|
|
|
|
|
djm = state["djm"]
|
|
|
|
|
if djm is not None:
|
|
|
|
|
q = _rand_coord(stream, w, h, oob_chance=True)
|
|
|
|
|
djm.step_from(q)
|
|
|
|
|
hm = djm.to_heightmap()
|
|
|
|
|
if hm is not None:
|
|
|
|
|
# Read a few cells from the heightmap
|
|
|
|
|
for _ in range(stream.int_in_range(0, 3)):
|
|
|
|
|
try:
|
|
|
|
|
hx = stream.int_in_range(0, w - 1)
|
|
|
|
|
hy = stream.int_in_range(0, h - 1)
|
|
|
|
|
_ = hm[hx, hy]
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dispatch_step(stream, state):
|
|
|
|
|
"""Fire grid.step() with random n / turn_order filter. Separate op so
|
|
|
|
|
we can mix it with regular ops in the main loop."""
|
|
|
|
|
grid = state["grid"]
|
|
|
|
|
mode = stream.u8() & 0x03
|
|
|
|
|
if mode == 0:
|
|
|
|
|
grid.step()
|
|
|
|
|
elif mode == 1:
|
|
|
|
|
grid.step(n=stream.int_in_range(0, 5))
|
|
|
|
|
elif mode == 2:
|
|
|
|
|
grid.step(turn_order=stream.int_in_range(-2, 5))
|
|
|
|
|
else:
|
|
|
|
|
grid.step(n=stream.int_in_range(0, 3),
|
|
|
|
|
turn_order=stream.int_in_range(-1, 5))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Entry point
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
def fuzz_one_input(data):
|
|
|
|
|
stream = ByteStream(data)
|
|
|
|
|
try:
|
2026-04-10 11:18:01 -04:00
|
|
|
w = stream.int_in_range(2, 16)
|
|
|
|
|
h = stream.int_in_range(2, 16)
|
|
|
|
|
grid = mcrfpy.Grid(grid_size=(w, h))
|
|
|
|
|
|
|
|
|
|
# Attach grid to a scene so behavior callbacks have a consistent world.
|
|
|
|
|
try:
|
|
|
|
|
scene = mcrfpy.Scene("fuzz_pb")
|
|
|
|
|
mcrfpy.current_scene = scene
|
|
|
|
|
scene.children.append(grid)
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
state = {
|
|
|
|
|
"grid": grid,
|
|
|
|
|
"entities": [],
|
|
|
|
|
"djm": None,
|
|
|
|
|
"w": w,
|
|
|
|
|
"h": h,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
n_ops = stream.int_in_range(1, 30)
|
|
|
|
|
for _ in range(n_ops):
|
|
|
|
|
if stream.remaining < 1:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Occasionally fire grid.step() to drive callbacks that mutate state.
|
|
|
|
|
# This sits BEFORE the dispatch so callbacks still run even when we
|
|
|
|
|
# run out of ops.
|
|
|
|
|
if (stream.u8() & 0x0f) == 0:
|
|
|
|
|
try:
|
|
|
|
|
_dispatch_step(stream, state)
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
op = stream.u8() % NUM_OPS
|
|
|
|
|
try:
|
|
|
|
|
_dispatch(op, stream, state)
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Op 17 per plan: rapid iteration -- fire grid.step() several times
|
|
|
|
|
# at the end with callbacks still installed, each call may mutate the
|
|
|
|
|
# entity list (spawn, die, move). Use the remaining bytes to decide
|
|
|
|
|
# how many rounds.
|
|
|
|
|
if stream.remaining >= 1:
|
|
|
|
|
rounds = stream.int_in_range(0, 10)
|
|
|
|
|
for _ in range(rounds):
|
|
|
|
|
try:
|
|
|
|
|
grid.step()
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
|
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
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|