diff --git a/.evergreen/generated_configs/functions.yml b/.evergreen/generated_configs/functions.yml index df7393de4c..cca9166af5 100644 --- a/.evergreen/generated_configs/functions.yml +++ b/.evergreen/generated_configs/functions.yml @@ -155,6 +155,7 @@ functions: - IS_WIN32 - REQUIRE_FIPS - TEST_MIN_DEPS + - FUZZ_MAX_TIME type: test - command: subprocess.exec params: diff --git a/.evergreen/generated_configs/tasks.yml b/.evergreen/generated_configs/tasks.yml index aeb5782901..87e8170992 100644 --- a/.evergreen/generated_configs/tasks.yml +++ b/.evergreen/generated_configs/tasks.yml @@ -168,6 +168,24 @@ tasks: depends_on: [{ name: .server-version, variant: .coverage_tag, status: "*", patch_optional: true }] tags: [coverage, pr] + # Fuzz tests + - name: fuzz-regression + commands: + - func: run tests + vars: + TEST_NAME: fuzz + SUB_TEST_NAME: regression + UV_PYTHON: "3.13" + tags: [fuzz, fuzz-regression, pr] + - name: fuzz + commands: + - func: run tests + vars: + TEST_NAME: fuzz + SUB_TEST_NAME: fuzz + UV_PYTHON: "3.13" + tags: [fuzz, fuzz-nightly] + # Getdata tests - name: getdata commands: diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 39cfcfe6d6..066cffdd07 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -318,6 +318,22 @@ buildvariants: TEST_NAME: enterprise_auth AUTH: auth + # Fuzz tests + - name: fuzz-regression-rhel8 + tasks: + - name: .fuzz-regression + display_name: Fuzz Regression RHEL8 + run_on: + - rhel8.10-small + tags: [pr] + - name: fuzz-rhel8 + tasks: + - name: .fuzz-nightly + display_name: Fuzz RHEL8 + run_on: + - rhel8.10-small + batchtime: 1440 + # Green framework tests - name: green-gevent-rhel8 tasks: diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index c7c7cd3be5..9bdc38c1cd 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -507,6 +507,17 @@ def create_doctests_variants(): ] +def create_fuzz_variants(): + host = HOSTS["rhel8"] + # Regression test on every PR. + name = get_variant_name("Fuzz Regression", host) + pr_variant = create_variant([".fuzz-regression"], name, host=host, tags=["pr"]) + # Fuzzing requires CPython on Linux/macOS; use a pinned CPython on RHEL8. + name = get_variant_name("Fuzz", host) + nightly = create_variant([".fuzz-nightly"], name, host=host, batchtime=BATCHTIME_DAY) + return [pr_variant, nightly] + + def create_atlas_connect_variants(): host = DEFAULT_HOST return [ @@ -891,6 +902,21 @@ def create_no_orchestration_tasks(): return tasks +def create_fuzz_tasks(): + # Deterministic regression test over the committed known-bug reproducers. + tags = ["fuzz", "fuzz-regression", "pr"] + test_vars = dict(TEST_NAME="fuzz", SUB_TEST_NAME="regression", UV_PYTHON="3.13") + test_func = FunctionCall(func="run tests", vars=test_vars) + tasks = [EvgTask(name="fuzz-regression", tags=tags, commands=[test_func])] + + # Time-boxed fuzzing against the generated seed corpus. + tags = ["fuzz", "fuzz-nightly"] + test_vars = dict(TEST_NAME="fuzz", SUB_TEST_NAME="fuzz", UV_PYTHON="3.13") + test_func = FunctionCall(func="run tests", vars=test_vars) + tasks.append(EvgTask(name="fuzz", tags=tags, commands=[test_func])) + return tasks + + def create_kms_tasks(): tasks = [] for kms_type in ["gcp", "azure"]: @@ -1343,6 +1369,7 @@ def create_run_tests_func(): "IS_WIN32", "REQUIRE_FIPS", "TEST_MIN_DEPS", + "FUZZ_MAX_TIME", ] args = [".evergreen/just.sh", "setup-tests", "${TEST_NAME}", "${SUB_TEST_NAME}"] setup_cmd = get_subprocess_exec(include_expansions_in_env=includes, args=args) diff --git a/.evergreen/scripts/run_tests.py b/.evergreen/scripts/run_tests.py index 7193cd9146..e50a266440 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -134,6 +134,40 @@ def handle_aws_lambda() -> None: run_command(f"bash {DRIVERS_TOOLS}/.evergreen/aws_lambda/{script_name}", env=env) +def handle_fuzz() -> None: + """Run the BSON fuzz targets (PYTHON-4232). Not pytest-based.""" + targets = ["fuzz_decode", "fuzz_decode_all", "fuzz_is_valid"] + + if SUB_TEST_NAME == "regression": + # Skip dotfiles (pathlib globbing matches them). + inputs = sorted( + str(p) for p in (ROOT / "fuzz" / "regression").glob("*") if not p.name.startswith(".") + ) + if not inputs: + raise RuntimeError("No fuzz regression inputs found in fuzz/regression/") + # Each reproducer is passed as its own file argument, so libFuzzer + # runs each exactly once; the -atheris_runs budget equals the file + # count so atheris exits cleanly after the last one. + runs = len(inputs) + for target in targets: + run_command( + f"{sys.executable} fuzz/{target}.py {' '.join(inputs)} -atheris_runs={runs}" + ) + return + + # Time-boxed fuzzing against the generated seed corpus. + corpus_dir = os.environ.get("FUZZ_CORPUS_DIR", str(ROOT / "fuzz-scratch" / "corpus")) + # Keep libFuzzer artifacts (crash-*, leak-*, ...) under the scratch dir. + artifact_prefix = f"{ROOT}/fuzz-scratch/" + max_time = os.environ.get("FUZZ_MAX_TIME", "300") + for target in targets: + run_command( + f"{sys.executable} fuzz/{target}.py {corpus_dir} " + f"-artifact_prefix={artifact_prefix} " + f"-max_total_time={max_time} -max_len=1048576 -print_final_stats=1" + ) + + def run() -> None: # Add diagnostic for python version. print("Running with python", sys.version) @@ -193,6 +227,10 @@ def run() -> None: handle_aws_lambda() return + if TEST_NAME == "fuzz": + handle_fuzz() + return + if os.environ.get("DEBUG_LOG"): TEST_ARGS.extend(f"-o log_cli_level={logging.DEBUG}".split()) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 28e0666eaf..dbf87d28d5 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -5,6 +5,8 @@ import platform import shutil import stat +import sys +import zipfile from pathlib import Path from urllib import request @@ -36,6 +38,7 @@ "UV_PYTHON_PREFERENCE", "REQUIRE_FIPS", "IS_WIN32", + "FUZZ_MAX_TIME", ] # Map the test name to test extra. @@ -51,7 +54,7 @@ # Map the test name to test group. -GROUP_MAP = dict(mockupdb="mockupdb", perf="perf") +GROUP_MAP = dict(fuzz="fuzz", mockupdb="mockupdb", perf="perf") # The libmongocrypt release used when LIBMONGOCRYPT_URL is not set. Must be at # least 1.20.0 for the GA "substring" query type. @@ -527,6 +530,21 @@ def handle_test_env() -> None: else: TEST_ARGS = f"test/performance/async_perf_test.py {TEST_ARGS}" + if test_name == "fuzz" and sub_test_name != "regression": + # Build the seed corpus from the vendored spec data. + scratch_dir = ROOT / "fuzz-scratch" + shutil.rmtree(scratch_dir, ignore_errors=True) + corpus_dir = scratch_dir / "corpus" + corpus_dir.mkdir(parents=True) + zip_path = scratch_dir / "corpus-seed.zip" + run_command( + f"{sys.executable} fuzz/build_corpus.py {zip_path} --data-dir {ROOT / 'test' / 'bson_corpus'}" + ) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(corpus_dir) + zip_path.unlink() + write_env("FUZZ_CORPUS_DIR", str(corpus_dir)) + # Add coverage if requested. if opts.cov: # Keep in sync with combine-coverage.sh. diff --git a/.evergreen/scripts/utils.py b/.evergreen/scripts/utils.py index fb69539f16..5b7da0f5a3 100644 --- a/.evergreen/scripts/utils.py +++ b/.evergreen/scripts/utils.py @@ -67,6 +67,7 @@ class Distro: "ecs-remote", ], "kms": ["azure", "azure-remote", "azure-fail", "gcp", "gcp-remote", "gcp-fail"], + "fuzz": ["regression", "fuzz"], "perf": ["sync", "async"], "auth_oidc": [ "default", @@ -83,7 +84,7 @@ class Distro: ], } -EXTRA_TESTS = ["aws_lambda", "doctest"] +EXTRA_TESTS = ["aws_lambda", "doctest", "fuzz"] # The managed Python version used for perf tests (PYTHON-6135). PERF_PYTHON_VERSION = "3.11.9" @@ -93,6 +94,7 @@ class Distro: "auth_oidc", "atlas_connect", "aws_lambda", + "fuzz", "mockupdb", "ocsp", "sfp", diff --git a/.gitignore b/.gitignore index 63813daf02..c113d3ec21 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ error_log # AI-assistant review workflow logs (kept untracked) /REVIEW_STATE.md /REVIEW.md + +# Fuzzing (PYTHON-4232) +/fuzz-scratch/ diff --git a/AGENTS.md b/AGENTS.md index ac1d33ec59..43a2a11d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ Official Python driver for MongoDB. - `test/asynchronous/` — async test suite - `test/` — sync test suite (generated from `test/asynchronous/` by `just synchro`) and shared test suite - `test/unified_format/` — cross-driver spec tests (Unified Test Format) +- `fuzz/` — BSON fuzz tests (not pytest; see `fuzz/README.md`) Do not edit files in `pymongo/synchronous/` or mirrored files in `test/` directly: they are generated by `just synchro` from `pymongo/asynchronous/` and `test/asynchronous/`. A file in `test/` is mirrored if a file of the same name exists in `test/asynchronous/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1b6a12605..84202cf9b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -445,6 +445,19 @@ git config submodule.drivers-evergreen-tools.update none To run any of the test suites with minimum supported dependencies, pass `--test-min-deps` to `just setup-tests`. +## Fuzz tests + +The `fuzz/` directory holds coverage-guided fuzz tests for `bson` +(PYTHON-4232). See [fuzz/README.md](fuzz/README.md) for details. The fuzz +tests can only be run on Linux x86_64, the only platform with atheris +wheels (CPython 3.12-3.14). + +- Regression test (runs on every PR): + `just setup-tests fuzz regression` then `just run-tests`. +- Time-boxed fuzzing (runs daily on Evergreen): `just setup-tests fuzz fuzz` + then `just run-tests`. Set `FUZZ_MAX_TIME` (seconds, default 300) to change + the per-target budget. + ## Testing time-dependent operations - `test.utils_shared.delay` - One can trigger an arbitrarily long-running operation on the server using this delay utility diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000000..ab05d6096f --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,118 @@ +# BSON fuzz tests + +Coverage-guided fuzz tests for the `bson` package, built on +[atheris](https://github.com/google/atheris) (PYTHON-4232). + +"Coverage-guided" means the fuzzer records which code paths each input +reaches and steers mutations toward untested code; it does not enumerate +the API surface. + +Each target runs the C extension and the pure-Python implementation and +compares their results when both are present. + +## Fuzz targets + +| File | What it tests | +|---|---| +| `fuzz_decode.py` | round-trip `bson.decode()` + `encode()` | +| `fuzz_decode_all.py` | `bson.decode_all()` + per-document `encode()` | +| `fuzz_is_valid.py` | `bson.is_valid()` and the invariant that `is_valid() == True` implies `decode()` succeeds | + +## Running + +Atheris is a `uv` dependency group. Run targets from the repository root: + +```bash +uv run --group fuzz python fuzz/fuzz_decode.py fuzz/regression/* +``` + +Atheris publishes CPython 3.12 to 3.14 wheels for Linux x86_64 only, so the +`fuzz` group cannot install on other interpreters or platforms. Pin a +compatible interpreter where one is available, and run in a Linux x86_64 +container otherwise: + +```bash +uv run --group fuzz --python 3.13 python fuzz/fuzz_decode.py fuzz/regression/* +``` + +The Evergreen fuzz tasks pin Linux CPython 3.13 and are unaffected. + +### Regression inputs (deterministic) + +`regression/` holds reproducers for known bugs. Running the targets +over them runs on Evergreen as the `fuzz-regression` sub-test on every PR: + +```bash +just setup-tests fuzz regression +just run-tests +``` + +Or without the justfile: + +```bash +uv run --group fuzz python fuzz/fuzz_decode.py fuzz/regression/* -atheris_runs=3 +uv run --group fuzz python fuzz/fuzz_decode_all.py fuzz/regression/* -atheris_runs=3 +uv run --group fuzz python fuzz/fuzz_is_valid.py fuzz/regression/* -atheris_runs=3 +``` + +When a target finds a bug, create a new ticket, add the reproducer as a new +file in `fuzz/regression/`, and fix the bug in the same PR. + +### Seed corpus (discovery fuzzing) + +The seed corpus is generated at build time from the vendored BSON spec corpus +(`../test/bson_corpus`), mirroring the Go driver's build-oss-fuzz-corpus: + +```bash +uv run python fuzz/build_corpus.py /tmp/fuzz-seed.zip --data-dir test/bson_corpus +uv run python -m zipfile -e /tmp/fuzz-seed.zip /tmp/fuzz-corpus +``` + +The builder also synthesizes seeds the spec corpus lacks: boundary-value +documents and a ~500 KB stress document. Without a large seed, libFuzzer's +default mutation cap of 4096 bytes would keep the nightly away from +long-string and many-field code paths. + +Run the targets against the corpus. Time-boxed runs suit CI; this runs on +Evergreen as the `fuzz` sub-test, daily: + +```bash +just setup-tests fuzz fuzz +FUZZ_MAX_TIME=300 just run-tests +``` + +Or without the justfile: + +```bash +uv run --group fuzz python fuzz/fuzz_decode.py /tmp/fuzz-corpus -max_total_time=60 +``` + +Pass any [libFuzzer flags](https://llvm.org/docs/LibFuzzer.html#options) +after the corpus directory. The `Failed to find function __sanitizer_*` +startup warnings are benign libFuzzer symbol probes on uninstrumented runs; +native ASan-instrumented fuzzing is tracked separately (PYTHON-6169). + +## Coverage report + +Measure which lines the seed corpus reaches: + +```bash +uv run --group fuzz --group coverage python -m coverage run fuzz/fuzz_decode.py /tmp/fuzz-corpus/* \ + -atheris_runs=$(( $(ls /tmp/fuzz-corpus | wc -l) )) +uv run --group coverage python -m coverage html +``` + +The run count equals the file count: replaying individual files (not a corpus +directory) performs no empty-input warmup, and letting the budget reach zero +gives atheris a graceful exit so coverage.py can flush its report. + +## Files + +``` +build_corpus.py Seed corpus builder (mirrors the Go driver's build-oss-fuzz-corpus) +fuzz_decode.py Fuzz target: bson.decode() +fuzz_decode_all.py Fuzz target: bson.decode_all() +fuzz_is_valid.py Fuzz target: bson.is_valid() +fuzz_shared.py Code shared by the fuzz targets +regression/ Known bug reproducers (no corpus setup required) +``` diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py new file mode 100755 index 0000000000..4d46140803 --- /dev/null +++ b/fuzz/build_corpus.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +OSS-Fuzz seed corpus builder for BSON fuzzing, mirroring the Go driver's +(mongodb/mongo-go-driver) internal/cmd/build-oss-fuzz-corpus/main.go. + +Converts the valid cases in the vendored BSON spec corpus (test/bson_corpus/ +by default) to raw BSON bytes and packages them into a ZIP, plus synthesized +boundary and large stress documents. + +Usage: python build_corpus.py bson_decode_seed_corpus.zip [--data-dir DIR] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import sys +import zipfile +from pathlib import Path +from typing import Any + +# Force the pure-Python implementation: an ASan-instrumented _cbson would +# abort on import. +sys.modules["bson._cbson"] = None # type: ignore[assignment] + +import bson +from bson import json_util +from bson.code import Code + +DEFAULT_DATA_DIR = "test/bson_corpus" + + +def extjson_to_bytes(extjson: str) -> bytes: + """Convert an Extended JSON string to bytes.""" + doc = json_util.loads(extjson) + return bson.encode(doc) + + +def hex_to_bytes(hex_str: str) -> bytes: + """Decode a hex string to bytes.""" + return bytes.fromhex(hex_str) + + +def add_to_corpus(zw: zipfile.ZipFile, bson_bytes: bytes) -> None: + """Add BSON bytes to the corpus ZIP, named by their SHA-256 digest.""" + filename = hashlib.sha256(bson_bytes).hexdigest() + if filename not in zw.namelist(): + zw.writestr(filename, bson_bytes) + + +def seed_extjson( + zw: zipfile.ZipFile, + extjson: str, + extjson_type: str, + description: str, +) -> None: + """Convert an Extended JSON string to BSON bytes and add it to the corpus.""" + try: + bson_bytes = extjson_to_bytes(extjson) + except Exception as exc: + print( + f"Warning: {description!r}: failed to convert {extjson_type} " + f"extended JSON to bytes: {exc}", + file=sys.stderr, + ) + return + add_to_corpus(zw, bson_bytes) + + +def seed_test_cases(zw: zipfile.ZipFile, test_cases: list[dict[str, Any]]) -> None: + """Seed the corpus from a list of valid BSON corpus test cases.""" + for tc in test_cases: + desc = tc.get("description", "") + + # Raw BSON bytes need no JSON round-trip. A valid case can carry + # several distinct wire representations; seed each one. + for field in ("canonical_bson", "degenerate_bson", "converted_bson"): + if field in tc and tc[field] is not None: + try: + add_to_corpus(zw, hex_to_bytes(tc[field])) + except ValueError as exc: + print( + f"Warning: {desc!r}: failed to decode {field} hex: {exc}", + file=sys.stderr, + ) + + # Extended JSON variants, converted through the encoder to canonical BSON. + for field, extjson_type in ( + ("canonical_extjson", "canonical"), + ("relaxed_extjson", "relaxed"), + ("degenerate_extjson", "degenerate"), + ("converted_extjson", "converted"), + ): + if field in tc and tc[field] is not None: + seed_extjson(zw, tc[field], extjson_type, desc) + + +def seed_bson_corpus(zw: zipfile.ZipFile, data_dir: str) -> None: + """Seed the corpus from every BSON corpus JSON file in data_dir.""" + json_files = sorted(Path(data_dir).glob("*.json")) + if not json_files: + print( + f"Error: no JSON files found in {data_dir!r}. " + "The BSON spec corpus is vendored in 'test/bson_corpus'.", + file=sys.stderr, + ) + sys.exit(1) + + for json_file in json_files: + try: + with open(json_file, encoding="utf-8") as fh: + corpus_doc = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + print(f"Warning: failed to parse {json_file}: {exc}", file=sys.stderr) + continue + + valid_cases = corpus_doc.get("valid", []) + seed_test_cases(zw, valid_cases) + + +def nest(doc: dict[str, Any], depth: int) -> dict[str, Any]: + """Wrap doc in depth levels of single-key subdocuments.""" + for _ in range(depth): + doc = {"n": doc} + return doc + + +def seed_boundary_documents(zw: zipfile.ZipFile) -> None: + """Seed documents at the int32 declared-length and minimum-size boundaries.""" + lies: tuple[tuple[str, bytes], ...] = ( + # Declares ~2 GB, body is 8 bytes. + ("declared_length_int32max", struct.pack(" None: + """Seed one ~500 KB document; without it, libFuzzer's max_len default + keeps mutation below the size where large-input paths live.""" + doc = { + "long_string": "a" * (256 * 1024), + "many_fields": {f"f{i:05d}": i for i in range(5_000)}, + "large_array": list(range(10_000)), + "large_subdoc": {f"nested_{i:03d}": {"s": "x" * 32} for i in range(500)}, + "code_w_scope": Code("function() {}", {"scope_string": "y" * 32768}), + "nested_depth_100": nest({"leaf": True}, 100), + } + add_to_corpus(zw, bson.encode(doc)) + print(" seeded large stress document") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Build an OSS-Fuzz seed corpus ZIP for Python BSON fuzzing." + ) + parser.add_argument( + "output", + help="Output ZIP file path, e.g. bson_decode_seed_corpus.zip", + ) + parser.add_argument( + "--data-dir", + default=DEFAULT_DATA_DIR, + help=f"Directory containing BSON corpus JSON files (default: {DEFAULT_DATA_DIR})", + ) + args = parser.parse_args() + + output_path = Path(args.output) + if output_path.suffix != ".zip": + print( + f"Error: output file must have a .zip extension, got {args.output!r}", + file=sys.stderr, + ) + sys.exit(1) + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zw: + seed_bson_corpus(zw, args.data_dir) + print("Synthesized seeds:") + seed_boundary_documents(zw) + seed_large_document(zw) + entry_count = len(zw.namelist()) + + print(f"Wrote {entry_count} corpus entries to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/fuzz/fuzz_decode.py b/fuzz/fuzz_decode.py new file mode 100755 index 0000000000..b323c97092 --- /dev/null +++ b/fuzz/fuzz_decode.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Fuzz target for bson.decode(). + +Runs the C extension and pure-Python implementations on arbitrary bytes and +compares their results when both are present. +""" + +from __future__ import annotations + +from typing import Optional + +import atheris +from fuzz_shared import OTHER_ERRORS, load_pure_python_bson, main, normalize + +with atheris.instrument_imports(): + # Load both implementations while import instrumentation is active so the + # pure-Python decoder gets coverage feedback; the C extension is a native + # module and is skipped by atheris. + import bson as _bson_c # C extension when available + + _bson_pure = load_pure_python_bson() + +# Consistency checks apply only when the C extension is present. +_BOTH_IMPLS = _bson_c.has_c() + + +def _decode_with(bson_mod, data: bytes) -> Optional[dict]: + """Run bson_mod.decode(data), returning the document or None on expected errors.""" + try: + return bson_mod.decode(data) + except (bson_mod.InvalidBSON, *OTHER_ERRORS): + return None + + +@atheris.instrument_func +def TestOneInput(data: bytes) -> None: + doc_c = _decode_with(_bson_c, data) + + # A successful decode must round-trip through encode(). + if doc_c is not None: + try: + _bson_c.encode(doc_c) + except Exception as exc: + raise AssertionError( + f"bson.encode() failed after bson.decode() succeeded: {exc!r}\n" + f"decoded document: {doc_c!r}" + ) from exc + + doc_pure = _decode_with(_bson_pure, data) + + if doc_pure is not None: + try: + _bson_pure.encode(doc_pure) + except Exception as exc: + raise AssertionError( + f"pure-Python bson.encode() failed after bson.decode() succeeded: {exc!r}\n" + f"decoded document: {doc_pure!r}" + ) from exc + + # Consistency check. + if _BOTH_IMPLS: + c_succeeded = doc_c is not None + pure_succeeded = doc_pure is not None + if c_succeeded != pure_succeeded: + raise AssertionError( + f"C extension and pure-Python bson.decode() disagree: " + f"C={'success' if c_succeeded else 'failure'}, " + f"pure={'success' if pure_succeeded else 'failure'} " + f"for input: {data!r}" + ) + if doc_c is not None and doc_pure is not None and normalize(doc_c) != normalize(doc_pure): + raise AssertionError( + f"C extension and pure-Python bson.decode() produced different " + f"documents: C={doc_c!r}, pure={doc_pure!r}, input: {data!r}" + ) + + +if __name__ == "__main__": + main(TestOneInput) diff --git a/fuzz/fuzz_decode_all.py b/fuzz/fuzz_decode_all.py new file mode 100755 index 0000000000..fa01549607 --- /dev/null +++ b/fuzz/fuzz_decode_all.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Fuzz target for bson.decode_all(). + +Runs both implementations on arbitrary bytes (including concatenated +documents) and compares their results when both are present. +""" + +from __future__ import annotations + +from typing import Optional + +import atheris +from fuzz_shared import OTHER_ERRORS, load_pure_python_bson, main, normalize + +with atheris.instrument_imports(): + # Load both implementations while import instrumentation is active so the + # pure-Python decoder gets coverage feedback; the C extension is a native + # module and is skipped by atheris. + import bson as _bson_c + + _bson_pure = load_pure_python_bson() + +# Consistency checks apply only when the C extension is present. +_BOTH_IMPLS = _bson_c.has_c() + + +def _decode_all_with(bson_mod, data: bytes) -> Optional[list]: + """Run bson_mod.decode_all(data), returning the documents or None on expected errors.""" + try: + return bson_mod.decode_all(data) + except (bson_mod.InvalidBSON, *OTHER_ERRORS): + return None + + +def _encode_all_with(bson_mod, docs) -> None: + """Re-encode every document to exercise the encoder as well.""" + for doc in docs: + try: + bson_mod.encode(doc) + except Exception as exc: + raise AssertionError( + f"bson.encode() failed after bson.decode_all() succeeded: {exc!r}\n" + f"decoded documents: {docs!r}" + ) from exc + + +@atheris.instrument_func +def TestOneInput(data: bytes) -> None: + docs_c = _decode_all_with(_bson_c, data) + if docs_c is not None: + _encode_all_with(_bson_c, docs_c) + + docs_pure = _decode_all_with(_bson_pure, data) + if docs_pure is not None: + _encode_all_with(_bson_pure, docs_pure) + + # Consistency checks. + if _BOTH_IMPLS: + c_succeeded = docs_c is not None + pure_succeeded = docs_pure is not None + + if c_succeeded != pure_succeeded: + raise AssertionError( + f"C extension and pure-Python bson.decode_all() disagree: " + f"C={'success' if c_succeeded else 'failure'}, " + f"pure={'success' if pure_succeeded else 'failure'} " + f"for input: {data!r}" + ) + + if docs_c is not None and docs_pure is not None and len(docs_c) != len(docs_pure): + raise AssertionError( + f"C extension decoded {len(docs_c)} document(s) but pure Python " + f"decoded {len(docs_pure)} document(s) for input: {data!r}" + ) + + if ( + docs_c is not None + and docs_pure is not None + and normalize(docs_c) != normalize(docs_pure) + ): + raise AssertionError( + f"C extension and pure-Python bson.decode_all() produced different " + f"documents for input: {data!r}" + ) + + +if __name__ == "__main__": + main(TestOneInput) diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py new file mode 100755 index 0000000000..5281c7e49e --- /dev/null +++ b/fuzz/fuzz_is_valid.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Fuzz target for bson.is_valid(). + +Checks that is_valid() returning True means decode() succeeds, and that +the C extension and pure Python agree on the result. +""" + +from __future__ import annotations + +import atheris +from fuzz_shared import load_pure_python_bson, main + +with atheris.instrument_imports(): + # Load both implementations while import instrumentation is active so the + # pure-Python parser gets coverage feedback; the C extension is a native + # module and is skipped by atheris. + import bson as _bson_c + + _bson_pure = load_pure_python_bson() + +# Consistency checks apply only when the C extension is present. +_BOTH_IMPLS = _bson_c.has_c() + + +@atheris.instrument_func +def TestOneInput(data: bytes) -> None: + valid_c = _bson_c.is_valid(data) + + if valid_c: + try: + _bson_c.decode(data) + except Exception as exc: + # is_valid() performs a full decode and swallows every exception, + # so a True result guarantees decode() raises none. + raise AssertionError( + f"bson.is_valid() returned True but bson.decode() raised " + f"{type(exc).__name__}: {exc!r}\ninput: {data!r}" + ) from exc + + valid_pure = _bson_pure.is_valid(data) + + if valid_pure: + try: + _bson_pure.decode(data) + except Exception as exc: + raise AssertionError( + f"pure-Python bson.is_valid() returned True but bson.decode() " + f"raised {type(exc).__name__}: {exc!r}\ninput: {data!r}" + ) from exc + + if _BOTH_IMPLS and valid_c != valid_pure: + raise AssertionError( + f"C extension and pure-Python bson.is_valid() disagree: " + f"C={valid_c}, pure={valid_pure}\ninput: {data!r}" + ) + + +if __name__ == "__main__": + main(TestOneInput) diff --git a/fuzz/fuzz_shared.py b/fuzz/fuzz_shared.py new file mode 100755 index 0000000000..30bc47e373 --- /dev/null +++ b/fuzz/fuzz_shared.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Code shared by the BSON fuzz targets.""" + +from __future__ import annotations + +import math +import os +import struct +import sys + +import atheris + + +def load_pure_python_bson(): + """Re-import bson with the C extension blocked, then restore sys.modules.""" + saved = { + k: sys.modules.pop(k) for k in list(sys.modules) if k == "bson" or k.startswith("bson.") + } + sys.modules["bson._cbson"] = None # type: ignore[assignment] + + try: + import bson as pure_bson + except Exception as exc: + raise RuntimeError("Could not load pure-Python bson for cross-checking") from exc + finally: + for k in list(sys.modules): + if k == "bson" or k.startswith("bson."): + del sys.modules[k] + sys.modules.update(saved) + + return pure_bson + + +# InvalidBSON is excluded: each bson module has its own class, caught as +# bson_mod.InvalidBSON. RecursionError is defensive: decode() rewraps +# all exceptions into InvalidBSON. +OTHER_ERRORS = ( + ValueError, + struct.error, + UnicodeDecodeError, + OverflowError, + KeyError, + RecursionError, +) + + +def normalize(obj: object) -> object: + """Comparable form of a decoded document. + + The two bson handles are separate module instances, so bson type classes + (ObjectId, Timestamp, ...) never compare equal across handles. Reduce + leaves to "type-name:repr"; NaN never compares equal to itself. + """ + if isinstance(obj, dict): + return [(k, normalize(v)) for k, v in obj.items()] + if isinstance(obj, (list, tuple)): + return [normalize(v) for v in obj] + if isinstance(obj, float) and math.isnan(obj): + return "nan" + return f"{type(obj).__name__}:{obj!r}" + + +def main(test_one_input) -> None: + """Set up atheris for a fuzz target and start fuzzing.""" + # FUZZ_NATIVE=1: libFuzzer is preloaded externally; skip atheris's own. + internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" + atheris.Setup(sys.argv, test_one_input, internal_libfuzzer=internal_libfuzzer) + atheris.Fuzz() diff --git a/fuzz/regression/decode__boolean_no_terminator b/fuzz/regression/decode__boolean_no_terminator new file mode 100644 index 0000000000..20519e16ef Binary files /dev/null and b/fuzz/regression/decode__boolean_no_terminator differ diff --git a/fuzz/regression/decode__regex_no_terminator b/fuzz/regression/decode__regex_no_terminator new file mode 100644 index 0000000000..ed46deaa2a Binary files /dev/null and b/fuzz/regression/decode__regex_no_terminator differ diff --git a/pyproject.toml b/pyproject.toml index dd97ab5d76..e7cf46c8ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,8 @@ coverage = [ mockupdb = [ "mockupdb>=1.8.1" ] +# Atheris publishes CPython 3.12-3.14 Linux x86_64 wheels only; see fuzz/README.md. +fuzz = ["atheris>=2.3.0"] perf = ["simplejson>=3.17.0"] typing = [ "mypy==1.19.1", @@ -266,6 +268,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|dummy.*)$" "UP031", "F401", "B023", "F811"] "tools/*.py" = ["T201"] "tools/test_*.py" = ["E402", "S"] +"fuzz/*.py" = ["T201", "E402"] "hatch_build.py" = ["S"] "_setup.py" = ["SIM112"] diff --git a/uv.lock b/uv.lock index 3476597fb9..823ead1470 100644 --- a/uv.lock +++ b/uv.lock @@ -40,6 +40,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "atheris" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/88/fd6ad595dafa9c7ce56dbfcaff0c7244988dac3af86c771166c6516ccf6b/atheris-3.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ec5e11f21a4c197fe91f7aea2b2de88e623c73a21fc07b105ac6329a1588457b", size = 36875908, upload-time = "2026-06-17T00:04:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/4e/18/e19718c384fd7d801d0da7485407daef9af6194b6d8c8818175bec5efec6/atheris-3.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8a9f51ce8369026e8eb7b7174835e8c4c85a1a6db5d9add36c15100779d2a39", size = 36800563, upload-time = "2026-06-17T00:04:04.559Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ff/ae7a5bfe99033e510bea4ed09934e636d93777317a48147369bc0dc2b71f/atheris-3.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:315a0b5c819852b1ffe1ca72efc389c7724881f2c33e4aacb8c6bcec49bd5011", size = 36772569, upload-time = "2026-06-17T00:04:07.702Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -1432,6 +1442,9 @@ zstd = [ coverage = [ { name = "coverage", extra = ["toml"] }, ] +fuzz = [ + { name = "atheris" }, +] gevent = [ { name = "gevent" }, ] @@ -1489,6 +1502,7 @@ provides-extras = ["aws", "docs", "encryption", "gssapi", "ocsp", "snappy", "tes [package.metadata.requires-dev] coverage = [{ name = "coverage", extras = ["toml"], specifier = ">=6.4,<=7.15.0" }] dev = [] +fuzz = [{ name = "atheris", specifier = ">=2.3.0" }] gevent = [{ name = "gevent", specifier = ">=21.12" }] lint = [{ name = "ruff", specifier = "==0.15.20" }] mockupdb = [{ name = "mockupdb", specifier = ">=1.8.1" }]