From 7de2ef81516ded61a249e6652147c7b522cc6747 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Tue, 29 Sep 2026 20:32:17 +0200 Subject: [PATCH] Add Atheris fuzz targets, fix findings --- .github/scripts/fuzz.sh | 62 ++++++++++ .gitignore | 4 + Makefile | 8 +- python-webrtc/cpp/CMakeLists.txt | 5 +- python-webrtc/cpp/src/media/audio_samples.cpp | 8 +- .../python/webrtc/models/video_frame.py | 2 + tests/fuzz/README.md | 22 ++++ tests/fuzz/_input.py | 87 +++++++++++++ tests/fuzz/fuzz_audio_data.py | 79 ++++++++++++ tests/fuzz/fuzz_generator.py | 114 ++++++++++++++++++ tests/fuzz/fuzz_native_buffers.py | 93 ++++++++++++++ tests/fuzz/fuzz_video_frame.py | 111 +++++++++++++++++ tests/test_audio_data.py | 12 ++ tests/test_video_frame.py | 11 ++ 14 files changed, 615 insertions(+), 3 deletions(-) create mode 100755 .github/scripts/fuzz.sh create mode 100644 tests/fuzz/README.md create mode 100644 tests/fuzz/_input.py create mode 100644 tests/fuzz/fuzz_audio_data.py create mode 100644 tests/fuzz/fuzz_generator.py create mode 100644 tests/fuzz/fuzz_native_buffers.py create mode 100644 tests/fuzz/fuzz_video_frame.py diff --git a/.github/scripts/fuzz.sh b/.github/scripts/fuzz.sh new file mode 100755 index 0000000..9d87d9c --- /dev/null +++ b/.github/scripts/fuzz.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Builds the extension with libFuzzer coverage, AddressSanitizer and UndefinedBehaviorSanitizer and runs a fuzz +# target of tests/fuzz with Atheris. Linux only: Apple Clang has no libFuzzer. In the manylinux image: +# docker run --rm --platform linux/amd64 -v "$PWD:/src" -w /src \ +# quay.io/pypa/manylinux_2_28_x86_64 .github/scripts/fuzz.sh video_frame -max_total_time=600 +# +# The first argument is the target (fuzz_.py), the rest are libFuzzer's. The corpus grows in +# tests/fuzz/corpus/, crashes are written to tests/fuzz/crashes. Pass a crash file to replay it. + +set -euo pipefail + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUILD="${WRTC_FUZZ_BUILD_DIR:-/tmp/wrtc-fuzz}" +PYTHON="${PYTHON:-/opt/python/cp313-cp313/bin/python}" +TARGET="${1:?usage: fuzz.sh [libFuzzer arguments]}" +shift + +if ! command -v clang > /dev/null; then + dnf install -y -q clang lld compiler-rt llvm +fi + +if [ ! -x "$BUILD/venv/bin/python" ]; then + "$PYTHON" -m venv "$BUILD/venv" + # built from source with this Clang, so its sanitizer runtime is the one the extension is instrumented for + CLANG_BIN="$(command -v clang)" LIBFUZZER_LIB="$(clang -print-runtime-dir)/libclang_rt.fuzzer_no_main.a" \ + "$BUILD/venv/bin/pip" install -q --no-binary atheris atheris \ + cmake ninja "pybind11>=3.0" pytest +fi +# shellcheck disable=SC1091 +source "$BUILD/venv/bin/activate" + +CC=clang CXX=clang++ cmake -S "$SRC" -B "$BUILD/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_UNITY_BUILD=ON \ + -DWRTC_SANITIZE=fuzzer-no-link,address,undefined \ + -Dpybind11_DIR="$(python -m pybind11 --cmakedir)" > /dev/null +cmake --build "$BUILD/build" + +# The interpreter isn't instrumented: libFuzzer and ASan must be loaded before anything else +LD_PRELOAD="$(python -c 'import atheris; print(atheris.path())')/asan_with_fuzzer.so" +export LD_PRELOAD +export PYTHONMALLOC=malloc +export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=0:detect_stack_use_after_return=0" +export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" +ASAN_SYMBOLIZER_PATH="$(command -v llvm-symbolizer)" +export ASAN_SYMBOLIZER_PATH +export PYTHONPATH="$BUILD/build/python-webrtc/cpp:$SRC/python-webrtc/python:$SRC/tests/fuzz" +export PYTHONDONTWRITEBYTECODE=1 + +CORPUS="$SRC/tests/fuzz/corpus/$TARGET" +mkdir -p "$CORPUS" "$SRC/tests/fuzz/crashes" +# a file argument replays it instead of fuzzing +if [ $# -gt 0 ] && [ -f "$1" ]; then + CRASH="$(realpath "$1")" + shift + cd "$SRC/tests/fuzz" + exec python "fuzz_$TARGET.py" "$CRASH" "$@" +fi +cd "$SRC/tests/fuzz" +exec python "fuzz_$TARGET.py" "$CORPUS" -artifact_prefix="$SRC/tests/fuzz/crashes/$TARGET-" \ + -max_len=4096 -timeout=30 -rss_limit_mb=4096 "$@" diff --git a/.gitignore b/.gitignore index 9efd1a3..66f5648 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ python-webrtc/python/*.session-journal python-webrtc/python/*.raw python-webrtc/python/snippet_test.py python-webrtc/python/test_ub.py + +# fuzzing (tests/fuzz): crashes and the corpora libFuzzer grows +/tests/fuzz/crashes/ +/tests/fuzz/corpus/ diff --git a/Makefile b/Makefile index 1a299c3..f005aa5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev test asan tsan lint format format-check tidy stub wheels doc clean +.PHONY: dev test asan tsan fuzz lint format format-check tidy stub wheels doc clean # pinned to the clang-tidy of .github/scripts/tidy.sh CLANG_FORMAT := uvx clang-format==22.1.8 @@ -20,6 +20,12 @@ asan: tsan: SANITIZE=thread .github/scripts/sanitizers-macos.sh $(O) +# a target of tests/fuzz with Atheris in the Linux image, the build kept in build/fuzz: make fuzz T=video_frame O=-max_total_time=600 +fuzz: + docker run --rm -it --platform linux/amd64 -v "$(CURDIR):/src" -v "$(CURDIR)/build/fuzz:/tmp/wrtc-fuzz" \ + -e WRTC_CACHE_DIR=/tmp/wrtc-fuzz/cache -w /src \ + quay.io/pypa/manylinux_2_28_x86_64 .github/scripts/fuzz.sh $(T) $(O) + lint: format-check uvx ruff check uvx ruff format --check diff --git a/python-webrtc/cpp/CMakeLists.txt b/python-webrtc/cpp/CMakeLists.txt index b890fa7..4dcbcf6 100644 --- a/python-webrtc/cpp/CMakeLists.txt +++ b/python-webrtc/cpp/CMakeLists.txt @@ -27,7 +27,10 @@ if(WRTC_SANITIZE) -fsanitize=${WRTC_SANITIZE} -fno-sanitize=vptr -fno-sanitize-recover=all -fno-omit-frame-pointer) target_link_options(${MODULE} PRIVATE -fsanitize=${WRTC_SANITIZE} -fno-sanitize=vptr) if(WRTC_SANITIZE MATCHES "address") - target_link_options(${MODULE} PRIVATE -shared-libasan) + # Atheris preloads its own runtime with libFuzzer (asan_with_fuzzer.so), which must provide the symbols + if(NOT WRTC_SANITIZE MATCHES "fuzzer") + target_link_options(${MODULE} PRIVATE -shared-libasan) + endif() # The libc++ inside libwebrtc isn't instrumented (no _LIBCPP_INSTRUMENTED_WITH_ASAN): containers it touches # aren't annotated, so annotating them in our code alone reports false container-overflows. target_compile_definitions(${MODULE} PRIVATE __SANITIZER_DISABLE_CONTAINER_OVERFLOW__) diff --git a/python-webrtc/cpp/src/media/audio_samples.cpp b/python-webrtc/cpp/src/media/audio_samples.cpp index 18d5c91..99e886f 100644 --- a/python-webrtc/cpp/src/media/audio_samples.cpp +++ b/python-webrtc/cpp/src/media/audio_samples.cpp @@ -8,6 +8,7 @@ #include "audio_samples.h" #include +#include #include #include #include @@ -92,7 +93,12 @@ namespace python_webrtc { case SampleType::S32: return Load(data); default: { - const double scaled = std::clamp(static_cast(Load(data)), -1.0, 1.0) * kS32Scale; + const double sample = Load(data); + // NaN is silence: clamping keeps it, and converting it to an integer is undefined + if (std::isnan(sample)) { + return 0; + } + const double scaled = std::clamp(sample, -1.0, 1.0) * kS32Scale; return static_cast(std::clamp(scaled, -kS32Scale, kS32Scale - 1)); } } diff --git a/python-webrtc/python/webrtc/models/video_frame.py b/python-webrtc/python/webrtc/models/video_frame.py index f1e9175..a0e2659 100644 --- a/python-webrtc/python/webrtc/models/video_frame.py +++ b/python-webrtc/python/webrtc/models/video_frame.py @@ -346,6 +346,8 @@ def _layout(value: Any) -> Optional[List[PlaneLayout]]: def _rotation(value: float) -> int: """The nearest multiple of 90, ties rounded up, from 0 to 270""" + if not math.isfinite(value): + raise TypeError(f'The rotation must be finite, not {value!r}') return int(math.floor(value / 90 + 0.5) * 90) % 360 diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..47d17fc --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,22 @@ +# Fuzzing + +Coverage-guided fuzz targets for [Atheris](https://github.com/google/atheris) (libFuzzer for Python). The extension +is built with libFuzzer coverage, ASan and UBSan; libwebrtc isn't instrumented, but its `RTC_CHECK` aborts and +crashes are caught. + +| Target | What it fuzzes | +| --- | --- | +| `video_frame` | `VideoFrame` from buffers, `copy_to` with rects, layouts and RGB conversions, frames of frames | +| `audio_data` | `AudioData` and `copy_to` with every format and layout conversion | +| `native_buffers` | The native `wrtc.VideoFrameBuffer` and `wrtc.copyAudioSamples` directly, without the Python checks | +| `generator` | `AudioData` and `VideoFrame` written to generators sent over a connection, read back by processors | + +Linux only (Apple Clang has no libFuzzer), in the manylinux image; the build is kept in `build/fuzz`: + +```sh +make fuzz T=video_frame O=-max_total_time=600 +make fuzz T=audio_data O=tests/fuzz/crashes/audio_data-crash-... # replays a crash +``` + +Only the documented exceptions are expected, and a few oracles check results (a frame or samples copied out as they +are give the same bytes). A crash becomes a regression test in `tests/`. diff --git a/tests/fuzz/_input.py b/tests/fuzz/_input.py new file mode 100644 index 0000000..871ebf2 --- /dev/null +++ b/tests/fuzz/_input.py @@ -0,0 +1,87 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Values for the fuzz targets, drawn from the fuzzer's bytes and biased to the edges where checks go wrong.""" + +import atheris + +# where sizes overflow: 16, 24 (the native limit of a frame side), 31, 32 and 64 bits +EDGES = [0, 1, 2, 3, 4, 7, 8, 15, 16, 255, 256, 2**16 - 1, 2**16, 2**24, 2**24 + 1, 2**31 - 1, 2**31] +EDGES += [2**32 - 1, 2**32, 2**32 + 1, 2**63 - 1, 2**63, 2**64 - 1, 2**64, 2**65] +FLOATS = [0.0, -0.0, 0.5, 1.5, -1.0, 1e-300, 1e300, float('inf'), float('-inf'), float('nan')] + + +class Input: + def __init__(self, data: bytes): + self._fdp = atheris.FuzzedDataProvider(data) + + def flag(self) -> bool: + return self._fdp.ConsumeBool() + + def choice(self, values): + return self._fdp.PickValueInList(list(values)) + + def small(self, limit: int = 64) -> int: + return self._fdp.ConsumeIntInRange(0, limit) + + def unsigned(self, limit: int = 64) -> int: + """Mostly small, sometimes an edge""" + mode = self._fdp.ConsumeIntInRange(0, 7) + if mode == 0: + return self.choice(EDGES) + if mode == 1: + return self._fdp.ConsumeIntInRange(0, 2**64 - 1) + return self.small(limit) + + def integer(self, limit: int = 64): + """An unsigned value, a negative one, or something that isn't an integer""" + mode = self._fdp.ConsumeIntInRange(0, 15) + if mode == 0: + return -self.unsigned(limit) - 1 + if mode == 1: + return self.number() + if mode == 2: + return self.choice([None, True, '1', b'1']) + return self.unsigned(limit) + + def number(self, limit: int = 64): + mode = self._fdp.ConsumeIntInRange(0, 3) + if mode == 0: + return self.choice(FLOATS) + if mode == 1: + return self.small(limit) + self.choice([0, 0, 0.5, 1e-9]) + return self.small(limit) + + def maybe(self, make): + return make() if self.flag() else None + + def buffer(self, size: int): + """size bytes as bytes, a bytearray, or a view of one, which may be strided""" + data = self._fdp.ConsumeBytes(size) + data += bytes(size - len(data)) + kind = self._fdp.ConsumeIntInRange(0, 7) + if kind == 0: + return bytearray(data) + if kind == 1: + return memoryview(bytearray(data * 2))[::2] + if kind == 2: + return memoryview(bytearray(data))[::-1] + if kind == 3: + return memoryview(bytearray(data)).cast('B', [size, 1]) if size else memoryview(bytearray()) + return data + + def destination(self, size: int): + """A writable buffer of about size bytes""" + size = max(0, size + self.choice([0, 0, 0, -1, 1, 64])) + kind = self._fdp.ConsumeIntInRange(0, 7) + if kind == 0: + return memoryview(bytearray(size * 2))[::2] + if kind == 1: + return memoryview(bytearray(size))[::-1] + if kind == 2: + return bytes(size) + return bytearray(size) diff --git a/tests/fuzz/fuzz_audio_data.py b/tests/fuzz/fuzz_audio_data.py new file mode 100644 index 0000000..1b83c12 --- /dev/null +++ b/tests/fuzz/fuzz_audio_data.py @@ -0,0 +1,79 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Fuzzes AudioData: creation, and copy_to with every conversion of format and layout.""" + +import sys + +import atheris + +with atheris.instrument_imports(): + from _input import Input + + import webrtc + +FORMATS = list(webrtc.AudioSampleFormat) +EXPECTED = (TypeError, ValueError, BufferError, webrtc.NotSupportedError, webrtc.InvalidStateError) +SAMPLE_BYTES = {'u8': 1, 's16': 2, 's32': 4, 'f32': 4} + + +def check_identity(audio: webrtc.AudioData, data: bytes) -> None: + """Interleaved samples copied out in their own format are the same bytes""" + if audio.format.value.endswith('-planar'): + return + out = bytearray(audio.allocation_size({'plane_index': 0})) + audio.copy_to(out, {'plane_index': 0}) + assert bytes(out) == data[: len(out)], f'{audio!r} copied out differently' + + +def test_one_input(data: bytes) -> None: + inp = Input(data) + format = inp.choice(FORMATS) + frames, channels = inp.integer(512), inp.integer(8) + sample_rate = inp.number(96000) if inp.flag() else 48000 + size = inp.small(1 << 14) + if isinstance(frames, int) and isinstance(channels, int) and inp.flag(): + # exactly enough samples, when it's not too many + exact = frames * channels * SAMPLE_BYTES[format.value.split('-')[0]] + size = exact if 0 <= exact < 1 << 16 else size + source = inp.buffer(size) + try: + audio = webrtc.AudioData( + format=format, + sample_rate=sample_rate, + number_of_frames=frames, + number_of_channels=channels, + timestamp=inp.integer(), + data=source, + ) + except EXPECTED: + return + check_identity(audio, bytes(source)) + for _ in range(inp.small(4)): + options = {'plane_index': inp.integer(8)} + if inp.flag(): + options['frame_offset'] = inp.integer(512) + if inp.flag(): + options['frame_count'] = inp.integer(512) + if inp.flag(): + options['format'] = inp.choice(FORMATS) + try: + size = audio.allocation_size(options) + audio.copy_to(inp.destination(min(size, 1 << 20)), options) + except EXPECTED: + pass + if inp.small(8) == 0: + audio = audio.clone() + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/fuzz/fuzz_generator.py b/tests/fuzz/fuzz_generator.py new file mode 100644 index 0000000..bcbe479 --- /dev/null +++ b/tests/fuzz/fuzz_generator.py @@ -0,0 +1,114 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Fuzzes what generators send: arbitrary AudioData and VideoFrame written to tracks of a connected peer connection. + +libwebrtc checks what it's given with RTC_CHECK, which aborts the process: the generators must reject what it can't +take. The remote tracks are read by processors, so received frames go through the native path too. +""" + +import asyncio +import os +import sys + +import atheris + +with atheris.instrument_imports(): + from _input import Input + + import webrtc + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) +from tests.helpers import connect # noqa: E402 + +EXPECTED = (TypeError, ValueError, BufferError, webrtc.NotSupportedError, webrtc.InvalidStateError) +PIXEL_FORMATS = list(webrtc.VideoPixelFormat) +SAMPLE_FORMATS = list(webrtc.AudioSampleFormat) +SAMPLE_BYTES = {'u8': 1, 's16': 2, 's32': 4, 'f32': 4} +RATES = [1, 100, 1499, 1500, 3000, 7999, 8000, 11025, 16000, 22050, 44100, 48000, 96000, 192000, 384000, 384001] + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) + + +class Session: + """A connected pair sending a generator of each kind, whose writers are replaced once they fail""" + + async def start(self) -> None: + self.caller, self.callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + self.processors = [] + self.callee.on('track', lambda event: self.processors.append(webrtc.MediaStreamTrackProcessor(event.track))) + self.senders, self.writers = {}, {} + for kind in ('audio', 'video'): + generator = webrtc.MediaStreamTrackGenerator(kind) + self.senders[kind] = self.caller.add_track(generator) + self.writers[kind] = generator.writable.get_writer() + await connect(self.caller, self.callee) + + async def write(self, kind: str, chunk) -> None: + try: + await self.writers[kind].write(chunk) + except EXPECTED: + # an errored stream fails every write after: send a new generator + generator = webrtc.MediaStreamTrackGenerator(kind) + await self.senders[kind].replace_track(generator) + self.writers[kind] = generator.writable.get_writer() + # lets the media threads and the processors run + await asyncio.sleep(0) + + +def audio_data(inp: Input): + format = inp.choice(SAMPLE_FORMATS) + channels = inp.small(20) if inp.flag() else inp.integer(20) + rate = inp.choice(RATES) if inp.flag() else inp.number(400000) + frames = inp.small(8000) if inp.flag() else inp.integer(8000) + if not all(isinstance(v, int) and not isinstance(v, bool) and 0 < v for v in (channels, frames)): + channels, frames = 1, 480 + size = min(frames * channels * SAMPLE_BYTES[format.value.split('-')[0]], 1 << 20) + return webrtc.AudioData( + format=format, + sample_rate=rate, + number_of_frames=frames, + number_of_channels=channels, + timestamp=inp.integer(), + data=bytes(size), + ) + + +def video_frame(inp: Input): + format = inp.choice(PIXEL_FORMATS) + width = inp.small(64) + 1 if inp.flag() else inp.choice([1, 2, 3, 15, 16, 17, 639, 640, 1920, 4096]) + height = inp.small(64) + 1 if inp.flag() else inp.choice([1, 2, 3, 15, 16, 17, 479, 480, 1080, 4096]) + init = {'format': format, 'coded_width': width, 'coded_height': height, 'timestamp': inp.integer()} + if inp.flag(): + init['rotation'] = inp.choice([0, 90, 180, 270]) + # enough for every format: 4 planes of 16-bit samples at most + return webrtc.VideoFrame(inp.buffer(width * height * 8), init) + + +session = Session() +loop.run_until_complete(session.start()) + + +def test_one_input(data: bytes) -> None: + inp = Input(data) + for _ in range(inp.small(3) + 1): + kind = 'audio' if inp.flag() else 'video' + try: + chunk = audio_data(inp) if kind == 'audio' else video_frame(inp) + except EXPECTED: + continue + loop.run_until_complete(session.write(kind, chunk)) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/fuzz/fuzz_native_buffers.py b/tests/fuzz/fuzz_native_buffers.py new file mode 100644 index 0000000..bcc1b23 --- /dev/null +++ b/tests/fuzz/fuzz_native_buffers.py @@ -0,0 +1,93 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Fuzzes the native buffer functions behind VideoFrame and AudioData directly, without the checks of Python. + +The native module must stay memory safe on its own: it may raise, but never read or write out of a buffer. +""" + +import sys + +import atheris + +with atheris.instrument_imports(): + from _input import Input + +from webrtc import wrtc + +PIXEL_FORMATS = [ + 'I420', 'I420P10', 'I420P12', 'I420A', 'I420AP10', 'I420AP12', 'I422', 'I422P10', 'I422P12', 'I422A', 'I422AP10', + 'I422AP12', 'I444', 'I444P10', 'I444P12', 'I444A', 'I444AP10', 'I444AP12', 'NV12', 'RGBA', 'RGBX', 'BGRA', 'BGRX', + 'NV21', '', +] # fmt: skip +SAMPLE_FORMATS = ['u8', 's16', 's32', 'f32', 'u8-planar', 's16-planar', 's32-planar', 'f32-planar', 'f64', ''] +MATRICES = ['', 'rgb', 'bt709', 'bt470bg', 'smpte170m', 'bt2020-ncl', 'unknown'] +# pybind11 raises TypeError for arguments it can't convert (negative or too large for size_t, overflowing int) +EXPECTED = (TypeError, ValueError, RuntimeError, BufferError) + + +def frame(inp: Input): + width, height = inp.unsigned(40), inp.unsigned(40) + layout = [(inp.unsigned(1 << 12), inp.unsigned(256)) for _ in range(inp.small(4))] + return wrtc.VideoFrameBuffer.fromData( + inp.choice(PIXEL_FORMATS), width, height, inp.buffer(inp.small(1 << 15)), layout + ) + + +def video(inp: Input) -> None: + buffer = frame(inp) + for _ in range(inp.small(4)): + action = inp.small(2) + if action == 0: + copies = [tuple(inp.unsigned(256) for _ in range(6)) for _ in range(inp.small(4))] + buffer.copyPlanes(inp.destination(inp.small(1 << 15)), copies) + elif action == 1: + buffer.convertTo( + inp.destination(inp.small(1 << 15)), + inp.choice(PIXEL_FORMATS), + inp.unsigned(40), + inp.unsigned(40), + inp.unsigned(40), + inp.unsigned(40), + inp.unsigned(1 << 12), + inp.unsigned(256), + inp.choice(MATRICES), + inp.flag(), + ) + else: + buffer = buffer.withoutAlpha() + + +def audio(inp: Input) -> None: + wrtc.copyAudioSamples( + inp.buffer(inp.small(1 << 14)), + inp.choice(SAMPLE_FORMATS), + inp.unsigned(8), + inp.unsigned(1024), + inp.destination(inp.small(1 << 14)), + inp.choice(SAMPLE_FORMATS), + inp.unsigned(8), + inp.unsigned(1024), + inp.unsigned(1024), + ) + + +def test_one_input(data: bytes) -> None: + inp = Input(data) + try: + video(inp) if inp.flag() else audio(inp) + except EXPECTED: + pass + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/fuzz/fuzz_video_frame.py b/tests/fuzz/fuzz_video_frame.py new file mode 100644 index 0000000..cd81929 --- /dev/null +++ b/tests/fuzz/fuzz_video_frame.py @@ -0,0 +1,111 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Fuzzes VideoFrame: creation from a buffer, copy_to with conversions and layouts, and frames of frames.""" + +import sys + +import atheris + +with atheris.instrument_imports(): + from _input import Input + + import webrtc + +FORMATS = list(webrtc.VideoPixelFormat) +# what the specification lets these raise, and BufferError for read-only buffers; anything else is a bug +EXPECTED = (TypeError, ValueError, BufferError, webrtc.NotSupportedError, webrtc.InvalidStateError) + + +def rect(inp: Input): + return {'x': inp.number(), 'y': inp.number(), 'width': inp.number(), 'height': inp.number()} + + +def layout(inp: Input): + return [{'offset': inp.integer(4096), 'stride': inp.integer(256)} for _ in range(inp.small(4))] + + +def copy_options(inp: Input): + options = {} + if inp.flag(): + options['rect'] = rect(inp) + if inp.flag(): + options['layout'] = layout(inp) + if inp.flag(): + options['format'] = inp.choice(FORMATS) + return options + + +def exercise(inp: Input, frame: webrtc.VideoFrame) -> None: + for _ in range(inp.small(4)): + action = inp.small(5) + if action == 0: + options = copy_options(inp) + size = frame.allocation_size(options) + frame._copy_to(inp.destination(min(size, 1 << 20)), options) + elif action == 1: + init = {'visible_rect': rect(inp)} if inp.flag() else {} + if inp.flag(): + init['alpha'] = inp.choice(['keep', 'discard']) + if inp.flag(): + init['rotation'] = inp.number(360) + init['flip'] = inp.flag() + if inp.flag(): + init['display_width'], init['display_height'] = inp.integer(), inp.integer() + frame = webrtc.VideoFrame(frame, init) + elif action == 2: + frame = frame.clone() + elif action == 3: + frame.close() + else: + frame.metadata() + + +def check_identity(inp: Input, format, width: int, height: int, data) -> None: + """A packed frame copied out as it is gives the same bytes""" + init = {'format': format, 'coded_width': width, 'coded_height': height, 'timestamp': 0} + size = webrtc.VideoFrame(bytes(1 << 16), init).allocation_size() if width * height <= 1024 else 0 + if not size: + return + data = bytes(data)[:size] + bytes(max(0, size - len(data))) + with webrtc.VideoFrame(data, init) as frame: + out = bytearray(size) + frame._copy_to(out, None) + assert bytes(out) == data, f'{format} {width}x{height} copied out differently' + + +def test_one_input(data: bytes) -> None: + inp = Input(data) + format = inp.choice(FORMATS) + if inp.flag(): + width, height = inp.small(32) + 1, inp.small(32) + 1 + check_identity(inp, format, width, height, inp.buffer(width * height * 8)) + return + width, height = inp.integer(), inp.integer() + init = {'format': format, 'coded_width': width, 'coded_height': height, 'timestamp': inp.integer()} + if inp.flag(): + init['layout'] = layout(inp) + if inp.flag(): + init['visible_rect'] = rect(inp) + if inp.flag(): + init['rotation'] = inp.number(360) + if inp.flag(): + init['display_width'], init['display_height'] = inp.integer(), inp.integer() + try: + frame = webrtc.VideoFrame(inp.buffer(inp.small(1 << 15)), init) + exercise(inp, frame) + except EXPECTED: + pass + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == '__main__': + main() diff --git a/tests/test_audio_data.py b/tests/test_audio_data.py index f71077f..8d6ef05 100644 --- a/tests/test_audio_data.py +++ b/tests/test_audio_data.py @@ -162,6 +162,18 @@ def test_sample_conversions(source, destination): audio.close() +@pytest.mark.parametrize('destination', ['u8', 's16', 's32']) +def test_non_finite_f32_samples_convert(destination): + """NaN is silence and infinities are the extremes; converting NaN was undefined behavior (found by fuzzing)""" + values = [float('nan'), float('inf'), float('-inf')] + audio = audio_data(format='f32', channels=1, frames=3, data=array.array('f', values).tobytes()) + silence, maximum, minimum = VALUES[destination][0][3], VALUES[destination][0][1], VALUES[destination][0][0] + out = array.array(VALUES[destination][1], [1] * 3) + audio.copy_to(memoryview(out).cast('B'), {'plane_index': 0, 'format': destination}) + assert out.tolist() == [silence, maximum, minimum] + audio.close() + + def test_s16_bytes_are_little_endian(): """s16 samples are little endian, scaled by 1/32768 to f32""" audio = audio_data(format='s16', channels=1, frames=2, data=struct.pack('<2h', 1, -1)) diff --git a/tests/test_video_frame.py b/tests/test_video_frame.py index 9d43457..ac0a992 100644 --- a/tests/test_video_frame.py +++ b/tests/test_video_frame.py @@ -8,6 +8,7 @@ """VideoFrame of WebCodecs: construction, copies, conversions and lifetime.""" import gc +import math import struct import pytest @@ -232,6 +233,16 @@ def test_rotation_and_flip(): f.close() +@pytest.mark.parametrize('rotation', [math.inf, -math.inf, math.nan]) +def test_rotation_must_be_finite(rotation): + """A rotation is a WebIDL double: non-finite values raise TypeError, they raised OverflowError (found by fuzzing)""" + with pytest.raises(TypeError): + webrtc.VideoFrame(bytes(32), format='RGBX', coded_width=4, coded_height=2, timestamp=0, rotation=rotation) + with webrtc.VideoFrame(bytes(32), format='RGBX', coded_width=4, coded_height=2, timestamp=0) as frame: + with pytest.raises(TypeError): + webrtc.VideoFrame(frame, rotation=rotation) + + @pytest.mark.asyncio async def test_close_and_clone(): """A closed frame has no pixels, a clone is closed separately"""