diff --git a/Makefile b/Makefile index dd2db94..61ee9c1 100644 --- a/Makefile +++ b/Makefile @@ -120,7 +120,7 @@ asan-test: asan # a Python `fuzz_one_input(data)` function loaded from the script named by # the MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code # where all the #258-#278 bugs live. No atheris dependency. -FUZZ_TARGETS := grid_entity property_types anim_timer_scene maps_procgen fov pathfinding_behavior +FUZZ_TARGETS := grid_entity property_types anim_timer_scene maps_procgen fov pathfinding_behavior audio_dsp import_parsers texture_factory shader_bindings FUZZ_SECONDS ?= 30 # Shared env for running the fuzz binary. PYTHONHOME points at the build-fuzz diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md index a7c0190..f9fc664 100644 --- a/tests/fuzz/README.md +++ b/tests/fuzz/README.md @@ -43,9 +43,19 @@ dir). Seed inputs committed to `tests/fuzz/seeds//` are read-only. | `fuzz_grid_entity.py` | EntityCollection append/remove/insert/extend/slice across differently-sized grids, `entity.die` during iteration | #258-#263, #273, #274 | | `fuzz_property_types.py` | Random property get/set with type confusion on Frame/Caption/Sprite/Entity/Grid/TileLayer/ColorLayer | #267, #268, #272 | | `fuzz_anim_timer_scene.py` | Animation + Timer state machine, Frame reparenting, scene swap in callbacks | #269, #270, #275, #277 | -| `fuzz_maps_procgen.py` | HeightMap/DiscreteMap ops and conversions, NoiseSource.sample, BSP.to_heightmap | new | -| `fuzz_fov.py` | grid.compute_fov + is_in_fov, transparent toggling | new | -| `fuzz_pathfinding_behavior.py` | DijkstraMap, grid.step, entity behavior fields | #273-adjacent | +| `fuzz_maps_procgen.py` | HeightMap/DiscreteMap ops and conversions, NoiseSource.sample, BSP.to_heightmap, ColorLayer/TileLayer `apply_threshold`/`apply_ranges`/`apply_gradient` | new | +| `fuzz_fov.py` | grid.compute_fov + is_in_fov, transparent toggling, ColorLayer perspective (`apply_perspective`/`update_perspective`/`clear_perspective`/`draw_fov`) | new | +| `fuzz_pathfinding_behavior.py` | DijkstraMap, grid.step, entity behavior fields, `Grid.find_path` + full AStarPath (peek/len/bool/iter) | #273-adjacent | +| `fuzz_audio_dsp.py` | SoundBuffer DSP chain: from_samples/tone/sfxr, concat/mix, pitch_shift/lp/hp/echo/reverb/distortion/bit_crush/gain/normalize/reverse/slice/sfxr_mutate | #312 | +| `fuzz_import_parsers.py` | Tiled/LDtk external file parsers (`TileSetFile`/`TileMapFile`/`LdtkProject`) via temp-file mutation of real fixtures | #312 | +| `fuzz_texture_factory.py` | `Texture.from_bytes`/`composite`/`hsl_shift` byte-ingestion + pixel transforms | #312 | +| `fuzz_shader_bindings.py` | Shader uniform binding lifetime: `uniforms[]`, `PropertyBinding`/`CallableBinding`, target destroyed mid-flight (#270/#271/#277 pattern) | #312 | + +Tier C surfaces from #312 are folded into existing targets rather than new +files: Line/Circle/Arc, `Scene.children` collection ops, and `find`/`find_all`/ +`bresenham`/`lock` live in `fuzz_property_types.py`; grid spatial queries + +GridPoint dynamic attrs in `fuzz_grid_entity.py`. (The benchmark triplet is +deliberately excluded — `end_benchmark()` writes a file per call.) Any target not yet implemented is a stub that still compiles and runs cleanly — `make fuzz` reports it as a no-op. diff --git a/tests/fuzz/fuzz_audio_dsp.py b/tests/fuzz/fuzz_audio_dsp.py new file mode 100644 index 0000000..401084e --- /dev/null +++ b/tests/fuzz/fuzz_audio_dsp.py @@ -0,0 +1,194 @@ +"""fuzz_audio_dsp - SoundBuffer DSP chain fuzzing (#312, Tier B). + +Targets the 14 signal-processing methods + factory/composition entry points +on mcrfpy.SoundBuffer. None of these were exercised by the #283 harness. + +Surface (verified against src/PySoundBuffer.cpp and src/audio/AudioEffects.cpp): + Factories : from_samples(data:y*, channels:II), tone(...), sfxr(...) + Compose : concat([...], overlap), mix([...]) + Effects : pitch_shift, low_pass, high_pass, echo, reverb, distortion, + bit_crush, gain, normalize, reverse, slice, sfxr_mutate + Props : duration, sample_count, sample_rate, channels, sfxr_params + +Why it bites: the AudioEffects math runs on a CPU vector with several +unguarded divisions by sample_rate and no NaN/inf clamping on most parameters +(echo feedback, reverb room/damping, distortion drive, lp/hp cutoff). Extreme +and special-float parameters are deliberately injected to flush out UB. + +No audio device or GL context is needed -- this is pure buffer math, so it runs +in the windowless fuzz build. Contract: fuzz_one_input(data: bytes) -> None. +""" + +import mcrfpy + +from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset + +MAX_OPS = 24 + +WAVEFORMS = ("sine", "square", "saw", "triangle", "noise", "bogus", "") +SFXR_PRESETS = ("coin", "laser", "explosion", "powerup", "hurt", "jump", + "blip", "not_a_preset", "") + + +def weird_float(stream): + """A float that is sometimes a special value (inf/-inf/nan) or extreme.""" + sel = stream.u8() % 8 + if sel == 0: + return float("inf") + if sel == 1: + return float("-inf") + if sel == 2: + return float("nan") + if sel == 3: + return 0.0 + if sel == 4: + return stream.float_in_range(-1e9, 1e9) + if sel == 5: + return stream.float_in_range(-1.0, 1.0) + if sel == 6: + return -stream.float_in_range(0.0, 1e6) + return stream.float_in_range(0.0, 1000.0) + + +def weird_int(stream): + sel = stream.u8() % 5 + if sel == 0: + return 0 + if sel == 1: + return -stream.int_in_range(0, 1_000_000) + if sel == 2: + return stream.int_in_range(0, 32) + if sel == 3: + return stream.int_in_range(0, 1_000_000) + return stream.int_in_range(-5, 20) + + +def make_from_samples(stream): + """Build a SoundBuffer from raw fuzzer bytes interpreted as int16 PCM.""" + n = stream.int_in_range(0, 4096) + raw = stream.take(n) + channels = stream.int_in_range(0, 4) # 0 -> ValueError (guarded) + rate = stream.pick_one((0, 1, 8000, 22050, 44100, 48000, 96000)) + return mcrfpy.SoundBuffer.from_samples(raw, channels, rate) + + +def make_tone(stream): + freq = weird_float(stream) + dur = stream.float_in_range(0.0, 0.2) # keep buffers small/fast + wave = stream.pick_one(WAVEFORMS) + return mcrfpy.SoundBuffer.tone( + freq, dur, wave, + stream.float_in_range(-0.1, 0.5), # attack + stream.float_in_range(-0.1, 0.5), # decay + stream.float_in_range(-0.5, 2.0), # sustain + stream.float_in_range(-0.1, 0.5), # release + stream.pick_one((0, 1, 8000, 44100)), # sample_rate + ) + + +def make_sfxr(stream): + if stream.bool(): + return mcrfpy.SoundBuffer.sfxr(stream.pick_one(SFXR_PRESETS), + stream.u32() if stream.bool() else None) + # Custom-parameter mode: 24 floats, several pushed to extremes. + params = tuple(weird_float(stream) for _ in range(24)) + return mcrfpy.SoundBuffer.sfxr(None, None, *params) + + +FACTORIES = (make_from_samples, make_tone, make_sfxr) + + +def make_buffer(stream): + """Return a SoundBuffer or None (never raises out).""" + factory = stream.pick_one(FACTORIES) + try: + return factory(stream) + except EXPECTED_EXCEPTIONS: + return None + + +def apply_effect(stream, buf): + """Apply one randomly chosen effect, returning the (possibly new) buffer.""" + which = stream.u8() % 12 + try: + if which == 0: + return buf.pitch_shift(weird_float(stream)) + if which == 1: + return buf.low_pass(weird_float(stream)) + if which == 2: + return buf.high_pass(weird_float(stream)) + if which == 3: + return buf.echo(weird_float(stream), weird_float(stream), weird_float(stream)) + if which == 4: + return buf.reverb(weird_float(stream), weird_float(stream), weird_float(stream)) + if which == 5: + return buf.distortion(weird_float(stream)) + if which == 6: + return buf.bit_crush(weird_int(stream), weird_int(stream)) + if which == 7: + return buf.gain(weird_float(stream)) + if which == 8: + return buf.normalize() + if which == 9: + return buf.reverse() + if which == 10: + return buf.slice(weird_float(stream), weird_float(stream)) + return buf.sfxr_mutate(weird_float(stream), + stream.u32() if stream.bool() else None) + except EXPECTED_EXCEPTIONS: + return buf + + +def read_props(buf): + for name in ("duration", "sample_count", "sample_rate", "channels", "sfxr_params"): + try: + _ = getattr(buf, name) + except EXPECTED_EXCEPTIONS: + pass + + +def fuzz_compose(stream, pool): + """Exercise concat/mix over a list of accumulated buffers.""" + if not pool: + return None + k = stream.int_in_range(0, len(pool)) + chosen = [pool[stream.int_in_range(0, len(pool) - 1)] for _ in range(k)] + try: + if stream.bool(): + return mcrfpy.SoundBuffer.concat(chosen, weird_float(stream)) + return mcrfpy.SoundBuffer.mix(chosen) + except EXPECTED_EXCEPTIONS: + return None + + +def fuzz_one_input(data): + stream = ByteStream(data) + pool = [] + try: + n = stream.int_in_range(1, MAX_OPS) + for _ in range(n): + if stream.remaining < 1: + break + choice = stream.u8() % 5 + if choice == 0 or not pool: + buf = make_buffer(stream) + if buf is not None: + read_props(buf) + if len(pool) < 6: + pool.append(buf) + elif choice == 1: + buf = fuzz_compose(stream, pool) + if buf is not None and len(pool) < 6: + pool.append(buf) + else: + # Apply a chain of effects to an existing buffer. + buf = pool[stream.int_in_range(0, len(pool) - 1)] + chain = stream.int_in_range(1, 4) + for _ in range(chain): + buf = apply_effect(stream, buf) + if buf is None: + break + if buf is not None: + read_props(buf) + except EXPECTED_EXCEPTIONS: + pass diff --git a/tests/fuzz/fuzz_import_parsers.py b/tests/fuzz/fuzz_import_parsers.py new file mode 100644 index 0000000..fc275aa --- /dev/null +++ b/tests/fuzz/fuzz_import_parsers.py @@ -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]) + 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 diff --git a/tests/fuzz/fuzz_shader_bindings.py b/tests/fuzz/fuzz_shader_bindings.py new file mode 100644 index 0000000..42f1d13 --- /dev/null +++ b/tests/fuzz/fuzz_shader_bindings.py @@ -0,0 +1,203 @@ +"""fuzz_shader_bindings - shader uniform binding lifetime fuzzing (#312, Tier A). + +Targets the exact pattern that produced #270 / #271 / #277: uniform bindings and +the UniformCollection are lifetime-coupled to a Drawable via weak_ptr, and the +binding/collection can outlive the Drawable. This hammers create -> bind -> +destroy-target -> evaluate sequences. + +Surface (verified against src/PyUniformBinding.cpp, src/PyUniformCollection.cpp, +src/PyShader.cpp, src/UIDrawable.cpp): + drawable.uniforms -> UniformCollection (weak_ptr owner) + uniforms[name] = float | (x,y[,z[,w]]) | PropertyBinding | CallableBinding + PropertyBinding(target, property) -- weak_ptr target + CallableBinding(callable) -- Python callable, evaluated lazily + drawable.shader = Shader(frag_src) -- requires GL; degrades gracefully + +If sf::Shader::isAvailable() is false in the windowless fuzz build, Shader() +raises RuntimeError (PyShader.cpp:82) which is swallowed -- but the binding / +UniformCollection bookkeeping (where the bugs lived) runs regardless of whether +a GLSL program actually compiles. Drawables are kept DETACHED (never appended to +a scene) so that `del` drops the last shared_ptr and the weak_ptr safety paths +are actually taken. + +Contract: fuzz_one_input(data: bytes) -> None. +""" + +import mcrfpy + +from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset + +MAX_OPS = 24 + +# Animatable float properties shared widely enough to satisfy hasProperty() +# on most drawables; invalid pairings raise ValueError and are swallowed. +PROP_NAMES = ("x", "y", "opacity", "w", "h", "z_index", "radius", "thickness", + "rotation", "outline", "not_a_real_property") + +GOOD_FRAG = ( + "uniform float u; void main(){ gl_FragColor = vec4(u,0.0,0.0,1.0); }" +) +BAD_FRAG = "this is not glsl {{{" + + +def make_drawable(stream): + """Construct one DETACHED drawable (not added to any scene).""" + which = stream.u8() % 7 + try: + if which == 0: + return mcrfpy.Frame(pos=(0, 0), size=(10, 10)) + if which == 1: + return mcrfpy.Caption((0, 0), None, "x") + if which == 2: + return mcrfpy.Sprite(pos=(0, 0)) + if which == 3: + return mcrfpy.Grid(grid_size=(4, 4)) + if which == 4: + return mcrfpy.Line(start=(0, 0), end=(5, 5)) + if which == 5: + return mcrfpy.Circle(center=(0, 0), radius=4.0) + return mcrfpy.Arc(center=(0, 0), radius=4.0, start_angle=0.0, end_angle=90.0) + except EXPECTED_EXCEPTIONS: + return None + + +def uniform_value(stream, target): + """Return a value to assign into a uniform slot.""" + sel = stream.u8() % 7 + if sel == 0: + return stream.float_in_range(-1e6, 1e6) + if sel == 1: + return (stream.float_in_range(-1, 1), stream.float_in_range(-1, 1)) + if sel == 2: + return tuple(stream.float_in_range(-1, 1) for _ in range(3)) + if sel == 3: + return tuple(stream.float_in_range(-1, 1) for _ in range(4)) + if sel == 4 and target is not None: + # PropertyBinding back to the target drawable (or another one). + return ("propbind", stream.pick_one(PROP_NAMES)) + if sel == 5: + return ("callbind", stream.u8() % 3) + # Deliberately wrong shapes. + return stream.pick_one((None, "str", (), (1, 2, 3, 4, 5), {"x": 1})) + + +def _callable_for(kind): + if kind == 0: + return lambda: 1.0 + if kind == 1: + return lambda: (_ for _ in ()).throw(ValueError("boom")) # raises on call + return lambda: "not a float" # wrong return type + + +def set_uniform(stream, drawable, name): + raw = uniform_value(stream, drawable) + value = raw + try: + if isinstance(raw, tuple) and len(raw) == 2 and raw[0] == "propbind": + value = mcrfpy.PropertyBinding(drawable, raw[1]) + elif isinstance(raw, tuple) and len(raw) == 2 and raw[0] == "callbind": + value = mcrfpy.CallableBinding(_callable_for(raw[1])) + except EXPECTED_EXCEPTIONS: + return None + try: + drawable.uniforms[name] = value + except EXPECTED_EXCEPTIONS: + pass + return value if isinstance(value, (mcrfpy.PropertyBinding, mcrfpy.CallableBinding)) else None + + +def read_uniforms(drawable, names): + try: + coll = drawable.uniforms + except EXPECTED_EXCEPTIONS: + return + try: + _ = len(coll) + except (TypeError, ValueError, AttributeError): + pass + for name in names: + try: + _ = coll[name] + except EXPECTED_EXCEPTIONS: + pass + try: + for _k in coll: + pass + except EXPECTED_EXCEPTIONS: + pass + + +def read_binding(binding): + """Read a binding after its target may have died (the safety check).""" + if binding is None: + return + for name in ("value", "is_valid", "target", "property", "callable"): + try: + _ = getattr(binding, name) + except EXPECTED_EXCEPTIONS: + pass + try: + repr(binding) + except EXPECTED_EXCEPTIONS: + pass + + +def try_assign_shader(stream, drawable): + src = GOOD_FRAG if stream.bool() else BAD_FRAG + try: + sh = mcrfpy.Shader(src, bool(stream.bool())) + except EXPECTED_EXCEPTIONS: + return + try: + drawable.shader = sh + except EXPECTED_EXCEPTIONS: + pass + + +def fuzz_lifecycle(stream): + """The core lifetime pattern: bind to a target, drop it, then evaluate.""" + target = make_drawable(stream) + if target is None: + return + if stream.bool(): + try_assign_shader(stream, target) + bindings = [] + names = [] + for _ in range(stream.int_in_range(1, 5)): + name = stream.ascii_str(6) or "u" + names.append(name) + b = set_uniform(stream, target, name) + if b is not None: + bindings.append(b) + read_uniforms(target, names) + # Drop the only strong ref to the target while bindings/collection persist. + if stream.bool(): + try: + coll = target.uniforms # keep the collection alive past target + except EXPECTED_EXCEPTIONS: + coll = None + del target + for b in bindings: + read_binding(b) + if coll is not None: + for name in names: + try: + _ = coll[name] + except EXPECTED_EXCEPTIONS: + pass + else: + del target + for b in bindings: + read_binding(b) + + +def fuzz_one_input(data): + stream = ByteStream(data) + try: + n = stream.int_in_range(1, MAX_OPS) + for _ in range(n): + if stream.remaining < 1: + break + fuzz_lifecycle(stream) + except EXPECTED_EXCEPTIONS: + pass diff --git a/tests/fuzz/fuzz_texture_factory.py b/tests/fuzz/fuzz_texture_factory.py new file mode 100644 index 0000000..e81e32d --- /dev/null +++ b/tests/fuzz/fuzz_texture_factory.py @@ -0,0 +1,164 @@ +"""fuzz_texture_factory - Texture byte-ingestion + pixel-transform fuzzing +(#312, Tier B). + +Raw byte ingestion and pixel transforms are classic memory-safety surfaces. +Drives the three Texture factory/transform entry points (verified against +src/PyTexture.cpp): + + Texture.from_bytes(data:y*, width, height, sprite_w, sprite_h, name='') [classmethod] + Texture.composite(layers:list, sprite_w, sprite_h, name='') [classmethod] + texture.hsl_shift(hue, sat=0.0, lit=0.0) [instance] + +from_bytes validates len(data) == width*height*4 (PyTexture.cpp:283) but does +NOT bound width/height to be positive, so zero dimensions and mismatched +lengths are stressed here. composite checks list/element types and equal layer +dimensions (PyTexture.cpp:315-360); empty lists, None/non-Texture elements, and +mismatched sizes are all exercised. + +NOTE: the width*height*4 multiplication-overflow path (huge dimensions paired +with a coincidentally-matching tiny buffer, PyTexture.cpp:283) is intentionally +NOT probed here -- forcing it would trigger a multi-GB sf::Image allocation and +OOM the fuzzer rather than produce a clean crash. Dimensions are bounded so the +length check rejects oversized inputs before any allocation. That overflow case +warrants a separate, manual ASan check. + +Creating sf::Texture needs a GL context; the fuzz build is SFML-backed (not +headless) and already loads default_texture at startup, so creation works. + +Contract: fuzz_one_input(data: bytes) -> None. +""" + +import mcrfpy + +from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS, safe_reset + +MAX_OPS = 24 + + +def weird_float(stream): + sel = stream.u8() % 6 + if sel == 0: + return float("inf") + if sel == 1: + return float("-inf") + if sel == 2: + return float("nan") + if sel == 3: + return stream.float_in_range(-720.0, 720.0) + if sel == 4: + return stream.float_in_range(-5.0, 5.0) + return 0.0 + + +def _default_texture(): + try: + return getattr(mcrfpy, "default_texture", None) + except Exception: + return None + + +def make_valid_texture(stream): + """Construct a small, valid Texture via from_bytes (matched length).""" + w = stream.int_in_range(1, 16) + h = stream.int_in_range(1, 16) + need = w * h * 4 + raw = stream.take(need) + if len(raw) < need: + raw = raw + bytes(need - len(raw)) + sw = stream.int_in_range(1, w) + sh = stream.int_in_range(1, h) + return mcrfpy.Texture.from_bytes(raw, w, h, sw, sh) + + +def fuzz_from_bytes_mismatch(stream): + """Deliberately mismatched dims/length/zero dims -> error paths.""" + n = stream.int_in_range(0, 1024) + raw = stream.take(n) + w = stream.int_in_range(-2, 64) + h = stream.int_in_range(-2, 64) + sw = stream.int_in_range(-2, 64) + sh = stream.int_in_range(-2, 64) + try: + mcrfpy.Texture.from_bytes(raw, w, h, sw, sh) + except EXPECTED_EXCEPTIONS: + pass + except MemoryError: + pass + + +def fuzz_composite(stream, pool): + """Composite a list mixing valid textures, None, non-textures, mismatches.""" + layers = [] + count = stream.int_in_range(0, 5) + for _ in range(count): + sel = stream.u8() % 5 + if sel == 0 and pool: + layers.append(pool[stream.int_in_range(0, len(pool) - 1)]) + elif sel == 1: + layers.append(None) + elif sel == 2: + layers.append("not a texture") + elif sel == 3: + dt = _default_texture() + layers.append(dt) + else: + try: + layers.append(make_valid_texture(stream)) + except EXPECTED_EXCEPTIONS: + pass + except MemoryError: + pass + sw = stream.int_in_range(-2, 32) + sh = stream.int_in_range(-2, 32) + # Sometimes pass a non-list to hit the PyList_Check path. + arg = layers if stream.bool() else tuple(layers) + try: + mcrfpy.Texture.composite(arg, sw, sh) + except EXPECTED_EXCEPTIONS: + pass + except MemoryError: + pass + + +def fuzz_hsl_shift(stream, pool): + tex = None + if pool and stream.bool(): + tex = pool[stream.int_in_range(0, len(pool) - 1)] + else: + tex = _default_texture() + if tex is None: + return + try: + tex.hsl_shift(weird_float(stream), weird_float(stream), weird_float(stream)) + except EXPECTED_EXCEPTIONS: + pass + except MemoryError: + pass + + +def fuzz_one_input(data): + stream = ByteStream(data) + pool = [] + try: + n = stream.int_in_range(1, MAX_OPS) + for _ in range(n): + if stream.remaining < 1: + break + choice = stream.u8() % 4 + if choice == 0: + try: + tex = make_valid_texture(stream) + if len(pool) < 6: + pool.append(tex) + except EXPECTED_EXCEPTIONS: + pass + except MemoryError: + pass + elif choice == 1: + fuzz_from_bytes_mismatch(stream) + elif choice == 2: + fuzz_composite(stream, pool) + else: + fuzz_hsl_shift(stream, pool) + except EXPECTED_EXCEPTIONS: + pass diff --git a/tests/fuzz/seeds/audio_dsp/.gitkeep b/tests/fuzz/seeds/audio_dsp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/audio_dsp/seed_compose.bin b/tests/fuzz/seeds/audio_dsp/seed_compose.bin new file mode 100644 index 0000000..8c3c84c Binary files /dev/null and b/tests/fuzz/seeds/audio_dsp/seed_compose.bin differ diff --git a/tests/fuzz/seeds/audio_dsp/seed_from_samples.bin b/tests/fuzz/seeds/audio_dsp/seed_from_samples.bin new file mode 100644 index 0000000..e7b95ed Binary files /dev/null and b/tests/fuzz/seeds/audio_dsp/seed_from_samples.bin differ diff --git a/tests/fuzz/seeds/audio_dsp/seed_sfxr.bin b/tests/fuzz/seeds/audio_dsp/seed_sfxr.bin new file mode 100644 index 0000000..5c87c5c Binary files /dev/null and b/tests/fuzz/seeds/audio_dsp/seed_sfxr.bin differ diff --git a/tests/fuzz/seeds/audio_dsp/seed_tone.bin b/tests/fuzz/seeds/audio_dsp/seed_tone.bin new file mode 100644 index 0000000..80b5e91 Binary files /dev/null and b/tests/fuzz/seeds/audio_dsp/seed_tone.bin differ diff --git a/tests/fuzz/seeds/import_parsers/.gitkeep b/tests/fuzz/seeds/import_parsers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/import_parsers/seed_ldtk.bin b/tests/fuzz/seeds/import_parsers/seed_ldtk.bin new file mode 100644 index 0000000..b41ad8d --- /dev/null +++ b/tests/fuzz/seeds/import_parsers/seed_ldtk.bin @@ -0,0 +1,326 @@ +{ + "__header__": { + "fileType": "LDtk Project JSON", + "app": "LDtk", + "doc": "https://ldtk.io/json", + "schema": "https://ldtk.io/files/JSON_SCHEMA.json", + "appAuthor": "Sebastien 'deepnight' Benard", + "appVersion": "1.5.3", + "url": "https://ldtk.io" + }, + "iid": "test-project-iid", + "jsonVersion": "1.5.3", + "appBuildId": 0, + "nextUid": 100, + "identifierStyle": "Capitalize", + "toc": [], + "worldLayout": "Free", + "worldGridWidth": 256, + "worldGridHeight": 256, + "defaultLevelWidth": 256, + "defaultLevelHeight": 256, + "defaultPivotX": 0, + "defaultPivotY": 0, + "defaultGridSize": 16, + "defaultEntityWidth": 16, + "defaultEntityHeight": 16, + "bgColor": "#40465B", + "defaultLevelBgColor": "#696A79", + "minifyJson": false, + "externalLevels": false, + "exportTiled": false, + "simplifiedExport": false, + "imageExportMode": "None", + "exportLevelBg": true, + "pngFilePattern": null, + "backupOnSave": false, + "backupLimit": 10, + "backupRelPath": null, + "levelNamePattern": "Level_%idx", + "tutorialDesc": null, + "customCommands": [], + "flags": [], + "defs": { + "layers": [ + { + "__type": "IntGrid", + "identifier": "Terrain", + "type": "IntGrid", + "uid": 1, + "doc": null, + "uiColor": null, + "gridSize": 16, + "guideGridWid": 0, + "guideGridHei": 0, + "displayOpacity": 1, + "inactiveOpacity": 0.6, + "hideInList": false, + "hideFieldsWhenInactive": true, + "canSelectWhenInactive": true, + "renderInWorldView": true, + "pxOffsetX": 0, + "pxOffsetY": 0, + "parallaxFactorX": 0, + "parallaxFactorY": 0, + "parallaxScaling": true, + "requiredTags": [], + "excludedTags": [], + "autoTilesetDefUid": 10, + "tilesetDefUid": 10, + "tilePivotX": 0, + "tilePivotY": 0, + "biomeFieldUid": null, + "intGridValues": [ + { "value": 1, "identifier": "wall", "color": "#FFFFFF", "tile": null, "groupUid": 0 }, + { "value": 2, "identifier": "floor", "color": "#808080", "tile": null, "groupUid": 0 }, + { "value": 3, "identifier": "water", "color": "#0000FF", "tile": null, "groupUid": 0 } + ], + "intGridValuesGroups": [], + "autoRuleGroups": [ + { + "uid": 50, + "name": "Walls", + "color": null, + "icon": null, + "active": true, + "isOptional": false, + "rules": [ + { + "uid": 51, + "active": true, + "size": 3, + "tileRectsIds": [[[0, 0]]], + "alpha": 1, + "chance": 1, + "breakOnMatch": true, + "pattern": [ + 0, 0, 0, + 0, 1, 0, + 0, 0, 0 + ], + "flipX": false, + "flipY": false, + "xModulo": 1, + "yModulo": 1, + "xOffset": 0, + "yOffset": 0, + "tileXOffset": 0, + "tileYOffset": 0, + "tileRandomXMin": 0, + "tileRandomXMax": 0, + "tileRandomYMin": 0, + "tileRandomYMax": 0, + "checker": "None", + "tileMode": "Single", + "pivotX": 0, + "pivotY": 0, + "outOfBoundsValue": -1, + "perlinActive": false, + "perlinSeed": 0, + "perlinScale": 0.2, + "perlinOctaves": 2, + "invalidated": false + }, + { + "uid": 52, + "active": true, + "size": 3, + "tileRectsIds": [[[16, 0]]], + "alpha": 1, + "chance": 1, + "breakOnMatch": true, + "pattern": [ + 0, -1, 0, + 0, 1, 0, + 0, 0, 0 + ], + "flipX": true, + "flipY": false, + "xModulo": 1, + "yModulo": 1, + "xOffset": 0, + "yOffset": 0, + "tileXOffset": 0, + "tileYOffset": 0, + "tileRandomXMin": 0, + "tileRandomXMax": 0, + "tileRandomYMin": 0, + "tileRandomYMax": 0, + "checker": "None", + "tileMode": "Single", + "pivotX": 0, + "pivotY": 0, + "outOfBoundsValue": -1, + "perlinActive": false, + "perlinSeed": 0, + "perlinScale": 0.2, + "perlinOctaves": 2, + "invalidated": false + } + ], + "usesWizard": false, + "requiredBiomeValues": [], + "biomeRequirementMode": 0 + }, + { + "uid": 60, + "name": "Floors", + "color": null, + "icon": null, + "active": true, + "isOptional": false, + "rules": [ + { + "uid": 61, + "active": true, + "size": 1, + "tileRectsIds": [[[32, 0]], [[48, 0]]], + "alpha": 1, + "chance": 1, + "breakOnMatch": true, + "pattern": [2], + "flipX": false, + "flipY": false, + "xModulo": 1, + "yModulo": 1, + "xOffset": 0, + "yOffset": 0, + "tileXOffset": 0, + "tileYOffset": 0, + "tileRandomXMin": 0, + "tileRandomXMax": 0, + "tileRandomYMin": 0, + "tileRandomYMax": 0, + "checker": "None", + "tileMode": "Single", + "pivotX": 0, + "pivotY": 0, + "outOfBoundsValue": -1, + "perlinActive": false, + "perlinSeed": 0, + "perlinScale": 0.2, + "perlinOctaves": 2, + "invalidated": false + } + ], + "usesWizard": false, + "requiredBiomeValues": [], + "biomeRequirementMode": 0 + } + ], + "autoSourceLayerDefUid": null + } + ], + "entities": [], + "tilesets": [ + { + "__cWid": 4, + "__cHei": 4, + "identifier": "Test_Tileset", + "uid": 10, + "relPath": "test_tileset.png", + "embedAtlas": null, + "pxWid": 64, + "pxHei": 64, + "tileGridSize": 16, + "spacing": 0, + "padding": 0, + "tags": [], + "tagsSourceEnumUid": null, + "enumTags": [], + "customData": [], + "savedSelections": [], + "cachedPixelData": null + } + ], + "enums": [ + { + "identifier": "TileType", + "uid": 20, + "values": [ + { "id": "Solid", "tileRect": null, "color": 0 }, + { "id": "Platform", "tileRect": null, "color": 0 } + ], + "iconTilesetUid": null, + "externalRelPath": null, + "externalFileChecksum": null, + "tags": [] + } + ], + "externalEnums": [], + "levelFields": [] + }, + "levels": [ + { + "identifier": "Level_0", + "iid": "level-0-iid", + "uid": 30, + "worldX": 0, + "worldY": 0, + "worldDepth": 0, + "pxWid": 80, + "pxHei": 80, + "__bgColor": "#696A79", + "bgColor": null, + "useAutoIdentifier": false, + "bgRelPath": null, + "bgPos": null, + "bgPivotX": 0.5, + "bgPivotY": 0.5, + "__smartColor": "#ADADB5", + "__bgPos": null, + "externalRelPath": null, + "fieldInstances": [], + "layerInstances": [ + { + "__identifier": "Terrain", + "__type": "IntGrid", + "__cWid": 5, + "__cHei": 5, + "__gridSize": 16, + "__opacity": 1, + "__pxTotalOffsetX": 0, + "__pxTotalOffsetY": 0, + "__tilesetDefUid": 10, + "__tilesetRelPath": "test_tileset.png", + "iid": "layer-iid", + "levelId": 30, + "layerDefUid": 1, + "pxOffsetX": 0, + "pxOffsetY": 0, + "visible": true, + "optionalRules": [], + "intGridCsv": [ + 1, 1, 1, 1, 1, + 1, 2, 2, 2, 1, + 1, 2, 3, 2, 1, + 1, 2, 2, 2, 1, + 1, 1, 1, 1, 1 + ], + "autoLayerTiles": [ + { "px": [0, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 }, + { "px": [16, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 }, + { "px": [32, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 }, + { "px": [48, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 }, + { "px": [64, 0], "src": [0, 0], "f": 0, "t": 0, "d": [51], "a": 1 }, + { "px": [16, 16], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [32, 16], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [48, 16], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [16, 32], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [48, 32], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [16, 48], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [32, 48], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 }, + { "px": [48, 48], "src": [32, 0], "f": 0, "t": 2, "d": [61], "a": 1 } + ], + "seed": 1234, + "overrideTilesetUid": null, + "gridTiles": [], + "entityInstances": [] + } + ], + "__neighbours": [] + } + ], + "worlds": [], + "dummyWorldIid": "dummy-iid" +} diff --git a/tests/fuzz/seeds/import_parsers/seed_tmj.bin b/tests/fuzz/seeds/import_parsers/seed_tmj.bin new file mode 100644 index 0000000..125e0f4 --- /dev/null +++ b/tests/fuzz/seeds/import_parsers/seed_tmj.bin @@ -0,0 +1,83 @@ +{ "compressionlevel":-1, + "height":4, + "infinite":false, + "layers":[ + { + "data":[1,2,1,1,2,2,1,3,1,1,1,1,3,3,2,1], + "height":4, + "id":1, + "name":"Ground", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":4, + "x":0, + "y":0 + }, + { + "data":[0,0,0,0,0,9,10,0,0,11,12,0,0,0,0,0], + "height":4, + "id":2, + "name":"Overlay", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":4, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":3, + "name":"Objects", + "objects":[ + { + "id":1, + "name":"spawn", + "type":"point", + "x":32, + "y":32, + "point":true, + "properties":[ + {"name":"player_start", "type":"bool", "value":true} + ] + }, + { + "id":2, + "name":"trigger_zone", + "x":0, + "y":0, + "width":64, + "height":64, + "properties":[ + {"name":"zone_id", "type":"int", "value":42} + ] + } + ], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + } + ], + "nextlayerid":4, + "nextobjectid":3, + "orientation":"orthogonal", + "renderorder":"right-down", + "tiledversion":"1.10.2", + "tileheight":16, + "tilesets":[ + { + "firstgid":1, + "source":"test_tileset.tsj" + } + ], + "tilewidth":16, + "type":"map", + "version":"1.10", + "width":4, + "properties":[ + {"name":"map_name", "type":"string", "value":"test"} + ] +} diff --git a/tests/fuzz/seeds/import_parsers/seed_tmx.bin b/tests/fuzz/seeds/import_parsers/seed_tmx.bin new file mode 100644 index 0000000..0b5e61c --- /dev/null +++ b/tests/fuzz/seeds/import_parsers/seed_tmx.bin @@ -0,0 +1,36 @@ + + + + + + + + +1,2,1,1, +2,2,1,3, +1,1,1,1, +3,3,2,1 + + + + +0,0,0,0, +0,9,10,0, +0,11,12,0, +0,0,0,0 + + + + + + + + + + + + + + + + diff --git a/tests/fuzz/seeds/import_parsers/seed_tsx.bin b/tests/fuzz/seeds/import_parsers/seed_tsx.bin new file mode 100644 index 0000000..3fec614 Binary files /dev/null and b/tests/fuzz/seeds/import_parsers/seed_tsx.bin differ diff --git a/tests/fuzz/seeds/shader_bindings/.gitkeep b/tests/fuzz/seeds/shader_bindings/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/shader_bindings/seed_callbind.bin b/tests/fuzz/seeds/shader_bindings/seed_callbind.bin new file mode 100644 index 0000000..4d321ef Binary files /dev/null and b/tests/fuzz/seeds/shader_bindings/seed_callbind.bin differ diff --git a/tests/fuzz/seeds/shader_bindings/seed_propbind.bin b/tests/fuzz/seeds/shader_bindings/seed_propbind.bin new file mode 100644 index 0000000..164df53 Binary files /dev/null and b/tests/fuzz/seeds/shader_bindings/seed_propbind.bin differ diff --git a/tests/fuzz/seeds/shader_bindings/seed_shapes.bin b/tests/fuzz/seeds/shader_bindings/seed_shapes.bin new file mode 100644 index 0000000..c2023cc Binary files /dev/null and b/tests/fuzz/seeds/shader_bindings/seed_shapes.bin differ diff --git a/tests/fuzz/seeds/texture_factory/.gitkeep b/tests/fuzz/seeds/texture_factory/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/seeds/texture_factory/seed_composite.bin b/tests/fuzz/seeds/texture_factory/seed_composite.bin new file mode 100644 index 0000000..6f9cbbb Binary files /dev/null and b/tests/fuzz/seeds/texture_factory/seed_composite.bin differ diff --git a/tests/fuzz/seeds/texture_factory/seed_mismatch.bin b/tests/fuzz/seeds/texture_factory/seed_mismatch.bin new file mode 100644 index 0000000..03d0277 Binary files /dev/null and b/tests/fuzz/seeds/texture_factory/seed_mismatch.bin differ diff --git a/tests/fuzz/seeds/texture_factory/seed_valid.bin b/tests/fuzz/seeds/texture_factory/seed_valid.bin new file mode 100644 index 0000000..371a75f Binary files /dev/null and b/tests/fuzz/seeds/texture_factory/seed_valid.bin differ