2026-04-10 11:13:16 -04:00
|
|
|
"""Fuzz target: EntityCollection operations across multiple differently-sized grids.
|
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:13:16 -04:00
|
|
|
Hunts the bug family from issues #258-#263, #273, #274:
|
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:13:16 -04:00
|
|
|
- #258-#263: UIEntity::gridstate heap overflows when an entity transfers between
|
|
|
|
|
grids of different sizes. The bug showed up in every mutation path
|
|
|
|
|
(append/extend/insert/setitem/slice assignment/set_grid).
|
|
|
|
|
- #273: entity.die() during iteration over grid.entities invalidated the C++
|
|
|
|
|
list iterator. The fix now raises RuntimeError; we still want the fuzzer to
|
|
|
|
|
exercise the raise path and any neighbour that could miss the guard.
|
|
|
|
|
- #274: set_grid(None) and set_grid(other_grid) needed to update the spatial
|
|
|
|
|
hash; a missing remove/insert causes use-after-free.
|
|
|
|
|
|
|
|
|
|
The fuzz loop keeps a small pool of grids of varying sizes and a pool of
|
|
|
|
|
entities, then dispatches a sequence of byte-driven operations against them:
|
|
|
|
|
create/destroy, append/insert/extend, remove/pop, __setitem__, slice
|
|
|
|
|
assignment, del, direct grid reassignment, and iteration with mid-loop
|
|
|
|
|
mutation. libFuzzer's coverage feedback drives the state exploration in
|
|
|
|
|
UIEntityCollection.cpp and UIEntity::set_grid.
|
|
|
|
|
|
|
|
|
|
The C++ harness (tests/fuzz/fuzz_common.cpp) invokes fuzz_one_input(data) for
|
|
|
|
|
every libFuzzer iteration and calls fuzz_common.safe_reset() before each call.
|
|
|
|
|
We still catch expected Python exceptions so one bad op does not abort the
|
|
|
|
|
whole iteration, which would hide later coverage from libFuzzer.
|
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:13:16 -04:00
|
|
|
# Caps chosen to keep each iteration fast and avoid address-space exhaustion
|
|
|
|
|
# under ASan. Grids up to 32x32 = 1024 cells means gridstate vectors stay
|
|
|
|
|
# small; 16 entities is enough to exercise slice-assignment boundaries.
|
|
|
|
|
MAX_GRIDS = 4
|
|
|
|
|
MAX_ENTITIES = 16
|
|
|
|
|
MAX_OPS = 48
|
|
|
|
|
MAX_GRID_DIM = 32
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_grid(stream, grids):
|
|
|
|
|
"""Pick a random grid from the pool, or None if pool empty."""
|
|
|
|
|
if not grids:
|
|
|
|
|
return None
|
|
|
|
|
return grids[stream.int_in_range(0, len(grids) - 1)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_entity(stream, entities):
|
|
|
|
|
"""Pick a random entity from the pool, or None if pool empty."""
|
|
|
|
|
if not entities:
|
|
|
|
|
return None
|
|
|
|
|
return entities[stream.int_in_range(0, len(entities) - 1)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_entity(stream, grids, attach=True):
|
|
|
|
|
"""Create a fresh entity, optionally attached to a random grid."""
|
|
|
|
|
x = stream.int_in_range(0, MAX_GRID_DIM - 1)
|
|
|
|
|
y = stream.int_in_range(0, MAX_GRID_DIM - 1)
|
|
|
|
|
grid = _pick_grid(stream, grids) if attach else None
|
|
|
|
|
if grid is not None:
|
|
|
|
|
return mcrfpy.Entity(grid_pos=(x, y), grid=grid)
|
|
|
|
|
return mcrfpy.Entity(grid_pos=(x, y))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_grid(stream):
|
|
|
|
|
"""Create a grid with a random small size."""
|
|
|
|
|
w = stream.int_in_range(1, MAX_GRID_DIM)
|
|
|
|
|
h = stream.int_in_range(1, MAX_GRID_DIM)
|
|
|
|
|
return mcrfpy.Grid(grid_size=(w, h))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_index(stream, collection_len):
|
|
|
|
|
"""Pick an index in [0, collection_len-1], or 0 when empty."""
|
|
|
|
|
if collection_len <= 0:
|
|
|
|
|
return 0
|
|
|
|
|
return stream.int_in_range(0, collection_len - 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_new_grid(stream, grids, entities):
|
|
|
|
|
if len(grids) >= MAX_GRIDS:
|
|
|
|
|
# Drop a random grid first so we exercise grid destruction paths too.
|
|
|
|
|
idx = stream.int_in_range(0, len(grids) - 1)
|
|
|
|
|
grids.pop(idx)
|
|
|
|
|
grids.append(_make_grid(stream))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_new_entity(stream, grids, entities):
|
|
|
|
|
if len(entities) >= MAX_ENTITIES:
|
|
|
|
|
idx = stream.int_in_range(0, len(entities) - 1)
|
|
|
|
|
entities.pop(idx)
|
|
|
|
|
entities.append(_make_entity(stream, grids, attach=bool(stream.int_in_range(0, 1))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_append(stream, grids, entities):
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None:
|
|
|
|
|
return
|
|
|
|
|
# Half the time append a fresh entity, half the time append an existing one
|
|
|
|
|
# from the pool to exercise the cross-grid transfer path in append().
|
|
|
|
|
if entities and stream.int_in_range(0, 1):
|
|
|
|
|
ent = _pick_entity(stream, entities)
|
|
|
|
|
else:
|
|
|
|
|
ent = _make_entity(stream, grids, attach=False)
|
|
|
|
|
if len(entities) < MAX_ENTITIES:
|
|
|
|
|
entities.append(ent)
|
|
|
|
|
grid.entities.append(ent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_insert(stream, grids, entities):
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None:
|
|
|
|
|
return
|
|
|
|
|
idx = stream.int_in_range(0, max(0, len(grid.entities)))
|
|
|
|
|
if entities and stream.int_in_range(0, 1):
|
|
|
|
|
ent = _pick_entity(stream, entities)
|
|
|
|
|
else:
|
|
|
|
|
ent = _make_entity(stream, grids, attach=False)
|
|
|
|
|
if len(entities) < MAX_ENTITIES:
|
|
|
|
|
entities.append(ent)
|
|
|
|
|
grid.entities.insert(idx, ent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_extend(stream, grids, entities):
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None:
|
|
|
|
|
return
|
|
|
|
|
count = stream.int_in_range(0, 3)
|
|
|
|
|
batch = []
|
|
|
|
|
for _ in range(count):
|
|
|
|
|
if entities and stream.int_in_range(0, 1):
|
|
|
|
|
batch.append(_pick_entity(stream, entities))
|
|
|
|
|
else:
|
|
|
|
|
ent = _make_entity(stream, grids, attach=False)
|
|
|
|
|
if len(entities) < MAX_ENTITIES:
|
|
|
|
|
entities.append(ent)
|
|
|
|
|
batch.append(ent)
|
|
|
|
|
grid.entities.extend(batch)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_remove(stream, grids, entities):
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None or len(grid.entities) == 0:
|
|
|
|
|
return
|
|
|
|
|
idx = _safe_index(stream, len(grid.entities))
|
|
|
|
|
target = grid.entities[idx]
|
|
|
|
|
grid.entities.remove(target)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_pop(stream, grids, entities):
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None or len(grid.entities) == 0:
|
|
|
|
|
return
|
|
|
|
|
if stream.int_in_range(0, 1):
|
|
|
|
|
grid.entities.pop()
|
|
|
|
|
else:
|
|
|
|
|
idx = _safe_index(stream, len(grid.entities))
|
|
|
|
|
grid.entities.pop(idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_setitem(stream, grids, entities):
|
|
|
|
|
"""Replace entities[i] with an entity possibly from a different grid.
|
|
|
|
|
|
|
|
|
|
This is the critical path for gridstate resize (#258-#263): when the
|
|
|
|
|
replacement entity was previously attached to a differently-sized grid,
|
|
|
|
|
its gridstate must be resized to match the new grid.
|
|
|
|
|
"""
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None or len(grid.entities) == 0:
|
|
|
|
|
return
|
|
|
|
|
idx = _safe_index(stream, len(grid.entities))
|
|
|
|
|
if entities and stream.int_in_range(0, 1):
|
|
|
|
|
ent = _pick_entity(stream, entities)
|
|
|
|
|
else:
|
|
|
|
|
ent = _make_entity(stream, grids, attach=True)
|
|
|
|
|
if len(entities) < MAX_ENTITIES:
|
|
|
|
|
entities.append(ent)
|
|
|
|
|
grid.entities[idx] = ent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_slice_assign(stream, grids, entities):
|
|
|
|
|
"""Slice assignment - covers both contiguous and extended slices."""
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None:
|
|
|
|
|
return
|
|
|
|
|
n = len(grid.entities)
|
|
|
|
|
a = stream.int_in_range(0, max(0, n))
|
|
|
|
|
b = stream.int_in_range(a, max(a, n))
|
|
|
|
|
extended = bool(stream.int_in_range(0, 1)) and a < b
|
|
|
|
|
count = stream.int_in_range(0, 3)
|
|
|
|
|
batch = []
|
|
|
|
|
for _ in range(count):
|
|
|
|
|
if entities and stream.int_in_range(0, 1):
|
|
|
|
|
batch.append(_pick_entity(stream, entities))
|
|
|
|
|
else:
|
|
|
|
|
ent = _make_entity(stream, grids, attach=False)
|
|
|
|
|
if len(entities) < MAX_ENTITIES:
|
|
|
|
|
entities.append(ent)
|
|
|
|
|
batch.append(ent)
|
|
|
|
|
if extended:
|
|
|
|
|
step = stream.int_in_range(1, 3)
|
|
|
|
|
# Extended slice assignment requires matched lengths
|
|
|
|
|
target_len = max(0, (b - a + step - 1) // step)
|
|
|
|
|
if target_len == len(batch):
|
|
|
|
|
grid.entities[a:b:step] = batch
|
|
|
|
|
else:
|
|
|
|
|
grid.entities[a:b] = batch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_del_index(stream, grids, entities):
|
|
|
|
|
"""del grid.entities[i] or del grid.entities[a:b]."""
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None or len(grid.entities) == 0:
|
|
|
|
|
return
|
|
|
|
|
if stream.int_in_range(0, 1):
|
|
|
|
|
idx = _safe_index(stream, len(grid.entities))
|
|
|
|
|
del grid.entities[idx]
|
|
|
|
|
else:
|
|
|
|
|
n = len(grid.entities)
|
|
|
|
|
a = stream.int_in_range(0, n)
|
|
|
|
|
b = stream.int_in_range(a, n)
|
|
|
|
|
del grid.entities[a:b]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_transfer(stream, grids, entities):
|
|
|
|
|
"""e.grid = other_grid - THE critical gridstate-overflow path (#258-#263).
|
|
|
|
|
|
|
|
|
|
Prefers picking a source and target grid of *different* sizes so every
|
|
|
|
|
transfer stresses ensureGridstate's resize logic.
|
|
|
|
|
"""
|
|
|
|
|
if len(grids) < 2 or not entities:
|
|
|
|
|
return
|
|
|
|
|
ent = _pick_entity(stream, entities)
|
|
|
|
|
# Try to pick a grid whose size differs from entity's current grid
|
|
|
|
|
target = _pick_grid(stream, grids)
|
|
|
|
|
if target is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
ent.grid = target
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_set_grid_none(stream, grids, entities):
|
|
|
|
|
"""Detach an entity from its grid - exercises #274 spatial hash removal."""
|
|
|
|
|
ent = _pick_entity(stream, entities)
|
|
|
|
|
if ent is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
ent.grid = None
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_die(stream, grids, entities):
|
|
|
|
|
"""Call e.die() outside iteration - plain lifecycle path."""
|
|
|
|
|
ent = _pick_entity(stream, entities)
|
|
|
|
|
if ent is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
ent.die()
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
# Don't remove from entities list - let the pool hold a dead reference;
|
|
|
|
|
# the next op that touches it should hit defensive paths.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _op_iterate_and_mutate(stream, grids, entities):
|
|
|
|
|
"""Iterate grid.entities and mid-loop call die() or reassign grid.
|
|
|
|
|
|
|
|
|
|
Targets #273 directly: the iterator must raise RuntimeError rather than
|
|
|
|
|
UB. We swallow the RuntimeError because it's the correct behaviour.
|
|
|
|
|
We also exercise a safe pattern (collect-then-die) in the other branch.
|
|
|
|
|
"""
|
|
|
|
|
grid = _pick_grid(stream, grids)
|
|
|
|
|
if grid is None or len(grid.entities) == 0:
|
|
|
|
|
return
|
|
|
|
|
mode = stream.int_in_range(0, 2)
|
|
|
|
|
if mode == 0:
|
|
|
|
|
# Unsafe: mutate mid-iter. Should raise RuntimeError.
|
|
|
|
|
try:
|
|
|
|
|
for ent in grid.entities:
|
|
|
|
|
if stream.remaining < 1:
|
|
|
|
|
break
|
|
|
|
|
if stream.int_in_range(0, 1):
|
|
|
|
|
ent.die()
|
|
|
|
|
else:
|
|
|
|
|
# Reassign to a different grid mid-iter.
|
|
|
|
|
other = _pick_grid(stream, grids)
|
|
|
|
|
if other is not None and other is not grid:
|
|
|
|
|
ent.grid = other
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
elif mode == 1:
|
|
|
|
|
# Safe: collect then mutate.
|
|
|
|
|
snapshot = list(grid.entities)
|
|
|
|
|
for ent in snapshot:
|
|
|
|
|
if stream.remaining < 1:
|
|
|
|
|
break
|
|
|
|
|
if stream.int_in_range(0, 3) == 0:
|
|
|
|
|
try:
|
|
|
|
|
ent.die()
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
# Read-only iteration with incidental queries
|
|
|
|
|
total = 0
|
|
|
|
|
for ent in grid.entities:
|
|
|
|
|
total += 1
|
|
|
|
|
if total > 64:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Dispatch table - each op is (min_bytes, callable)
|
|
|
|
|
_OPS = [
|
|
|
|
|
_op_new_grid, # 0
|
|
|
|
|
_op_new_entity, # 1
|
|
|
|
|
_op_append, # 2
|
|
|
|
|
_op_insert, # 3
|
|
|
|
|
_op_extend, # 4
|
|
|
|
|
_op_remove, # 5
|
|
|
|
|
_op_pop, # 6
|
|
|
|
|
_op_setitem, # 7
|
|
|
|
|
_op_slice_assign, # 8
|
|
|
|
|
_op_del_index, # 9
|
|
|
|
|
_op_transfer, # 10
|
|
|
|
|
_op_set_grid_none, # 11
|
|
|
|
|
_op_die, # 12
|
|
|
|
|
_op_iterate_and_mutate, # 13
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-04-10 11:13:16 -04:00
|
|
|
"""libFuzzer entry point.
|
|
|
|
|
|
|
|
|
|
Called by the C++ harness for every iteration. fuzz_common.safe_reset() is
|
|
|
|
|
invoked BEFORE this function, so mcrfpy.current_scene is clear. We rebuild
|
|
|
|
|
our local state from scratch each call so inputs are independent and
|
|
|
|
|
reproducible from a crashing seed.
|
|
|
|
|
"""
|
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
|
|
|
stream = ByteStream(data)
|
2026-04-10 11:13:16 -04:00
|
|
|
if stream.remaining < 2:
|
|
|
|
|
return
|
|
|
|
|
|
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
|
|
|
try:
|
2026-04-10 11:13:16 -04:00
|
|
|
# Seed the pool: one grid, zero entities. Ops will grow both pools.
|
|
|
|
|
initial_grids = stream.int_in_range(1, MAX_GRIDS)
|
|
|
|
|
grids = []
|
|
|
|
|
for _ in range(initial_grids):
|
|
|
|
|
try:
|
|
|
|
|
grids.append(_make_grid(stream))
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
pass
|
|
|
|
|
entities = []
|
|
|
|
|
|
|
|
|
|
n_ops = stream.int_in_range(1, MAX_OPS)
|
|
|
|
|
for _ in range(n_ops):
|
|
|
|
|
if stream.remaining < 2:
|
|
|
|
|
break
|
|
|
|
|
op_idx = stream.int_in_range(0, len(_OPS) - 1)
|
|
|
|
|
try:
|
|
|
|
|
_OPS[op_idx](stream, grids, entities)
|
|
|
|
|
except EXPECTED_EXCEPTIONS:
|
|
|
|
|
# One failing op must not abort the rest of the iteration.
|
|
|
|
|
# libFuzzer needs to see coverage from subsequent ops.
|
|
|
|
|
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
|
2026-04-10 11:13:16 -04:00
|
|
|
finally:
|
|
|
|
|
# Drop local references so the next safe_reset() can fully clean up.
|
|
|
|
|
# Entities retained in `entities` keep their C++ UIEntity alive, which
|
|
|
|
|
# is fine - safe_reset only clears current_scene/timers.
|
|
|
|
|
grids = None
|
|
|
|
|
entities = None
|