McRogueFace/tests/fuzz/fuzz_grid_entity.py
John McCardle 246ed886db Fold Tier C surface into existing fuzz targets; closes #312
Extends the five existing targets to cover the remaining gaps from #312
without new files:

- property_types     Line/Circle/Arc setters, Scene.children collection ops
                     (index/count/find/insert/slice/pop), module functions
                     find/find_all/bresenham/lock. Benchmark triplet excluded
                     (end_benchmark writes a file per call).
- grid_entity        grid.at / [x,y] / entities_in_radius / center_camera /
                     hovered_cell, and GridPoint named-layer __getattr__/
                     __setattr__.
- pathfinding_behavior  Grid.find_path + full AStarPath (peek/__len__/__bool__/
                     iteration) that path_from didn't reach.
- fov                ColorLayer perspective (apply/update/clear_perspective)
                     and draw_fov.
- maps_procgen       ColorLayer/TileLayer apply_threshold/apply_ranges/
                     apply_gradient from HeightMap sources.

The full instrumented campaign surfaced five new bugs, filed as #321 (HIGH
ColorLayer.draw_fov bad-free), #322 (WangSet.terrain_enum error-pending
abort), #323/#324/#325 (float->int UB in pitch_shift/hsl_shift/Vector). Per
decision, this issue delivers fuzz coverage only; the bugs are tracked
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
2026-06-21 16:45:03 -04:00

462 lines
16 KiB
Python

"""Fuzz target: EntityCollection operations across multiple differently-sized grids.
Hunts the bug family from issues #258-#263, #273, #274:
- #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.
"""
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
# 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_grid_query(stream, grids, entities):
"""Tier C (#312): grid spatial-query surface not covered elsewhere --
at(), grid[x,y] subscript, entities_in_radius, center_camera, hovered_cell.
Coords intentionally stray out of bounds to exercise the guards.
"""
grid = _pick_grid(stream, grids)
if grid is None:
return
which = stream.u8() % 5
if which == 0:
x = stream.int_in_range(-3, MAX_GRID_DIM + 3)
y = stream.int_in_range(-3, MAX_GRID_DIM + 3)
gp = grid.at(x, y)
_ = gp.walkable
_ = gp.transparent
_ = gp.grid_pos
_ = gp.entities
elif which == 1:
x = stream.int_in_range(-3, MAX_GRID_DIM + 3)
y = stream.int_in_range(-3, MAX_GRID_DIM + 3)
_ = grid[x, y]
elif which == 2:
pos = (stream.int_in_range(-3, MAX_GRID_DIM + 3),
stream.int_in_range(-3, MAX_GRID_DIM + 3))
grid.entities_in_radius(pos, stream.float_in_range(-1.0, 24.0))
elif which == 3:
if stream.bool():
grid.center_camera((stream.float_in_range(-5.0, 40.0),
stream.float_in_range(-5.0, 40.0)))
else:
grid.center_camera()
else:
_ = grid.hovered_cell
def _op_gridpoint_attrs(stream, grids, entities):
"""Tier C (#312): GridPoint __getattr__/__setattr__ named-layer access.
Ensures the grid has a named ColorLayer + TileLayer so the dynamic
attribute path (UIGridPoint::getattro/setattro) resolves to real layers,
then exercises built-in props, valid layer writes, a bogus layer name, and
a wrong-typed tile write.
"""
grid = _pick_grid(stream, grids)
if grid is None:
return
try:
if len(grid.layers) == 0:
grid.add_layer(mcrfpy.ColorLayer(name="fuzzcolor", z_index=0))
grid.add_layer(mcrfpy.TileLayer(name="fuzztile", z_index=-1))
except EXPECTED_EXCEPTIONS:
pass
x = stream.int_in_range(0, MAX_GRID_DIM - 1)
y = stream.int_in_range(0, MAX_GRID_DIM - 1)
if (stream.u8() & 0x07) == 0:
x = stream.int_in_range(-3, MAX_GRID_DIM + 3)
y = stream.int_in_range(-3, MAX_GRID_DIM + 3)
try:
gp = grid.at(x, y)
except EXPECTED_EXCEPTIONS:
return
for setter in (("walkable", stream.bool()), ("transparent", stream.bool())):
try:
setattr(gp, setter[0], setter[1])
except EXPECTED_EXCEPTIONS:
pass
for name in ("fuzzcolor", "fuzztile", "nonexistent_layer"):
try:
_ = getattr(gp, name)
except EXPECTED_EXCEPTIONS:
pass
try:
gp.fuzzcolor = (stream.int_in_range(-20, 300),
stream.int_in_range(-20, 300),
stream.int_in_range(-20, 300))
except EXPECTED_EXCEPTIONS:
pass
try:
gp.fuzztile = stream.int_in_range(-5, 4096)
except EXPECTED_EXCEPTIONS:
pass
try:
gp.fuzztile = "not an int" # wrong type -> TypeError path
except EXPECTED_EXCEPTIONS:
pass
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
_op_grid_query, # 14 (Tier C #312)
_op_gridpoint_attrs, # 15 (Tier C #312)
]
def fuzz_one_input(data):
"""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.
"""
stream = ByteStream(data)
if stream.remaining < 2:
return
try:
# 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
except EXPECTED_EXCEPTIONS:
pass
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