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
This commit is contained in:
parent
ea9251c4e8
commit
925699ef0b
24 changed files with 1204 additions and 4 deletions
184
tests/fuzz/fuzz_import_parsers.py
Normal file
184
tests/fuzz/fuzz_import_parsers.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue