McRogueFace/tests/fuzz/fuzz_import_parsers.py
John McCardle 925699ef0b Add 4 libFuzzer targets for Tier A/B API surface; addresses #312
New targets under tests/fuzz/, wired into Makefile FUZZ_TARGETS, each with a
seed corpus (parser seeds are real fixtures prefixed with a loader selector
byte):

- fuzz_audio_dsp        SoundBuffer factories + 14 DSP effects + concat/mix.
                        Self-contained (CPU sample math, no device).
- fuzz_import_parsers   TileSetFile/TileMapFile/LdtkProject. Loaders take a
                        path, so each iteration writes mutated bytes to a temp
                        file; OSError (IOError) is swallowed as an expected
                        parse-failure outcome.
- fuzz_texture_factory  Texture.from_bytes/composite/hsl_shift byte ingestion.
                        Multiplication-overflow path documented as out of scope
                        (would OOM, not crash cleanly).
- fuzz_shader_bindings  uniforms[] + PropertyBinding/CallableBinding lifetime,
                        target Drawable destroyed mid-flight (#270/#271/#277
                        pattern). Degrades to pure binding-lifetime fuzzing if
                        shaders are unavailable.

All four signature-validated against the live mcrfpy API before running.

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

184 lines
5.9 KiB
Python

"""fuzz_import_parsers - Tiled/LDtk external file parser fuzzing (#312, Tier A).
External file parsers are historically the highest-yield fuzz target. This
drives the three loaders that ingest untrusted XML/JSON from disk:
mcrfpy.TileSetFile(path) -- .tsx (XML) / .tsj / .json
mcrfpy.TileMapFile(path) -- .tmx (XML) / .tmj / .json
mcrfpy.LdtkProject(path) -- .ldtk (JSON)
All three take a filesystem PATH (verified: src/tiled/PyTileSetFile.cpp:20,
src/tiled/PyTileMapFile.cpp:22, src/ldtk/PyLdtkProject.cpp:24 -- each parses
"s"). So each iteration writes the fuzzer bytes to a temp file with a
format-appropriate extension, then loads it.
Input layout: byte[0] selects the loader/extension (see LOADERS); the rest is
the file body. Seeds are built as bytes([selector]) + <fixture file bytes> so
libFuzzer mutates real .tsx/.tmx/.tmj/.ldtk content (see tests/fuzz/seeds/).
After a successful load the parsed object's properties, lookups, and dynamic
IntEnum builders (terrain_enum) are exercised -- the underlying parse code has
unguarded std::stoi on wangid tokens (src/tiled/TiledParse.cpp:200-202) and
divisions that depend on parsed grid_size/tile_size (src/ldtk/LdtkParse.cpp).
Contract: fuzz_one_input(data: bytes) -> None.
"""
import os
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset
# The loaders wrap std::exceptions (malformed file, unresolvable external
# tileset reference, etc.) in PyExc_IOError == OSError, which the shared
# EXPECTED_EXCEPTIONS tuple does not include. Swallow it here: a parse failure
# on garbage input is the expected outcome, not a bug.
PARSER_EXPECTED = EXPECTED_EXCEPTIONS + (OSError,)
# (extension, loader-callable). byte[0] % len(LOADERS) selects one.
LOADERS = (
("tsx", lambda p: mcrfpy.TileSetFile(p)),
("tmx", lambda p: mcrfpy.TileMapFile(p)),
("tmj", lambda p: mcrfpy.TileMapFile(p)),
("ldtk", lambda p: mcrfpy.LdtkProject(p)),
("tsj", lambda p: mcrfpy.TileSetFile(p)),
("json", lambda p: mcrfpy.TileMapFile(p)),
)
_TMP_DIR = os.environ.get("TMPDIR", "/tmp")
_TMP_BASE = os.path.join(_TMP_DIR, "mcrf_fuzz_parser_%d" % os.getpid())
def _read_seq(obj, names):
"""Read a list of property names, swallowing expected errors."""
for name in names:
try:
_ = getattr(obj, name)
except EXPECTED_EXCEPTIONS:
pass
def exercise_tileset(stream, ts):
_read_seq(ts, ("name", "tile_width", "tile_height", "tile_count", "columns",
"margin", "spacing", "image_source", "properties", "wang_sets"))
# tile_info over a few ids including out-of-range
for _ in range(stream.int_in_range(0, 4)):
try:
ts.tile_info(stream.int_in_range(-5, 4096))
except EXPECTED_EXCEPTIONS:
pass
# Walk wang sets; terrain_enum builds a Python IntEnum from parsed colors.
try:
for ws in ts.wang_sets:
_read_seq(ws, ("name", "type", "color_count", "colors"))
try:
ws.terrain_enum()
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
def exercise_tilemap(stream, tm):
_read_seq(tm, ("width", "height", "tile_width", "tile_height", "orientation",
"properties", "tileset_count", "tile_layer_names",
"object_layer_names"))
try:
for i in range(min(tm.tileset_count, 4)):
try:
tm.tileset(i)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(tm.tile_layer_names)[:3]:
try:
tm.tile_layer_data(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
for _ in range(stream.int_in_range(0, 4)):
try:
tm.resolve_gid(stream.int_in_range(-5, 1 << 20))
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(tm.object_layer_names)[:3]:
try:
tm.object_layer(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
def exercise_ldtk(stream, proj):
_read_seq(proj, ("version", "tileset_names", "ruleset_names", "level_names",
"enums"))
try:
for name in list(proj.tileset_names)[:3]:
try:
proj.tileset(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(proj.ruleset_names)[:3]:
try:
rs = proj.ruleset(name)
_read_seq(rs, ("name", "grid_size", "value_count", "values",
"rule_count", "group_count"))
try:
rs.terrain_enum()
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
try:
for name in list(proj.level_names)[:3]:
try:
proj.level(name)
except EXPECTED_EXCEPTIONS:
pass
except EXPECTED_EXCEPTIONS:
pass
EXERCISE = {
"tsx": exercise_tileset,
"tsj": exercise_tileset,
"tmx": exercise_tilemap,
"tmj": exercise_tilemap,
"json": exercise_tilemap,
"ldtk": exercise_ldtk,
}
def fuzz_one_input(data):
stream = ByteStream(data)
if stream.remaining < 1:
return
ext, loader = LOADERS[stream.u8() % len(LOADERS)]
body = stream.take(stream.remaining)
path = "%s.%s" % (_TMP_BASE, ext)
try:
with open(path, "wb") as fh:
fh.write(body)
except OSError:
return
try:
obj = loader(path)
EXERCISE[ext](stream, obj)
except PARSER_EXPECTED:
pass
finally:
try:
os.unlink(path)
except OSError:
pass