From e5a27f3efb1e49bea402de84bee6978c5ef1952a Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 05:20:13 -0500 Subject: [PATCH 01/11] PYTHON-4232 Add fuzzing tests Adds atheris-based fuzz targets for bson that run the C extension and pure-Python implementations on arbitrary input and compare their results. Known-bug reproducers in fuzz/regression/ run on every PR; a nightly Evergreen task runs time-boxed discovery fuzzing against a seed corpus built at setup time from the vendored BSON spec corpus. No driver source is changed. --- .evergreen/generated_configs/functions.yml | 1 + .evergreen/generated_configs/tasks.yml | 18 ++ .evergreen/generated_configs/variants.yml | 16 ++ .evergreen/scripts/generate_config.py | 27 +++ .evergreen/scripts/run_tests.py | 31 +++ .evergreen/scripts/setup_tests.py | 17 +- .evergreen/scripts/utils.py | 4 +- .gitignore | 9 + AGENTS.md | 1 + CONTRIBUTING.md | 12 + fuzz/README.md | 98 ++++++++ fuzz/build_corpus.py | 213 ++++++++++++++++++ fuzz/fuzz_decode.py | 134 +++++++++++ fuzz/fuzz_decode_all.py | 107 +++++++++ fuzz/fuzz_is_valid.py | 114 ++++++++++ fuzz/regression/decode__boolean_no_terminator | Bin 0 -> 8 bytes fuzz/regression/decode__regex_no_terminator | Bin 0 -> 8 bytes pyproject.toml | 2 + uv.lock | 14 ++ 19 files changed, 816 insertions(+), 2 deletions(-) create mode 100644 fuzz/README.md create mode 100755 fuzz/build_corpus.py create mode 100755 fuzz/fuzz_decode.py create mode 100755 fuzz/fuzz_decode_all.py create mode 100755 fuzz/fuzz_is_valid.py create mode 100644 fuzz/regression/decode__boolean_no_terminator create mode 100644 fuzz/regression/decode__regex_no_terminator 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..22c9ac03c4 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"]) + # Atheris is CPython-only and Linux/macOS-only; 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..b8fd55e04b 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -134,6 +134,33 @@ 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": + # Run every target over the committed known-bug reproducers. + # 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/") + runs = len(inputs) + for target in targets: + run_command(f"python 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-corpus")) + max_time = os.environ.get("FUZZ_MAX_TIME", "300") + for target in targets: + run_command( + f"python fuzz/{target}.py {corpus_dir} " + 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 +220,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..e346ea4e15 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -36,6 +36,7 @@ "UV_PYTHON_PREFERENCE", "REQUIRE_FIPS", "IS_WIN32", + "FUZZ_MAX_TIME", ] # Map the test name to test extra. @@ -51,7 +52,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 +528,20 @@ 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; nothing + # corpus-related is committed (PYTHON-4232). + corpus_dir = ROOT / "fuzz-corpus" + shutil.rmtree(corpus_dir, ignore_errors=True) + corpus_dir.mkdir() + zip_path = ROOT / "fuzz-corpus-seed.zip" + run_command( + f"python fuzz/build_corpus.py {zip_path} --data-dir {ROOT / 'test' / 'bson_corpus'}" + ) + run_command(f"python -m zipfile -e {zip_path} {corpus_dir}") + run_command(f"rm {zip_path}") + 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..515ffc74e0 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,12 @@ error_log # AI-assistant review workflow logs (kept untracked) /REVIEW_STATE.md /REVIEW.md + +# Fuzzing (PYTHON-4232) +/fuzz-corpus/ +/fuzz-corpus-seed.zip +/crash-* +/leak-* +/oom-* +/timeout-* +/bson-fuzz-tests/ diff --git a/AGENTS.md b/AGENTS.md index ac1d33ec59..08e1300194 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/` — atheris-based 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..5c749b12ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -445,6 +445,18 @@ 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`, built on +[atheris](https://github.com/google/atheris) (PYTHON-4232). See +`fuzz/README.md` for details. + +- 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..3e5b141f3e --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,98 @@ +# BSON fuzz tests + +Coverage-guided fuzz tests for the `bson` package, built on +[atheris](https://github.com/google/atheris) (PYTHON-4232). + +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` | `bson.decode()` + round-trip `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/* +``` + +### Regression inputs (deterministic) + +`regression/` holds committed 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 +``` + +## 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() +regression/ Known bug reproducers (committed; no corpus setup required) +``` diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py new file mode 100755 index 0000000000..2e634fd106 --- /dev/null +++ b/fuzz/build_corpus.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +OSS-Fuzz seed corpus builder for BSON fuzzing, mirroring the Go driver's +internal/cmd/build-oss-fuzz-corpus/main.go. + +Reads the vendored BSON spec corpus (test/bson_corpus/ by default), converts +each valid test case to raw BSON bytes, and packages them into a ZIP. Also +synthesizes seeds the spec corpus lacks: documents at the int32 +declared-length and minimum-size boundaries, and one large (~500 KB) stress +document. + +Usage: + # Build the seed corpus ZIP: + python build_corpus.py bson_decode_seed_corpus.zip + + # Use a custom data directory: + python build_corpus.py bson_decode_seed_corpus.zip --data-dir /path/to/bson-corpus +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import sys +import zipfile +from pathlib import Path +from typing import Any + +# Use the pure-Python bson implementation: JSON conversion needs no C +# extension, and 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_bson_bytes(extjson: str) -> bytes: + """Convert an Extended JSON string to canonical BSON bytes.""" + doc = json_util.loads(extjson) + return bson.encode(doc) + + +def canonical_hex_to_bson_bytes(hex_str: str) -> bytes: + """Decode a canonical_bson hex string to raw BSON 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() + # namelist() is O(n), fine at this corpus size. + 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_bson_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", "") + + # Canonical BSON bytes need no JSON round-trip. + if "canonical_bson" in tc: + try: + bson_bytes = canonical_hex_to_bson_bytes(tc["canonical_bson"]) + add_to_corpus(zw, bson_bytes) + except ValueError as exc: + print( + f"Warning: {desc!r}: failed to decode canonical_bson 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. + + A declared length that lies must fail with InvalidBSON, never read or + allocate the declared size. + """ + lies: tuple[tuple[str, bytes], ...] = ( + # Declares ~2 GB; body is 8 bytes. Must fail fast, not allocate. + ("declared_length_int32max", struct.pack(" None: + """ + Synthesize one large (~500 KB) stress document. + + Without a seed this size, libFuzzer's max_len default of max(4096, + largest seed) keeps mutation below the size where long-string and + many-field 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(zipfile.ZipFile(output_path).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..cb8a680ebe --- /dev/null +++ b/fuzz/fuzz_decode.py @@ -0,0 +1,134 @@ +#!/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. + +Usage (with atheris / OSS-Fuzz): + python fuzz_decode.py corpus/ +""" + +from __future__ import annotations + +import struct +import sys +from typing import Optional + +import atheris + +# Load both bson implementations before atheris instruments imports. +import bson as _bson_c # C extension when available + + +def _load_pure_python_bson(): + """ + Return a fresh import of the bson package with the C extension blocked. + + ``None`` for ``bson._cbson`` in sys.modules forces the pure-Python + fallback; the original modules are restored afterwards so ``_bson_c`` + remains valid. + """ + 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: + print(f"Warning: could not load pure-Python bson: {exc}", file=sys.stderr) + pure_bson = None + 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 + + +def _c_extension_available() -> bool: + """Return True if the bson C extension is actually loaded.""" + try: + import bson._cbson as cbson + + return cbson is not None + except ImportError: + return False + + +_bson_pure = _load_pure_python_bson() + +# Consistency checks only apply when the two handles genuinely differ. +_BOTH_IMPLS = _bson_pure is not None and _c_extension_available() + +with atheris.instrument_imports(): + pass # bson is already imported above; nothing further to instrument + + +# InvalidBSON is excluded here: each bson module instance has its own class, +# so _decode_with catches bson_mod.InvalidBSON rather than a shared tuple. +_OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) + + +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: + # C extension (or the pure-Python fallback). + 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 + + # Pure Python. + if _bson_pure is not None: + 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}" + ) + + +def main() -> None: + import os + + # FUZZ_NATIVE=1: libFuzzer is preloaded externally; skip atheris's own. + internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" + atheris.Setup(sys.argv, TestOneInput, internal_libfuzzer=internal_libfuzzer) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/fuzz/fuzz_decode_all.py b/fuzz/fuzz_decode_all.py new file mode 100755 index 0000000000..129c016b4a --- /dev/null +++ b/fuzz/fuzz_decode_all.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Fuzz target for bson.decode_all(). + +Runs the multi-document decoder on arbitrary bytes (including concatenated +BSON documents) under both the C extension and pure Python implementations, +and compares their results when both are present. + +Usage (with atheris / OSS-Fuzz): + python fuzz_decode_all.py corpus/ +""" + +from __future__ import annotations + +import struct +import sys +from typing import Optional + +import atheris + +# Load both bson implementations before atheris instruments imports. +import bson as _bson_c + + +def _load_pure_python_bson(): + 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: + print(f"Warning: could not load pure-Python bson: {exc}", file=sys.stderr) + pure_bson = None + 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 + + +def _c_extension_available() -> bool: + try: + import bson._cbson as cbson + + return cbson is not None + except ImportError: + return False + + +_bson_pure = _load_pure_python_bson() +_BOTH_IMPLS = _bson_pure is not None and _c_extension_available() + +with atheris.instrument_imports(): + pass # bson is already imported above; nothing further to instrument + +_OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) + + +def _decode_all_with(bson_mod, data: bytes) -> Optional[list]: + try: + docs = bson_mod.decode_all(data) + # Re-encode every document to exercise the encoder as well. + for doc in docs: + bson_mod.encode(doc) + return docs + except (bson_mod.InvalidBSON, *_OTHER_ERRORS): + return None + + +@atheris.instrument_func +def TestOneInput(data: bytes) -> None: + docs_c = _decode_all_with(_bson_c, data) + + if _bson_pure is not None: + docs_pure = _decode_all_with(_bson_pure, data) + + 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 c_succeeded and pure_succeeded 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}" + ) + + +def main() -> None: + import os + + internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" + atheris.Setup(sys.argv, TestOneInput, internal_libfuzzer=internal_libfuzzer) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py new file mode 100755 index 0000000000..4dc07751ab --- /dev/null +++ b/fuzz/fuzz_is_valid.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Fuzz target for bson.is_valid(). + +Two invariants are checked: + 1. If is_valid() returns True, decode() must not raise InvalidBSON. + 2. The C extension and pure Python agree on the is_valid() result. + +Usage (with atheris / OSS-Fuzz): + python fuzz_is_valid.py corpus/ +""" + +from __future__ import annotations + +import struct +import sys + +import atheris + +# Load both bson implementations before atheris instruments imports. +import bson as _bson_c + + +def _load_pure_python_bson(): + 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: + print(f"Warning: could not load pure-Python bson: {exc}", file=sys.stderr) + pure_bson = None + 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 + + +def _c_extension_available() -> bool: + try: + import bson._cbson as cbson + + return cbson is not None + except ImportError: + return False + + +_bson_pure = _load_pure_python_bson() +_BOTH_IMPLS = _bson_pure is not None and _c_extension_available() + +with atheris.instrument_imports(): + import bson + +_EXPECTED_DECODE_ERRORS = ( + bson.InvalidBSON, + ValueError, + struct.error, + UnicodeDecodeError, + OverflowError, + KeyError, +) + + +@atheris.instrument_func +def TestOneInput(data: bytes) -> None: + valid_c = _bson_c.is_valid(data) + + if valid_c: + try: + _bson_c.decode(data) + except bson.InvalidBSON as exc: + raise AssertionError( + f"bson.is_valid() returned True but bson.decode() raised " + f"InvalidBSON: {exc!r}\ninput: {data!r}" + ) from exc + except _EXPECTED_DECODE_ERRORS: + # is_valid() only guarantees structural validity; other decode + # errors are not invariant violations. + pass + + if _bson_pure is not None: + valid_pure = _bson_pure.is_valid(data) + + if valid_pure: + try: + _bson_pure.decode(data) + except _bson_pure.InvalidBSON as exc: + raise AssertionError( + f"pure-Python bson.is_valid() returned True but bson.decode() " + f"raised InvalidBSON: {exc!r}\ninput: {data!r}" + ) from exc + except _EXPECTED_DECODE_ERRORS: + pass + + 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}" + ) + + +def main() -> None: + import os + + internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" + atheris.Setup(sys.argv, TestOneInput, internal_libfuzzer=internal_libfuzzer) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/fuzz/regression/decode__boolean_no_terminator b/fuzz/regression/decode__boolean_no_terminator new file mode 100644 index 0000000000000000000000000000000000000000..20519e16efd476be95af14f681772f290e1a2f62 GIT binary patch literal 8 Pcmd;JU|`@#WMBXQ0gC`~ literal 0 HcmV?d00001 diff --git a/fuzz/regression/decode__regex_no_terminator b/fuzz/regression/decode__regex_no_terminator new file mode 100644 index 0000000000000000000000000000000000000000..ed46deaa2a8d062a584e85559e30e3e0a71ed21b GIT binary patch literal 8 Pcmd;JU|`@*WMBXQ0ha)E literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 56b932efdc..a8d489b3ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ coverage = [ mockupdb = [ "mockupdb>=1.8.1" ] +fuzz = ["atheris>=2.3.0"] perf = ["simplejson>=3.17.0"] typing = [ "mypy==1.19.1", @@ -260,6 +261,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 f6e2f67961..7ac0d1f7fa 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" @@ -1357,6 +1367,9 @@ zstd = [ coverage = [ { name = "coverage", extra = ["toml"] }, ] +fuzz = [ + { name = "atheris" }, +] gevent = [ { name = "gevent" }, ] @@ -1414,6 +1427,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" }] From cf395ab6c8a22cc023669f9d32c92b5e0900c8fc Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 06:35:55 -0500 Subject: [PATCH 02/11] PYTHON-4232 Trim fuzz test comments and docstrings --- .evergreen/scripts/run_tests.py | 1 - .evergreen/scripts/setup_tests.py | 3 +-- fuzz/build_corpus.py | 42 ++++++++----------------------- fuzz/fuzz_decode.py | 22 ++++------------ fuzz/fuzz_decode_all.py | 8 ++---- fuzz/fuzz_is_valid.py | 11 +++----- 6 files changed, 22 insertions(+), 65 deletions(-) diff --git a/.evergreen/scripts/run_tests.py b/.evergreen/scripts/run_tests.py index b8fd55e04b..b598ed70d4 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -139,7 +139,6 @@ def handle_fuzz() -> None: targets = ["fuzz_decode", "fuzz_decode_all", "fuzz_is_valid"] if SUB_TEST_NAME == "regression": - # Run every target over the committed known-bug reproducers. # Skip dotfiles (pathlib globbing matches them). inputs = sorted( str(p) for p in (ROOT / "fuzz" / "regression").glob("*") if not p.name.startswith(".") diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index e346ea4e15..fc70fde6e8 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -529,8 +529,7 @@ def handle_test_env() -> None: 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; nothing - # corpus-related is committed (PYTHON-4232). + # Build the seed corpus from the vendored spec data. corpus_dir = ROOT / "fuzz-corpus" shutil.rmtree(corpus_dir, ignore_errors=True) corpus_dir.mkdir() diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py index 2e634fd106..3b33110086 100755 --- a/fuzz/build_corpus.py +++ b/fuzz/build_corpus.py @@ -3,18 +3,11 @@ OSS-Fuzz seed corpus builder for BSON fuzzing, mirroring the Go driver's internal/cmd/build-oss-fuzz-corpus/main.go. -Reads the vendored BSON spec corpus (test/bson_corpus/ by default), converts -each valid test case to raw BSON bytes, and packages them into a ZIP. Also -synthesizes seeds the spec corpus lacks: documents at the int32 -declared-length and minimum-size boundaries, and one large (~500 KB) stress -document. - -Usage: - # Build the seed corpus ZIP: - python build_corpus.py bson_decode_seed_corpus.zip - - # Use a custom data directory: - python build_corpus.py bson_decode_seed_corpus.zip --data-dir /path/to/bson-corpus +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 @@ -28,8 +21,8 @@ from pathlib import Path from typing import Any -# Use the pure-Python bson implementation: JSON conversion needs no C -# extension, and an ASan-instrumented _cbson would abort on import. +# Force the pure-Python implementation: an ASan-instrumented _cbson would +# abort on import. sys.modules["bson._cbson"] = None # type: ignore[assignment] import bson @@ -53,7 +46,6 @@ def canonical_hex_to_bson_bytes(hex_str: str) -> bytes: 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() - # namelist() is O(n), fine at this corpus size. if filename not in zw.namelist(): zw.writestr(filename, bson_bytes) @@ -135,35 +127,23 @@ def nest(doc: dict[str, Any], depth: int) -> dict[str, Any]: def seed_boundary_documents(zw: zipfile.ZipFile) -> None: - """ - Seed documents at the int32 declared-length and minimum-size boundaries. - - A declared length that lies must fail with InvalidBSON, never read or - allocate the declared size. - """ + """Seed documents at the int32 declared-length and minimum-size boundaries.""" lies: tuple[tuple[str, bytes], ...] = ( - # Declares ~2 GB; body is 8 bytes. Must fail fast, not allocate. + # Declares ~2 GB, body is 8 bytes. ("declared_length_int32max", struct.pack(" None: - """ - Synthesize one large (~500 KB) stress document. - - Without a seed this size, libFuzzer's max_len default of max(4096, - largest seed) keeps mutation below the size where long-string and - many-field paths live. - """ + """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)}, diff --git a/fuzz/fuzz_decode.py b/fuzz/fuzz_decode.py index cb8a680ebe..e3f0ff05d7 100755 --- a/fuzz/fuzz_decode.py +++ b/fuzz/fuzz_decode.py @@ -4,9 +4,6 @@ Runs the C extension and pure-Python implementations on arbitrary bytes and compares their results when both are present. - -Usage (with atheris / OSS-Fuzz): - python fuzz_decode.py corpus/ """ from __future__ import annotations @@ -22,13 +19,7 @@ def _load_pure_python_bson(): - """ - Return a fresh import of the bson package with the C extension blocked. - - ``None`` for ``bson._cbson`` in sys.modules forces the pure-Python - fallback; the original modules are restored afterwards so ``_bson_c`` - remains valid. - """ + """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.") } @@ -49,7 +40,6 @@ def _load_pure_python_bson(): def _c_extension_available() -> bool: - """Return True if the bson C extension is actually loaded.""" try: import bson._cbson as cbson @@ -60,15 +50,15 @@ def _c_extension_available() -> bool: _bson_pure = _load_pure_python_bson() -# Consistency checks only apply when the two handles genuinely differ. +# Consistency checks apply only when the two handles genuinely differ. _BOTH_IMPLS = _bson_pure is not None and _c_extension_available() with atheris.instrument_imports(): - pass # bson is already imported above; nothing further to instrument + pass # bson already imported above -# InvalidBSON is excluded here: each bson module instance has its own class, -# so _decode_with catches bson_mod.InvalidBSON rather than a shared tuple. +# InvalidBSON is excluded: each bson module has its own class, caught via +# bson_mod.InvalidBSON below. _OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) @@ -82,7 +72,6 @@ def _decode_with(bson_mod, data: bytes) -> Optional[dict]: @atheris.instrument_func def TestOneInput(data: bytes) -> None: - # C extension (or the pure-Python fallback). doc_c = _decode_with(_bson_c, data) # A successful decode must round-trip through encode(). @@ -95,7 +84,6 @@ def TestOneInput(data: bytes) -> None: f"decoded document: {doc_c!r}" ) from exc - # Pure Python. if _bson_pure is not None: doc_pure = _decode_with(_bson_pure, data) diff --git a/fuzz/fuzz_decode_all.py b/fuzz/fuzz_decode_all.py index 129c016b4a..6d96382499 100755 --- a/fuzz/fuzz_decode_all.py +++ b/fuzz/fuzz_decode_all.py @@ -2,12 +2,8 @@ """ Fuzz target for bson.decode_all(). -Runs the multi-document decoder on arbitrary bytes (including concatenated -BSON documents) under both the C extension and pure Python implementations, -and compares their results when both are present. - -Usage (with atheris / OSS-Fuzz): - python fuzz_decode_all.py corpus/ +Runs both implementations on arbitrary bytes (including concatenated +documents) and compares their results when both are present. """ from __future__ import annotations diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py index 4dc07751ab..f4bf623f13 100755 --- a/fuzz/fuzz_is_valid.py +++ b/fuzz/fuzz_is_valid.py @@ -2,12 +2,8 @@ """ Fuzz target for bson.is_valid(). -Two invariants are checked: - 1. If is_valid() returns True, decode() must not raise InvalidBSON. - 2. The C extension and pure Python agree on the is_valid() result. - -Usage (with atheris / OSS-Fuzz): - python fuzz_is_valid.py corpus/ +Checks that is_valid() returning True means decode() raises no InvalidBSON, +and that the C extension and pure Python agree on the result. """ from __future__ import annotations @@ -77,8 +73,7 @@ def TestOneInput(data: bytes) -> None: f"InvalidBSON: {exc!r}\ninput: {data!r}" ) from exc except _EXPECTED_DECODE_ERRORS: - # is_valid() only guarantees structural validity; other decode - # errors are not invariant violations. + # is_valid() only guarantees structural validity. pass if _bson_pure is not None: From 9bee1a967b6f7d76038113c2ea48ceda9c84fb8e Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 08:53:55 -0500 Subject: [PATCH 03/11] PYTHON-4232 Address review comments --- .evergreen/scripts/generate_config.py | 2 +- .evergreen/scripts/run_tests.py | 5 ++++- .evergreen/scripts/setup_tests.py | 9 +++++---- .gitignore | 8 +------- AGENTS.md | 2 +- CONTRIBUTING.md | 5 ++--- fuzz/build_corpus.py | 2 +- 7 files changed, 15 insertions(+), 18 deletions(-) diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 22c9ac03c4..9bdc38c1cd 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -512,7 +512,7 @@ def create_fuzz_variants(): # Regression test on every PR. name = get_variant_name("Fuzz Regression", host) pr_variant = create_variant([".fuzz-regression"], name, host=host, tags=["pr"]) - # Atheris is CPython-only and Linux/macOS-only; use a pinned CPython on RHEL8. + # 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] diff --git a/.evergreen/scripts/run_tests.py b/.evergreen/scripts/run_tests.py index b598ed70d4..c8dc7f7dae 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -151,11 +151,14 @@ def handle_fuzz() -> None: return # Time-boxed fuzzing against the generated seed corpus. - corpus_dir = os.environ.get("FUZZ_CORPUS_DIR", str(ROOT / "fuzz-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"python fuzz/{target}.py {corpus_dir} " + f"-artifact_prefix={artifact_prefix} " f"-max_total_time={max_time} -max_len=1048576 -print_final_stats=1" ) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index fc70fde6e8..1ade52e91b 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -530,10 +530,11 @@ def handle_test_env() -> None: if test_name == "fuzz" and sub_test_name != "regression": # Build the seed corpus from the vendored spec data. - corpus_dir = ROOT / "fuzz-corpus" - shutil.rmtree(corpus_dir, ignore_errors=True) - corpus_dir.mkdir() - zip_path = ROOT / "fuzz-corpus-seed.zip" + 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"python fuzz/build_corpus.py {zip_path} --data-dir {ROOT / 'test' / 'bson_corpus'}" ) diff --git a/.gitignore b/.gitignore index 515ffc74e0..c113d3ec21 100644 --- a/.gitignore +++ b/.gitignore @@ -55,10 +55,4 @@ error_log /REVIEW.md # Fuzzing (PYTHON-4232) -/fuzz-corpus/ -/fuzz-corpus-seed.zip -/crash-* -/leak-* -/oom-* -/timeout-* -/bson-fuzz-tests/ +/fuzz-scratch/ diff --git a/AGENTS.md b/AGENTS.md index 08e1300194..43a2a11d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +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/` — atheris-based BSON fuzz tests (not pytest; see `fuzz/README.md`) +- `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 5c749b12ad..196feecca7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -447,9 +447,8 @@ To run any of the test suites with minimum supported dependencies, pass `--test- ## Fuzz tests -The `fuzz/` directory holds coverage-guided fuzz tests for `bson`, built on -[atheris](https://github.com/google/atheris) (PYTHON-4232). See -`fuzz/README.md` for details. +The `fuzz/` directory holds coverage-guided fuzz tests for `bson` +(PYTHON-4232). See `fuzz/README.md` for details. - Regression test (runs on every PR): `just setup-tests fuzz regression` then `just run-tests`. diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py index 3b33110086..92d4545819 100755 --- a/fuzz/build_corpus.py +++ b/fuzz/build_corpus.py @@ -39,7 +39,7 @@ def extjson_to_bson_bytes(extjson: str) -> bytes: def canonical_hex_to_bson_bytes(hex_str: str) -> bytes: - """Decode a canonical_bson hex string to raw BSON bytes.""" + """Decode a hex string to bytes.""" return bytes.fromhex(hex_str) From f6878d7b6cf961eba141ddc75fc8d6f2b66defb1 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 09:05:32 -0500 Subject: [PATCH 04/11] PYTHON-4232 Address review comments --- CONTRIBUTING.md | 3 ++- fuzz/build_corpus.py | 4 ++-- fuzz/fuzz_decode.py | 15 +++------------ fuzz/fuzz_decode_all.py | 11 +---------- fuzz/fuzz_is_valid.py | 11 +---------- 5 files changed, 9 insertions(+), 35 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 196feecca7..68655e389e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -448,7 +448,8 @@ To run any of the test suites with minimum supported dependencies, pass `--test- ## Fuzz tests The `fuzz/` directory holds coverage-guided fuzz tests for `bson` -(PYTHON-4232). See `fuzz/README.md` for details. +([PYTHON-4232](https://jira.mongodb.org/browse/PYTHON-4232)). See +`fuzz/README.md` for details. - Regression test (runs on every PR): `just setup-tests fuzz regression` then `just run-tests`. diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py index 92d4545819..b09bb48138 100755 --- a/fuzz/build_corpus.py +++ b/fuzz/build_corpus.py @@ -38,7 +38,7 @@ def extjson_to_bson_bytes(extjson: str) -> bytes: return bson.encode(doc) -def canonical_hex_to_bson_bytes(hex_str: str) -> bytes: +def hex_to_bytes(hex_str: str) -> bytes: """Decode a hex string to bytes.""" return bytes.fromhex(hex_str) @@ -77,7 +77,7 @@ def seed_test_cases(zw: zipfile.ZipFile, test_cases: list[dict[str, Any]]) -> No # Canonical BSON bytes need no JSON round-trip. if "canonical_bson" in tc: try: - bson_bytes = canonical_hex_to_bson_bytes(tc["canonical_bson"]) + bson_bytes = hex_to_bytes(tc["canonical_bson"]) add_to_corpus(zw, bson_bytes) except ValueError as exc: print( diff --git a/fuzz/fuzz_decode.py b/fuzz/fuzz_decode.py index e3f0ff05d7..c4c06a703e 100755 --- a/fuzz/fuzz_decode.py +++ b/fuzz/fuzz_decode.py @@ -39,26 +39,17 @@ def _load_pure_python_bson(): return pure_bson -def _c_extension_available() -> bool: - try: - import bson._cbson as cbson - - return cbson is not None - except ImportError: - return False - - _bson_pure = _load_pure_python_bson() # Consistency checks apply only when the two handles genuinely differ. -_BOTH_IMPLS = _bson_pure is not None and _c_extension_available() +_BOTH_IMPLS = _bson_pure is not None and _bson_c.has_c() with atheris.instrument_imports(): pass # bson already imported above -# InvalidBSON is excluded: each bson module has its own class, caught via -# bson_mod.InvalidBSON below. +# InvalidBSON is excluded: each bson module has its own class, which +# _decode_with catches as bson_mod.InvalidBSON below. _OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) diff --git a/fuzz/fuzz_decode_all.py b/fuzz/fuzz_decode_all.py index 6d96382499..e478015f3f 100755 --- a/fuzz/fuzz_decode_all.py +++ b/fuzz/fuzz_decode_all.py @@ -36,17 +36,8 @@ def _load_pure_python_bson(): return pure_bson -def _c_extension_available() -> bool: - try: - import bson._cbson as cbson - - return cbson is not None - except ImportError: - return False - - _bson_pure = _load_pure_python_bson() -_BOTH_IMPLS = _bson_pure is not None and _c_extension_available() +_BOTH_IMPLS = _bson_pure is not None and _bson_c.has_c() with atheris.instrument_imports(): pass # bson is already imported above; nothing further to instrument diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py index f4bf623f13..854c66a337 100755 --- a/fuzz/fuzz_is_valid.py +++ b/fuzz/fuzz_is_valid.py @@ -35,17 +35,8 @@ def _load_pure_python_bson(): return pure_bson -def _c_extension_available() -> bool: - try: - import bson._cbson as cbson - - return cbson is not None - except ImportError: - return False - - _bson_pure = _load_pure_python_bson() -_BOTH_IMPLS = _bson_pure is not None and _c_extension_available() +_BOTH_IMPLS = _bson_pure is not None and _bson_c.has_c() with atheris.instrument_imports(): import bson From 9037e7a35ebfed840052b595d8ed0dd0d07da9d8 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 09:13:11 -0500 Subject: [PATCH 05/11] PYTHON-4232 Address review comments --- CONTRIBUTING.md | 3 +-- fuzz/build_corpus.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68655e389e..e350e1c29d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -448,8 +448,7 @@ To run any of the test suites with minimum supported dependencies, pass `--test- ## Fuzz tests The `fuzz/` directory holds coverage-guided fuzz tests for `bson` -([PYTHON-4232](https://jira.mongodb.org/browse/PYTHON-4232)). See -`fuzz/README.md` for details. +(PYTHON-4232). See [fuzz/README.md](fuzz/README.md) for details. - Regression test (runs on every PR): `just setup-tests fuzz regression` then `just run-tests`. diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py index b09bb48138..a6943f9783 100755 --- a/fuzz/build_corpus.py +++ b/fuzz/build_corpus.py @@ -32,8 +32,8 @@ DEFAULT_DATA_DIR = "test/bson_corpus" -def extjson_to_bson_bytes(extjson: str) -> bytes: - """Convert an Extended JSON string to canonical BSON bytes.""" +def extjson_to_bytes(extjson: str) -> bytes: + """Convert an Extended JSON string to bytes.""" doc = json_util.loads(extjson) return bson.encode(doc) @@ -58,7 +58,7 @@ def seed_extjson( ) -> None: """Convert an Extended JSON string to BSON bytes and add it to the corpus.""" try: - bson_bytes = extjson_to_bson_bytes(extjson) + bson_bytes = extjson_to_bytes(extjson) except Exception as exc: print( f"Warning: {description!r}: failed to convert {extjson_type} " From 6d298839eca2e415637894e44dadfd968601eac0 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 10:04:34 -0500 Subject: [PATCH 06/11] Address Copilot review comments --- .evergreen/scripts/run_tests.py | 3 + fuzz/README.md | 4 ++ fuzz/fuzz_decode.py | 86 ++++++++++++++---------- fuzz/fuzz_decode_all.py | 112 ++++++++++++++++++++++---------- fuzz/fuzz_is_valid.py | 59 ++++++++--------- 5 files changed, 167 insertions(+), 97 deletions(-) diff --git a/.evergreen/scripts/run_tests.py b/.evergreen/scripts/run_tests.py index c8dc7f7dae..8a82049938 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -145,6 +145,9 @@ def handle_fuzz() -> None: ) if not inputs: raise RuntimeError("No fuzz regression inputs found in fuzz/regression/") + # One run per committed reproducer: with individual files libFuzzer + # performs no empty-input warmup (that only happens in directory-corpus + # mode), and atheris exits gracefully once the budget reaches 0. runs = len(inputs) for target in targets: run_command(f"python fuzz/{target}.py {' '.join(inputs)} -atheris_runs={runs}") diff --git a/fuzz/README.md b/fuzz/README.md index 3e5b141f3e..89d478244b 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -87,6 +87,10 @@ uv run --group fuzz --group coverage python -m coverage run fuzz/fuzz_decode.py 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 ``` diff --git a/fuzz/fuzz_decode.py b/fuzz/fuzz_decode.py index c4c06a703e..ceb89384cc 100755 --- a/fuzz/fuzz_decode.py +++ b/fuzz/fuzz_decode.py @@ -8,15 +8,13 @@ from __future__ import annotations +import math import struct import sys from typing import Optional import atheris -# Load both bson implementations before atheris instruments imports. -import bson as _bson_c # C extension when available - def _load_pure_python_bson(): """Re-import bson with the C extension blocked, then restore sys.modules.""" @@ -28,8 +26,7 @@ def _load_pure_python_bson(): try: import bson as pure_bson except Exception as exc: - print(f"Warning: could not load pure-Python bson: {exc}", file=sys.stderr) - pure_bson = None + 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."): @@ -39,13 +36,16 @@ def _load_pure_python_bson(): return pure_bson -_bson_pure = _load_pure_python_bson() +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 -# Consistency checks apply only when the two handles genuinely differ. -_BOTH_IMPLS = _bson_pure is not None and _bson_c.has_c() + _bson_pure = _load_pure_python_bson() -with atheris.instrument_imports(): - pass # bson already imported above +# Consistency checks apply only when the C extension is present. +_BOTH_IMPLS = _bson_c.has_c() # InvalidBSON is excluded: each bson module has its own class, which @@ -53,6 +53,22 @@ def _load_pure_python_bson(): _OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) +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 _decode_with(bson_mod, data: bytes) -> Optional[dict]: """Run bson_mod.decode(data), returning the document or None on expected errors.""" try: @@ -75,29 +91,33 @@ def TestOneInput(data: bytes) -> None: f"decoded document: {doc_c!r}" ) from exc - if _bson_pure is not None: - 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}" - ) + 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}" + ) def main() -> None: diff --git a/fuzz/fuzz_decode_all.py b/fuzz/fuzz_decode_all.py index e478015f3f..b2c79a887a 100755 --- a/fuzz/fuzz_decode_all.py +++ b/fuzz/fuzz_decode_all.py @@ -8,17 +8,16 @@ from __future__ import annotations +import math import struct import sys from typing import Optional import atheris -# Load both bson implementations before atheris instruments imports. -import bson as _bson_c - 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.") } @@ -26,8 +25,7 @@ def _load_pure_python_bson(): try: import bson as pure_bson except Exception as exc: - print(f"Warning: could not load pure-Python bson: {exc}", file=sys.stderr) - pure_bson = None + 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."): @@ -36,50 +34,94 @@ def _load_pure_python_bson(): return pure_bson -_bson_pure = _load_pure_python_bson() -_BOTH_IMPLS = _bson_pure is not None and _bson_c.has_c() - with atheris.instrument_imports(): - pass # bson is already imported above; nothing further to instrument + # 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() _OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) +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 _decode_all_with(bson_mod, data: bytes) -> Optional[list]: + """Run bson_mod.decode_all(data), returning the documents or None on expected errors.""" try: - docs = bson_mod.decode_all(data) - # Re-encode every document to exercise the encoder as well. - for doc in docs: - bson_mod.encode(doc) - return docs + 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 _bson_pure is not None: - docs_pure = _decode_all_with(_bson_pure, data) - - 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 c_succeeded and pure_succeeded 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: + _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}" + ) def main() -> None: diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py index 854c66a337..61779b338d 100755 --- a/fuzz/fuzz_is_valid.py +++ b/fuzz/fuzz_is_valid.py @@ -13,11 +13,9 @@ import atheris -# Load both bson implementations before atheris instruments imports. -import bson as _bson_c - 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.") } @@ -25,8 +23,7 @@ def _load_pure_python_bson(): try: import bson as pure_bson except Exception as exc: - print(f"Warning: could not load pure-Python bson: {exc}", file=sys.stderr) - pure_bson = None + 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."): @@ -35,14 +32,19 @@ def _load_pure_python_bson(): return pure_bson -_bson_pure = _load_pure_python_bson() -_BOTH_IMPLS = _bson_pure is not None and _bson_c.has_c() - with atheris.instrument_imports(): - import bson + # 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() _EXPECTED_DECODE_ERRORS = ( - bson.InvalidBSON, + _bson_c.InvalidBSON, ValueError, struct.error, UnicodeDecodeError, @@ -58,7 +60,7 @@ def TestOneInput(data: bytes) -> None: if valid_c: try: _bson_c.decode(data) - except bson.InvalidBSON as exc: + except _bson_c.InvalidBSON as exc: raise AssertionError( f"bson.is_valid() returned True but bson.decode() raised " f"InvalidBSON: {exc!r}\ninput: {data!r}" @@ -67,25 +69,24 @@ def TestOneInput(data: bytes) -> None: # is_valid() only guarantees structural validity. pass - if _bson_pure is not None: - valid_pure = _bson_pure.is_valid(data) - - if valid_pure: - try: - _bson_pure.decode(data) - except _bson_pure.InvalidBSON as exc: - raise AssertionError( - f"pure-Python bson.is_valid() returned True but bson.decode() " - f"raised InvalidBSON: {exc!r}\ninput: {data!r}" - ) from exc - except _EXPECTED_DECODE_ERRORS: - pass - - if _BOTH_IMPLS and valid_c != valid_pure: + valid_pure = _bson_pure.is_valid(data) + + if valid_pure: + try: + _bson_pure.decode(data) + except _bson_pure.InvalidBSON as exc: raise AssertionError( - f"C extension and pure-Python bson.is_valid() disagree: " - f"C={valid_c}, pure={valid_pure}\ninput: {data!r}" - ) + f"pure-Python bson.is_valid() returned True but bson.decode() " + f"raised InvalidBSON: {exc!r}\ninput: {data!r}" + ) from exc + except _EXPECTED_DECODE_ERRORS: + pass + + 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}" + ) def main() -> None: From c98927ee8c06e9b355b66b595e284b1f8b266a28 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 13:45:14 -0500 Subject: [PATCH 07/11] Seed raw BSON variants, document atheris platform support --- fuzz/README.md | 11 +++++++++++ fuzz/build_corpus.py | 21 +++++++++++---------- pyproject.toml | 1 + 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/fuzz/README.md b/fuzz/README.md index 89d478244b..f8f9e2090b 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -22,6 +22,17 @@ Atheris is a `uv` dependency group. Run targets from the repository root: 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 committed reproducers for known bugs. Running the targets diff --git a/fuzz/build_corpus.py b/fuzz/build_corpus.py index a6943f9783..ab426e7321 100755 --- a/fuzz/build_corpus.py +++ b/fuzz/build_corpus.py @@ -74,16 +74,17 @@ def seed_test_cases(zw: zipfile.ZipFile, test_cases: list[dict[str, Any]]) -> No for tc in test_cases: desc = tc.get("description", "") - # Canonical BSON bytes need no JSON round-trip. - if "canonical_bson" in tc: - try: - bson_bytes = hex_to_bytes(tc["canonical_bson"]) - add_to_corpus(zw, bson_bytes) - except ValueError as exc: - print( - f"Warning: {desc!r}: failed to decode canonical_bson hex: {exc}", - file=sys.stderr, - ) + # 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 ( diff --git a/pyproject.toml b/pyproject.toml index 091bce9200..e7cf46c8ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ 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 = [ From 3ef3356809fd150a6431e1f638bb592dc77a2e1d Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 13:59:42 -0500 Subject: [PATCH 08/11] Fail is_valid fuzz case on any decode error --- fuzz/fuzz_is_valid.py | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py index 61779b338d..ca8694ee6f 100755 --- a/fuzz/fuzz_is_valid.py +++ b/fuzz/fuzz_is_valid.py @@ -2,13 +2,12 @@ """ Fuzz target for bson.is_valid(). -Checks that is_valid() returning True means decode() raises no InvalidBSON, -and that the C extension and pure Python agree on the result. +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 struct import sys import atheris @@ -43,15 +42,6 @@ def _load_pure_python_bson(): # Consistency checks apply only when the C extension is present. _BOTH_IMPLS = _bson_c.has_c() -_EXPECTED_DECODE_ERRORS = ( - _bson_c.InvalidBSON, - ValueError, - struct.error, - UnicodeDecodeError, - OverflowError, - KeyError, -) - @atheris.instrument_func def TestOneInput(data: bytes) -> None: @@ -60,27 +50,24 @@ def TestOneInput(data: bytes) -> None: if valid_c: try: _bson_c.decode(data) - except _bson_c.InvalidBSON as exc: + 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"InvalidBSON: {exc!r}\ninput: {data!r}" + f"{type(exc).__name__}: {exc!r}\ninput: {data!r}" ) from exc - except _EXPECTED_DECODE_ERRORS: - # is_valid() only guarantees structural validity. - pass valid_pure = _bson_pure.is_valid(data) if valid_pure: try: _bson_pure.decode(data) - except _bson_pure.InvalidBSON as exc: + except Exception as exc: raise AssertionError( f"pure-Python bson.is_valid() returned True but bson.decode() " - f"raised InvalidBSON: {exc!r}\ninput: {data!r}" + f"raised {type(exc).__name__}: {exc!r}\ninput: {data!r}" ) from exc - except _EXPECTED_DECODE_ERRORS: - pass if _BOTH_IMPLS and valid_c != valid_pure: raise AssertionError( From 89e5a4fbdb305619a2f6939951d14d1684a1a611 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 15 Oct 2026 15:08:45 -0500 Subject: [PATCH 09/11] Launch fuzz targets with sys.executable --- .evergreen/scripts/run_tests.py | 6 ++++-- .evergreen/scripts/setup_tests.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.evergreen/scripts/run_tests.py b/.evergreen/scripts/run_tests.py index 8a82049938..695423edc6 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -150,7 +150,9 @@ def handle_fuzz() -> None: # mode), and atheris exits gracefully once the budget reaches 0. runs = len(inputs) for target in targets: - run_command(f"python fuzz/{target}.py {' '.join(inputs)} -atheris_runs={runs}") + run_command( + f"{sys.executable} fuzz/{target}.py {' '.join(inputs)} -atheris_runs={runs}" + ) return # Time-boxed fuzzing against the generated seed corpus. @@ -160,7 +162,7 @@ def handle_fuzz() -> None: max_time = os.environ.get("FUZZ_MAX_TIME", "300") for target in targets: run_command( - f"python fuzz/{target}.py {corpus_dir} " + 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" ) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 1ade52e91b..d1c9514091 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -5,6 +5,7 @@ import platform import shutil import stat +import sys from pathlib import Path from urllib import request @@ -536,9 +537,9 @@ def handle_test_env() -> None: corpus_dir.mkdir(parents=True) zip_path = scratch_dir / "corpus-seed.zip" run_command( - f"python fuzz/build_corpus.py {zip_path} --data-dir {ROOT / 'test' / 'bson_corpus'}" + f"{sys.executable} fuzz/build_corpus.py {zip_path} --data-dir {ROOT / 'test' / 'bson_corpus'}" ) - run_command(f"python -m zipfile -e {zip_path} {corpus_dir}") + run_command(f"{sys.executable} -m zipfile -e {zip_path} {corpus_dir}") run_command(f"rm {zip_path}") write_env("FUZZ_CORPUS_DIR", str(corpus_dir)) From 318a89153e61317bf8866f0ac0710fbfe3bd116e Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 16 Oct 2026 17:27:14 -0500 Subject: [PATCH 10/11] Dedup fuzz helpers, use zipfile, clarify docs --- .evergreen/scripts/run_tests.py | 6 +-- .evergreen/scripts/setup_tests.py | 6 ++- CONTRIBUTING.md | 4 +- fuzz/README.md | 11 ++++-- fuzz/build_corpus.py | 4 +- fuzz/fuzz_decode.py | 63 +++---------------------------- fuzz/fuzz_decode_all.py | 57 +++------------------------- fuzz/fuzz_is_valid.py | 34 ++--------------- 8 files changed, 33 insertions(+), 152 deletions(-) diff --git a/.evergreen/scripts/run_tests.py b/.evergreen/scripts/run_tests.py index 695423edc6..e50a266440 100644 --- a/.evergreen/scripts/run_tests.py +++ b/.evergreen/scripts/run_tests.py @@ -145,9 +145,9 @@ def handle_fuzz() -> None: ) if not inputs: raise RuntimeError("No fuzz regression inputs found in fuzz/regression/") - # One run per committed reproducer: with individual files libFuzzer - # performs no empty-input warmup (that only happens in directory-corpus - # mode), and atheris exits gracefully once the budget reaches 0. + # 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( diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index d1c9514091..dbf87d28d5 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -6,6 +6,7 @@ import shutil import stat import sys +import zipfile from pathlib import Path from urllib import request @@ -539,8 +540,9 @@ def handle_test_env() -> None: run_command( f"{sys.executable} fuzz/build_corpus.py {zip_path} --data-dir {ROOT / 'test' / 'bson_corpus'}" ) - run_command(f"{sys.executable} -m zipfile -e {zip_path} {corpus_dir}") - run_command(f"rm {zip_path}") + 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e350e1c29d..84202cf9b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -448,7 +448,9 @@ To run any of the test suites with minimum supported dependencies, pass `--test- ## Fuzz tests The `fuzz/` directory holds coverage-guided fuzz tests for `bson` -(PYTHON-4232). See [fuzz/README.md](fuzz/README.md) for details. +(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`. diff --git a/fuzz/README.md b/fuzz/README.md index f8f9e2090b..ab05d6096f 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -3,6 +3,10 @@ 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. @@ -10,7 +14,7 @@ compares their results when both are present. | File | What it tests | |---|---| -| `fuzz_decode.py` | `bson.decode()` + round-trip `encode()` | +| `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 | @@ -35,7 +39,7 @@ The Evergreen fuzz tasks pin Linux CPython 3.13 and are unaffected. ### Regression inputs (deterministic) -`regression/` holds committed reproducers for known bugs. Running the targets +`regression/` holds reproducers for known bugs. Running the targets over them runs on Evergreen as the `fuzz-regression` sub-test on every PR: ```bash @@ -109,5 +113,6 @@ build_corpus.py Seed corpus builder (mirrors the Go driver's build-oss-fuzz- 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() -regression/ Known bug reproducers (committed; no corpus setup required) +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 index ab426e7321..4d46140803 100755 --- a/fuzz/build_corpus.py +++ b/fuzz/build_corpus.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ OSS-Fuzz seed corpus builder for BSON fuzzing, mirroring the Go driver's -internal/cmd/build-oss-fuzz-corpus/main.go. +(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 @@ -185,8 +185,8 @@ def main() -> None: print("Synthesized seeds:") seed_boundary_documents(zw) seed_large_document(zw) + entry_count = len(zw.namelist()) - entry_count = len(zipfile.ZipFile(output_path).namelist()) print(f"Wrote {entry_count} corpus entries to {output_path}") diff --git a/fuzz/fuzz_decode.py b/fuzz/fuzz_decode.py index ceb89384cc..b323c97092 100755 --- a/fuzz/fuzz_decode.py +++ b/fuzz/fuzz_decode.py @@ -8,33 +8,10 @@ from __future__ import annotations -import math -import struct -import sys from typing import Optional 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 - +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 @@ -42,38 +19,17 @@ def _load_pure_python_bson(): # module and is skipped by atheris. import bson as _bson_c # C extension when available - _bson_pure = _load_pure_python_bson() + _bson_pure = load_pure_python_bson() # Consistency checks apply only when the C extension is present. _BOTH_IMPLS = _bson_c.has_c() -# InvalidBSON is excluded: each bson module has its own class, which -# _decode_with catches as bson_mod.InvalidBSON below. -_OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) - - -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 _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): + except (bson_mod.InvalidBSON, *OTHER_ERRORS): return None @@ -113,21 +69,12 @@ def TestOneInput(data: bytes) -> None: 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): + 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}" ) -def main() -> None: - import os - - # FUZZ_NATIVE=1: libFuzzer is preloaded externally; skip atheris's own. - internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" - atheris.Setup(sys.argv, TestOneInput, internal_libfuzzer=internal_libfuzzer) - atheris.Fuzz() - - if __name__ == "__main__": - main() + main(TestOneInput) diff --git a/fuzz/fuzz_decode_all.py b/fuzz/fuzz_decode_all.py index b2c79a887a..fa01549607 100755 --- a/fuzz/fuzz_decode_all.py +++ b/fuzz/fuzz_decode_all.py @@ -8,31 +8,10 @@ from __future__ import annotations -import math -import struct -import sys from typing import Optional 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 - +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 @@ -40,35 +19,17 @@ def _load_pure_python_bson(): # module and is skipped by atheris. import bson as _bson_c - _bson_pure = _load_pure_python_bson() + _bson_pure = load_pure_python_bson() # Consistency checks apply only when the C extension is present. _BOTH_IMPLS = _bson_c.has_c() -_OTHER_ERRORS = (ValueError, struct.error, UnicodeDecodeError, OverflowError, KeyError) - - -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 _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): + except (bson_mod.InvalidBSON, *OTHER_ERRORS): return None @@ -116,7 +77,7 @@ def TestOneInput(data: bytes) -> None: if ( docs_c is not None and docs_pure is not None - and _normalize(docs_c) != _normalize(docs_pure) + and normalize(docs_c) != normalize(docs_pure) ): raise AssertionError( f"C extension and pure-Python bson.decode_all() produced different " @@ -124,13 +85,5 @@ def TestOneInput(data: bytes) -> None: ) -def main() -> None: - import os - - internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" - atheris.Setup(sys.argv, TestOneInput, internal_libfuzzer=internal_libfuzzer) - atheris.Fuzz() - - if __name__ == "__main__": - main() + main(TestOneInput) diff --git a/fuzz/fuzz_is_valid.py b/fuzz/fuzz_is_valid.py index ca8694ee6f..5281c7e49e 100755 --- a/fuzz/fuzz_is_valid.py +++ b/fuzz/fuzz_is_valid.py @@ -8,28 +8,8 @@ from __future__ import annotations -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 - +from fuzz_shared import load_pure_python_bson, main with atheris.instrument_imports(): # Load both implementations while import instrumentation is active so the @@ -37,7 +17,7 @@ def _load_pure_python_bson(): # module and is skipped by atheris. import bson as _bson_c - _bson_pure = _load_pure_python_bson() + _bson_pure = load_pure_python_bson() # Consistency checks apply only when the C extension is present. _BOTH_IMPLS = _bson_c.has_c() @@ -76,13 +56,5 @@ def TestOneInput(data: bytes) -> None: ) -def main() -> None: - import os - - internal_libfuzzer = os.environ.get("FUZZ_NATIVE") != "1" - atheris.Setup(sys.argv, TestOneInput, internal_libfuzzer=internal_libfuzzer) - atheris.Fuzz() - - if __name__ == "__main__": - main() + main(TestOneInput) From 19f256d7b830a18ba9031654ff07a625f06586a6 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 16 Oct 2026 17:30:05 -0500 Subject: [PATCH 11/11] add helper file --- fuzz/fuzz_shared.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100755 fuzz/fuzz_shared.py 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()