|
| 1 | +"""Regression tests for PYTHON-3449: buffer.c rewritten in terms of PyByteArray. |
| 2 | +
|
| 3 | +Each test maps to a specific memory safety concern identified during the spike. |
| 4 | +""" |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +import threading |
| 9 | +import tracemalloc |
| 10 | + |
| 11 | +import pytest |
| 12 | + |
| 13 | +import bson |
| 14 | + |
| 15 | +pytestmark = pytest.mark.default |
| 16 | +from bson import DEFAULT_CODEC_OPTIONS, _dict_to_bson |
| 17 | + |
| 18 | + |
| 19 | +class TestResizeWithNestedBackfill: |
| 20 | + """Resize + backfill correctness (stale-pointer hazard). |
| 21 | +
|
| 22 | + Encodes a deeply nested document that forces multiple buffer growths and |
| 23 | + exercises the save_space + backfill pattern (the document length field is |
| 24 | + written at a saved offset *after* all child elements are encoded). If the |
| 25 | + PyByteArray pointer were stale after a resize, the backfilled length would |
| 26 | + be wrong and decode() would raise or return corrupt data. |
| 27 | + """ |
| 28 | + |
| 29 | + def test_deep_nesting_round_trip(self): |
| 30 | + # ~300 bytes per level forces a resize before the outer doc length is backfilled |
| 31 | + doc: dict = {"a" * 50: "b" * 250} |
| 32 | + for _ in range(20): |
| 33 | + doc = {"nested": doc} |
| 34 | + encoded = bson.encode(doc) |
| 35 | + assert bson.decode(encoded) == doc |
| 36 | + |
| 37 | + def test_wide_document_round_trip(self): |
| 38 | + # Many keys causes many save_space + backfill cycles |
| 39 | + doc = {str(i): "x" * 100 for i in range(100)} |
| 40 | + encoded = bson.encode(doc) |
| 41 | + assert bson.decode(encoded) == doc |
| 42 | + |
| 43 | + |
| 44 | +class TestSequentialEncodesNoCorruption: |
| 45 | + """Many sequential encodes (regression for use-after-free / double-free). |
| 46 | +
|
| 47 | + Allocator reuse of freed memory would surface as a corrupt decode if a |
| 48 | + use-after-free existed in pymongo_buffer_finish or pymongo_buffer_free. |
| 49 | + """ |
| 50 | + |
| 51 | + def test_varying_sizes(self): |
| 52 | + docs = [{"i": i, "data": "x" * (i % 500)} for i in range(2000)] |
| 53 | + results = [bson.encode(d) for d in docs] |
| 54 | + for doc, enc in zip(docs, results): |
| 55 | + assert bson.decode(enc) == doc |
| 56 | + |
| 57 | + |
| 58 | +class TestConcurrentEncoding: |
| 59 | + """Concurrent encoding (free-threaded Python / Py_BEGIN_CRITICAL_SECTION). |
| 60 | +
|
| 61 | + Each thread owns its own buffer, so a race would show up as a corrupt |
| 62 | + result rather than a crash/deadlock. |
| 63 | + """ |
| 64 | + |
| 65 | + def test_concurrent_threads(self): |
| 66 | + doc = {"k": "v" * 100, "nested": {"a": list(range(50))}} |
| 67 | + expected = bson.encode(doc) |
| 68 | + errors: list[bytes] = [] |
| 69 | + |
| 70 | + def encode_and_check(): |
| 71 | + for _ in range(500): |
| 72 | + result = bson.encode(doc) |
| 73 | + if result != expected: |
| 74 | + errors.append(result) |
| 75 | + |
| 76 | + threads = [threading.Thread(target=encode_and_check) for _ in range(8)] |
| 77 | + for t in threads: |
| 78 | + t.start() |
| 79 | + for t in threads: |
| 80 | + t.join() |
| 81 | + assert not errors, f"Got {len(errors)} corrupt result(s)" |
| 82 | + |
| 83 | + |
| 84 | +class TestPublicAPIReturnTypes: |
| 85 | + """bson.encode() must return bytes; _dict_to_bson() must return bytearray. |
| 86 | +
|
| 87 | + Guards the bytes() wrapper in bson/__init__.py from being accidentally |
| 88 | + removed, and confirms the internal function exposes bytearray. |
| 89 | + """ |
| 90 | + |
| 91 | + def test_encode_returns_bytes(self): |
| 92 | + result = bson.encode({"x": 1}) |
| 93 | + assert type(result) is bytes, f"expected bytes, got {type(result)}" |
| 94 | + |
| 95 | + def test_dict_to_bson_returns_bytearray(self): |
| 96 | + result = _dict_to_bson({"x": 1}, False, DEFAULT_CODEC_OPTIONS) |
| 97 | + assert type(result) is bytearray, f"expected bytearray, got {type(result)}" # type: ignore[comparison-overlap] |
| 98 | + |
| 99 | + def test_encode_and_dict_to_bson_agree(self): |
| 100 | + doc = {"a": 1, "b": "hello"} |
| 101 | + assert bson.encode(doc) == bytes(_dict_to_bson(doc, False, DEFAULT_CODEC_OPTIONS)) |
| 102 | + |
| 103 | + |
| 104 | +class TestIsValidAcceptsBytearray: |
| 105 | + """bson.is_valid() must accept bytearray after the isinstance fix.""" |
| 106 | + |
| 107 | + def test_is_valid_accepts_bytearray(self): |
| 108 | + ba = _dict_to_bson({"x": 1}, False, DEFAULT_CODEC_OPTIONS) |
| 109 | + assert isinstance(ba, bytearray) |
| 110 | + assert bson.is_valid(ba) |
| 111 | + |
| 112 | + def test_is_valid_rejects_non_bytes(self): |
| 113 | + with pytest.raises(TypeError): |
| 114 | + bson.is_valid("not bytes") # type: ignore[arg-type] |
| 115 | + |
| 116 | + |
| 117 | +class TestEncodeFailureNoLeak: |
| 118 | + """Encoding failures must not leak memory (error-path safety). |
| 119 | +
|
| 120 | + Peak memory must stay bounded after repeated failures. A growing peak |
| 121 | + indicates a leak in the C buffer error path. |
| 122 | + """ |
| 123 | + |
| 124 | + def test_no_leak_on_repeated_failures(self): |
| 125 | + tracemalloc.start() |
| 126 | + for _ in range(1000): |
| 127 | + with pytest.raises(Exception): # noqa: B017 |
| 128 | + bson.encode({1: "non-string key"}) # type: ignore[arg-type,dict-item] |
| 129 | + _, peak = tracemalloc.get_traced_memory() |
| 130 | + tracemalloc.stop() |
| 131 | + assert peak < 5 * 1024 * 1024, f"peak memory {peak} bytes exceeds 5 MiB" |
0 commit comments