Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .evergreen/generated_configs/functions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ functions:
- IS_WIN32
- REQUIRE_FIPS
- TEST_MIN_DEPS
- FUZZ_MAX_TIME
type: test
- command: subprocess.exec
params:
Expand Down
18 changes: 18 additions & 0 deletions .evergreen/generated_configs/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions .evergreen/generated_configs/variants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions .evergreen/scripts/generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions .evergreen/scripts/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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())

Expand Down
20 changes: 19 additions & 1 deletion .evergreen/scripts/setup_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import platform
import shutil
import stat
import sys
import zipfile
from pathlib import Path
from urllib import request

Expand Down Expand Up @@ -36,6 +38,7 @@
"UV_PYTHON_PREFERENCE",
"REQUIRE_FIPS",
"IS_WIN32",
"FUZZ_MAX_TIME",
]

# Map the test name to test extra.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion .evergreen/scripts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -93,6 +94,7 @@ class Distro:
"auth_oidc",
"atlas_connect",
"aws_lambda",
"fuzz",
"mockupdb",
"ocsp",
"sfp",
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ error_log
# AI-assistant review workflow logs (kept untracked)
/REVIEW_STATE.md
/REVIEW.md

# Fuzzing (PYTHON-4232)
/fuzz-scratch/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# BSON fuzz tests

Coverage-guided fuzz tests for the `bson` package, built on

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "coverage-guided" mean? Does this mean that the fuzz tests try to cover the full surface of the API?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an explainer

[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)
```
Loading
Loading