diff options
| author | Gyorgy Sarvari <skandigraun@gmail.com> | 2026-02-05 07:59:36 +0100 |
|---|---|---|
| committer | Anuj Mittal <anuj.mittal@oss.qualcomm.com> | 2026-02-19 08:20:16 +0530 |
| commit | 891e25f9bfc768d32dd2143adf19cd910840885f (patch) | |
| tree | b25be4644c18e5e3de22d9241aef6b5edb94bfbe /meta-python/recipes-devtools/python/python3-cbor2/CVE-2025-68131.patch | |
| parent | d28d0a23615f4d91d125fdcd247a71245087dab3 (diff) | |
| download | meta-openembedded-891e25f9bfc768d32dd2143adf19cd910840885f.tar.gz | |
python3-cbor2: patch CVE-2025-68131
Details: https://nvd.nist.gov/vuln/detail/CVE-2025-68131
The NVD report mentions a PR as the solution, however
in the discussion of that PR it turned out that this
is incorrect, and another patch is the solution. That
patch was picked.
Ptests passed successfully.
Signed-off-by: Gyorgy Sarvari <skandigraun@gmail.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
Diffstat (limited to 'meta-python/recipes-devtools/python/python3-cbor2/CVE-2025-68131.patch')
| -rw-r--r-- | meta-python/recipes-devtools/python/python3-cbor2/CVE-2025-68131.patch | 515 |
1 files changed, 515 insertions, 0 deletions
diff --git a/meta-python/recipes-devtools/python/python3-cbor2/CVE-2025-68131.patch b/meta-python/recipes-devtools/python/python3-cbor2/CVE-2025-68131.patch new file mode 100644 index 0000000000..bf6819eebe --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-cbor2/CVE-2025-68131.patch | |||
| @@ -0,0 +1,515 @@ | |||
| 1 | From 60b74e9842e83318efccf0f4eed6a94a07ac5677 Mon Sep 17 00:00:00 2001 | ||
| 2 | From: Andreas Eriksen <andreer@vespa.ai> | ||
| 3 | Date: Thu, 18 Dec 2025 16:48:26 +0100 | ||
| 4 | Subject: [PATCH] Merge commit from fork | ||
| 5 | |||
| 6 | * track depth of recursive encode/decode, clear shared refs on start | ||
| 7 | |||
| 8 | * test that shared refs are cleared on start | ||
| 9 | |||
| 10 | * add fix-shared-state-reset to version history | ||
| 11 | |||
| 12 | * clear shared state _after_ encode/decode | ||
| 13 | |||
| 14 | * use PY_SSIZE_T_MAX to clear shareables list | ||
| 15 | |||
| 16 | * use context manager for python decoder depth tracking | ||
| 17 | |||
| 18 | * use context manager for python encoder depth tracking | ||
| 19 | |||
| 20 | CVE: CVE-2025-68131 | ||
| 21 | Upstream-Status: Backport [https://github.com/agronholm/cbor2/commit/f1d701cd2c411ee40bb1fe383afe7f365f35abf0] | ||
| 22 | Signed-off-by: Gyorgy Sarvari <skandigraun@gmail.com> | ||
| 23 | --- | ||
| 24 | cbor2/_decoder.py | 38 +++++++++++++++++----- | ||
| 25 | cbor2/_encoder.py | 44 +++++++++++++++++++++----- | ||
| 26 | source/decoder.c | 28 ++++++++++++++++- | ||
| 27 | source/decoder.h | 1 + | ||
| 28 | source/encoder.c | 23 ++++++++++++-- | ||
| 29 | source/encoder.h | 1 + | ||
| 30 | tests/test_decoder.py | 62 ++++++++++++++++++++++++++++++++++++ | ||
| 31 | tests/test_encoder.py | 70 +++++++++++++++++++++++++++++++++++++++++ | ||
| 32 | 8 files changed, 250 insertions(+), 17 deletions(-) | ||
| 33 | |||
| 34 | diff --git a/cbor2/_decoder.py b/cbor2/_decoder.py | ||
| 35 | index 42a9740..b552492 100644 | ||
| 36 | --- a/cbor2/_decoder.py | ||
| 37 | +++ b/cbor2/_decoder.py | ||
| 38 | @@ -5,6 +5,7 @@ import struct | ||
| 39 | import sys | ||
| 40 | from codecs import getincrementaldecoder | ||
| 41 | from collections.abc import Callable, Mapping, Sequence | ||
| 42 | +from contextlib import contextmanager | ||
| 43 | from datetime import date, datetime, timedelta, timezone | ||
| 44 | from io import BytesIO | ||
| 45 | from typing import IO, TYPE_CHECKING, Any, TypeVar, cast, overload | ||
| 46 | @@ -59,6 +60,7 @@ class CBORDecoder: | ||
| 47 | "_immutable", | ||
| 48 | "_str_errors", | ||
| 49 | "_stringref_namespace", | ||
| 50 | + "_decode_depth", | ||
| 51 | ) | ||
| 52 | |||
| 53 | _fp: IO[bytes] | ||
| 54 | @@ -100,6 +102,7 @@ class CBORDecoder: | ||
| 55 | self._shareables: list[object] = [] | ||
| 56 | self._stringref_namespace: list[str | bytes] | None = None | ||
| 57 | self._immutable = False | ||
| 58 | + self._decode_depth = 0 | ||
| 59 | |||
| 60 | @property | ||
| 61 | def immutable(self) -> bool: | ||
| 62 | @@ -225,13 +228,33 @@ class CBORDecoder: | ||
| 63 | if unshared: | ||
| 64 | self._share_index = old_index | ||
| 65 | |||
| 66 | + @contextmanager | ||
| 67 | + def _decoding_context(self): | ||
| 68 | + """ | ||
| 69 | + Context manager for tracking decode depth and clearing shared state. | ||
| 70 | + | ||
| 71 | + Shared state is cleared at the end of each top-level decode to prevent | ||
| 72 | + shared references from leaking between independent decode operations. | ||
| 73 | + Nested calls (from hooks) must preserve the state. | ||
| 74 | + """ | ||
| 75 | + self._decode_depth += 1 | ||
| 76 | + try: | ||
| 77 | + yield | ||
| 78 | + finally: | ||
| 79 | + self._decode_depth -= 1 | ||
| 80 | + assert self._decode_depth >= 0 | ||
| 81 | + if self._decode_depth == 0: | ||
| 82 | + self._shareables.clear() | ||
| 83 | + self._share_index = None | ||
| 84 | + | ||
| 85 | def decode(self) -> object: | ||
| 86 | """ | ||
| 87 | Decode the next value from the stream. | ||
| 88 | |||
| 89 | :raises CBORDecodeError: if there is any problem decoding the stream | ||
| 90 | """ | ||
| 91 | - return self._decode() | ||
| 92 | + with self._decoding_context(): | ||
| 93 | + return self._decode() | ||
| 94 | |||
| 95 | def decode_from_bytes(self, buf: bytes) -> object: | ||
| 96 | """ | ||
| 97 | @@ -242,12 +265,13 @@ class CBORDecoder: | ||
| 98 | object needs to be decoded separately from the rest but while still | ||
| 99 | taking advantage of the shared value registry. | ||
| 100 | """ | ||
| 101 | - with BytesIO(buf) as fp: | ||
| 102 | - old_fp = self.fp | ||
| 103 | - self.fp = fp | ||
| 104 | - retval = self._decode() | ||
| 105 | - self.fp = old_fp | ||
| 106 | - return retval | ||
| 107 | + with self._decoding_context(): | ||
| 108 | + with BytesIO(buf) as fp: | ||
| 109 | + old_fp = self.fp | ||
| 110 | + self.fp = fp | ||
| 111 | + retval = self._decode() | ||
| 112 | + self.fp = old_fp | ||
| 113 | + return retval | ||
| 114 | |||
| 115 | @overload | ||
| 116 | def _decode_length(self, subtype: int) -> int: ... | ||
| 117 | diff --git a/cbor2/_encoder.py b/cbor2/_encoder.py | ||
| 118 | index fe65763..5b9609c 100644 | ||
| 119 | --- a/cbor2/_encoder.py | ||
| 120 | +++ b/cbor2/_encoder.py | ||
| 121 | @@ -124,6 +124,7 @@ class CBOREncoder: | ||
| 122 | "string_namespacing", | ||
| 123 | "_string_references", | ||
| 124 | "indefinite_containers", | ||
| 125 | + "_encode_depth", | ||
| 126 | ) | ||
| 127 | |||
| 128 | _fp: IO[bytes] | ||
| 129 | @@ -188,6 +189,7 @@ class CBOREncoder: | ||
| 130 | int, tuple[object, int | None] | ||
| 131 | ] = {} # indexes used for value sharing | ||
| 132 | self._string_references: dict[str | bytes, int] = {} # indexes used for string references | ||
| 133 | + self._encode_depth = 0 | ||
| 134 | self._encoders = default_encoders.copy() | ||
| 135 | if canonical: | ||
| 136 | self._encoders.update(canonical_encoders) | ||
| 137 | @@ -303,6 +305,24 @@ class CBOREncoder: | ||
| 138 | """ | ||
| 139 | self._fp_write(data) | ||
| 140 | |||
| 141 | + @contextmanager | ||
| 142 | + def _encoding_context(self): | ||
| 143 | + """ | ||
| 144 | + Context manager for tracking encode depth and clearing shared state. | ||
| 145 | + | ||
| 146 | + Shared state is cleared at the end of each top-level encode to prevent | ||
| 147 | + shared references from leaking between independent encode operations. | ||
| 148 | + Nested calls (from hooks) must preserve the state. | ||
| 149 | + """ | ||
| 150 | + self._encode_depth += 1 | ||
| 151 | + try: | ||
| 152 | + yield | ||
| 153 | + finally: | ||
| 154 | + self._encode_depth -= 1 | ||
| 155 | + if self._encode_depth == 0: | ||
| 156 | + self._shared_containers.clear() | ||
| 157 | + self._string_references.clear() | ||
| 158 | + | ||
| 159 | def encode(self, obj: Any) -> None: | ||
| 160 | """ | ||
| 161 | Encode the given object using CBOR. | ||
| 162 | @@ -310,6 +330,16 @@ class CBOREncoder: | ||
| 163 | :param obj: | ||
| 164 | the object to encode | ||
| 165 | """ | ||
| 166 | + with self._encoding_context(): | ||
| 167 | + self._encode_value(obj) | ||
| 168 | + | ||
| 169 | + def _encode_value(self, obj: Any) -> None: | ||
| 170 | + """ | ||
| 171 | + Internal fast path for encoding - used by built-in encoders. | ||
| 172 | + | ||
| 173 | + External code should use encode() instead, which properly manages | ||
| 174 | + shared state between independent encode operations. | ||
| 175 | + """ | ||
| 176 | obj_type = obj.__class__ | ||
| 177 | encoder = self._encoders.get(obj_type) or self._find_encoder(obj_type) or self._default | ||
| 178 | if not encoder: | ||
| 179 | @@ -459,7 +489,7 @@ class CBOREncoder: | ||
| 180 | def encode_array(self, value: Sequence[Any]) -> None: | ||
| 181 | self.encode_length(4, len(value) if not self.indefinite_containers else None) | ||
| 182 | for item in value: | ||
| 183 | - self.encode(item) | ||
| 184 | + self._encode_value(item) | ||
| 185 | |||
| 186 | if self.indefinite_containers: | ||
| 187 | self.encode_break() | ||
| 188 | @@ -468,8 +498,8 @@ class CBOREncoder: | ||
| 189 | def encode_map(self, value: Mapping[Any, Any]) -> None: | ||
| 190 | self.encode_length(5, len(value) if not self.indefinite_containers else None) | ||
| 191 | for key, val in value.items(): | ||
| 192 | - self.encode(key) | ||
| 193 | - self.encode(val) | ||
| 194 | + self._encode_value(key) | ||
| 195 | + self._encode_value(val) | ||
| 196 | |||
| 197 | if self.indefinite_containers: | ||
| 198 | self.encode_break() | ||
| 199 | @@ -494,10 +524,10 @@ class CBOREncoder: | ||
| 200 | # String referencing requires that the order encoded is | ||
| 201 | # the same as the order emitted so string references are | ||
| 202 | # generated after an order is determined | ||
| 203 | - self.encode(realkey) | ||
| 204 | + self._encode_value(realkey) | ||
| 205 | else: | ||
| 206 | self._fp_write(sortkey[1]) | ||
| 207 | - self.encode(value) | ||
| 208 | + self._encode_value(value) | ||
| 209 | |||
| 210 | if self.indefinite_containers: | ||
| 211 | self.encode_break() | ||
| 212 | @@ -511,7 +541,7 @@ class CBOREncoder: | ||
| 213 | self._string_references = {} | ||
| 214 | |||
| 215 | self.encode_length(6, value.tag) | ||
| 216 | - self.encode(value.value) | ||
| 217 | + self._encode_value(value.value) | ||
| 218 | |||
| 219 | self.string_referencing = old_string_referencing | ||
| 220 | self._string_references = old_string_references | ||
| 221 | @@ -574,7 +604,7 @@ class CBOREncoder: | ||
| 222 | def encode_stringref(self, value: str | bytes) -> None: | ||
| 223 | # Semantic tag 25 | ||
| 224 | if not self._stringref(value): | ||
| 225 | - self.encode(value) | ||
| 226 | + self._encode_value(value) | ||
| 227 | |||
| 228 | def encode_rational(self, value: Fraction) -> None: | ||
| 229 | # Semantic tag 30 | ||
| 230 | diff --git a/source/decoder.c b/source/decoder.c | ||
| 231 | index 8b6b842..b0bdb9a 100644 | ||
| 232 | --- a/source/decoder.c | ||
| 233 | +++ b/source/decoder.c | ||
| 234 | @@ -143,6 +143,7 @@ CBORDecoder_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) | ||
| 235 | self->str_errors = PyBytes_FromString("strict"); | ||
| 236 | self->immutable = false; | ||
| 237 | self->shared_index = -1; | ||
| 238 | + self->decode_depth = 0; | ||
| 239 | } | ||
| 240 | return (PyObject *) self; | ||
| 241 | error: | ||
| 242 | @@ -2083,11 +2084,30 @@ decode(CBORDecoderObject *self, DecodeOptions options) | ||
| 243 | } | ||
| 244 | |||
| 245 | |||
| 246 | +// Reset shared state at the end of each top-level decode to prevent | ||
| 247 | +// shared references from leaking between independent decode operations. | ||
| 248 | +// Nested calls (from hooks) must preserve the state. | ||
| 249 | +static inline void | ||
| 250 | +clear_shareable_state(CBORDecoderObject *self) | ||
| 251 | +{ | ||
| 252 | + PyList_SetSlice(self->shareables, 0, PY_SSIZE_T_MAX, NULL); | ||
| 253 | + self->shared_index = -1; | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | + | ||
| 257 | // CBORDecoder.decode(self) -> obj | ||
| 258 | PyObject * | ||
| 259 | CBORDecoder_decode(CBORDecoderObject *self) | ||
| 260 | { | ||
| 261 | - return decode(self, DECODE_NORMAL); | ||
| 262 | + PyObject *ret; | ||
| 263 | + self->decode_depth++; | ||
| 264 | + ret = decode(self, DECODE_NORMAL); | ||
| 265 | + self->decode_depth--; | ||
| 266 | + assert(self->decode_depth >= 0); | ||
| 267 | + if (self->decode_depth == 0) { | ||
| 268 | + clear_shareable_state(self); | ||
| 269 | + } | ||
| 270 | + return ret; | ||
| 271 | } | ||
| 272 | |||
| 273 | |||
| 274 | @@ -2100,6 +2120,7 @@ CBORDecoder_decode_from_bytes(CBORDecoderObject *self, PyObject *data) | ||
| 275 | if (!_CBOR2_BytesIO && _CBOR2_init_BytesIO() == -1) | ||
| 276 | return NULL; | ||
| 277 | |||
| 278 | + self->decode_depth++; | ||
| 279 | save_read = self->read; | ||
| 280 | buf = PyObject_CallFunctionObjArgs(_CBOR2_BytesIO, data, NULL); | ||
| 281 | if (buf) { | ||
| 282 | @@ -2111,6 +2132,11 @@ CBORDecoder_decode_from_bytes(CBORDecoderObject *self, PyObject *data) | ||
| 283 | Py_DECREF(buf); | ||
| 284 | } | ||
| 285 | self->read = save_read; | ||
| 286 | + self->decode_depth--; | ||
| 287 | + assert(self->decode_depth >= 0); | ||
| 288 | + if (self->decode_depth == 0) { | ||
| 289 | + clear_shareable_state(self); | ||
| 290 | + } | ||
| 291 | return ret; | ||
| 292 | } | ||
| 293 | |||
| 294 | diff --git a/source/decoder.h b/source/decoder.h | ||
| 295 | index 6bb6d52..a2f1bcb 100644 | ||
| 296 | --- a/source/decoder.h | ||
| 297 | +++ b/source/decoder.h | ||
| 298 | @@ -13,6 +13,7 @@ typedef struct { | ||
| 299 | PyObject *str_errors; | ||
| 300 | bool immutable; | ||
| 301 | Py_ssize_t shared_index; | ||
| 302 | + Py_ssize_t decode_depth; | ||
| 303 | } CBORDecoderObject; | ||
| 304 | |||
| 305 | extern PyTypeObject CBORDecoderType; | ||
| 306 | diff --git a/source/encoder.c b/source/encoder.c | ||
| 307 | index 4dc3c6b..e87670d 100644 | ||
| 308 | --- a/source/encoder.c | ||
| 309 | +++ b/source/encoder.c | ||
| 310 | @@ -114,6 +114,7 @@ CBOREncoder_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) | ||
| 311 | self->string_referencing = false; | ||
| 312 | self->string_namespacing = false; | ||
| 313 | self->indefinite_containers = false; | ||
| 314 | + self->encode_depth = 0; | ||
| 315 | } | ||
| 316 | return (PyObject *) self; | ||
| 317 | } | ||
| 318 | @@ -2132,17 +2133,35 @@ encode(CBOREncoderObject *self, PyObject *value) | ||
| 319 | } | ||
| 320 | |||
| 321 | |||
| 322 | +// Reset shared state at the end of each top-level encode to prevent | ||
| 323 | +// shared references from leaking between independent encode operations. | ||
| 324 | +// Nested calls (from hooks or recursive encoding) must preserve the state. | ||
| 325 | +static inline void | ||
| 326 | +clear_shared_state(CBOREncoderObject *self) | ||
| 327 | +{ | ||
| 328 | + PyDict_Clear(self->shared); | ||
| 329 | + PyDict_Clear(self->string_references); | ||
| 330 | +} | ||
| 331 | + | ||
| 332 | + | ||
| 333 | // CBOREncoder.encode(self, value) | ||
| 334 | PyObject * | ||
| 335 | CBOREncoder_encode(CBOREncoderObject *self, PyObject *value) | ||
| 336 | { | ||
| 337 | PyObject *ret; | ||
| 338 | |||
| 339 | - // TODO reset shared dict? | ||
| 340 | - if (Py_EnterRecursiveCall(" in CBOREncoder.encode")) | ||
| 341 | + self->encode_depth++; | ||
| 342 | + if (Py_EnterRecursiveCall(" in CBOREncoder.encode")) { | ||
| 343 | + self->encode_depth--; | ||
| 344 | return NULL; | ||
| 345 | + } | ||
| 346 | ret = encode(self, value); | ||
| 347 | Py_LeaveRecursiveCall(); | ||
| 348 | + self->encode_depth--; | ||
| 349 | + assert(self->encode_depth >= 0); | ||
| 350 | + if (self->encode_depth == 0) { | ||
| 351 | + clear_shared_state(self); | ||
| 352 | + } | ||
| 353 | return ret; | ||
| 354 | } | ||
| 355 | |||
| 356 | diff --git a/source/encoder.h b/source/encoder.h | ||
| 357 | index abc6560..915f1f2 100644 | ||
| 358 | --- a/source/encoder.h | ||
| 359 | +++ b/source/encoder.h | ||
| 360 | @@ -25,6 +25,7 @@ typedef struct { | ||
| 361 | bool string_referencing; | ||
| 362 | bool string_namespacing; | ||
| 363 | bool indefinite_containers; | ||
| 364 | + Py_ssize_t encode_depth; | ||
| 365 | } CBOREncoderObject; | ||
| 366 | |||
| 367 | extern PyTypeObject CBOREncoderType; | ||
| 368 | diff --git a/tests/test_decoder.py b/tests/test_decoder.py | ||
| 369 | index 0f4af4d..c8b47d5 100644 | ||
| 370 | --- a/tests/test_decoder.py | ||
| 371 | +++ b/tests/test_decoder.py | ||
| 372 | @@ -1022,3 +1022,65 @@ def test_oversized_read(impl, payload: bytes, tmp_path: Path) -> None: | ||
| 373 | dummy_path.write_bytes(payload) | ||
| 374 | with dummy_path.open("rb") as f: | ||
| 375 | impl.load(f) | ||
| 376 | + | ||
| 377 | + | ||
| 378 | +class TestDecoderReuse: | ||
| 379 | + """ | ||
| 380 | + Tests for correct behavior when reusing CBORDecoder instances. | ||
| 381 | + """ | ||
| 382 | + | ||
| 383 | + def test_decoder_reuse_resets_shared_refs(self, impl): | ||
| 384 | + """ | ||
| 385 | + Shared references should be scoped to a single decode operation, | ||
| 386 | + not persist across multiple decodes on the same decoder instance. | ||
| 387 | + """ | ||
| 388 | + # Message with shareable tag (28) | ||
| 389 | + msg1 = impl.dumps(impl.CBORTag(28, "first_value")) | ||
| 390 | + | ||
| 391 | + # Message with sharedref tag (29) referencing index 0 | ||
| 392 | + msg2 = impl.dumps(impl.CBORTag(29, 0)) | ||
| 393 | + | ||
| 394 | + # Reuse decoder across messages | ||
| 395 | + decoder = impl.CBORDecoder(BytesIO(msg1)) | ||
| 396 | + result1 = decoder.decode() | ||
| 397 | + assert result1 == "first_value" | ||
| 398 | + | ||
| 399 | + # Second decode should fail - sharedref(0) doesn't exist in this context | ||
| 400 | + decoder.fp = BytesIO(msg2) | ||
| 401 | + with pytest.raises(impl.CBORDecodeValueError, match="shared reference"): | ||
| 402 | + decoder.decode() | ||
| 403 | + | ||
| 404 | + def test_decode_from_bytes_resets_shared_refs(self, impl): | ||
| 405 | + """ | ||
| 406 | + decode_from_bytes should also reset shared references between calls. | ||
| 407 | + """ | ||
| 408 | + msg1 = impl.dumps(impl.CBORTag(28, "value")) | ||
| 409 | + msg2 = impl.dumps(impl.CBORTag(29, 0)) | ||
| 410 | + | ||
| 411 | + decoder = impl.CBORDecoder(BytesIO(b"")) | ||
| 412 | + decoder.decode_from_bytes(msg1) | ||
| 413 | + | ||
| 414 | + with pytest.raises(impl.CBORDecodeValueError, match="shared reference"): | ||
| 415 | + decoder.decode_from_bytes(msg2) | ||
| 416 | + | ||
| 417 | + def test_shared_refs_within_single_decode(self, impl): | ||
| 418 | + """ | ||
| 419 | + Shared references must work correctly within a single decode operation. | ||
| 420 | + | ||
| 421 | + Note: This tests non-cyclic sibling references [shareable(x), sharedref(0)], | ||
| 422 | + which is a different pattern from test_cyclic_array/test_cyclic_map that | ||
| 423 | + test self-referencing structures like shareable([sharedref(0)]). | ||
| 424 | + """ | ||
| 425 | + # [shareable("hello"), sharedref(0)] -> ["hello", "hello"] | ||
| 426 | + data = unhexlify( | ||
| 427 | + "82" # array(2) | ||
| 428 | + "d81c" # tag(28) shareable | ||
| 429 | + "65" # text(5) | ||
| 430 | + "68656c6c6f" # "hello" | ||
| 431 | + "d81d" # tag(29) sharedref | ||
| 432 | + "00" # unsigned(0) | ||
| 433 | + ) | ||
| 434 | + | ||
| 435 | + result = impl.loads(data) | ||
| 436 | + assert result == ["hello", "hello"] | ||
| 437 | + assert result[0] is result[1] # Same object reference | ||
| 438 | diff --git a/tests/test_encoder.py b/tests/test_encoder.py | ||
| 439 | index cbb4295..e6adc08 100644 | ||
| 440 | --- a/tests/test_encoder.py | ||
| 441 | +++ b/tests/test_encoder.py | ||
| 442 | @@ -717,3 +717,73 @@ def test_indefinite_containers(impl): | ||
| 443 | expected = b"\xbf\xff" | ||
| 444 | assert impl.dumps({}, indefinite_containers=True) == expected | ||
| 445 | assert impl.dumps({}, indefinite_containers=True, canonical=True) == expected | ||
| 446 | + | ||
| 447 | + | ||
| 448 | +class TestEncoderReuse: | ||
| 449 | + """ | ||
| 450 | + Tests for correct behavior when reusing CBOREncoder instances. | ||
| 451 | + """ | ||
| 452 | + | ||
| 453 | + def test_encoder_reuse_resets_shared_containers(self, impl): | ||
| 454 | + """ | ||
| 455 | + Shared container tracking should be scoped to a single encode operation, | ||
| 456 | + not persist across multiple encodes on the same encoder instance. | ||
| 457 | + """ | ||
| 458 | + fp = BytesIO() | ||
| 459 | + encoder = impl.CBOREncoder(fp, value_sharing=True) | ||
| 460 | + shared_obj = ["hello"] | ||
| 461 | + | ||
| 462 | + # First encode: object is tracked in shared containers | ||
| 463 | + encoder.encode([shared_obj, shared_obj]) | ||
| 464 | + | ||
| 465 | + # Second encode on new fp: should produce valid standalone CBOR | ||
| 466 | + # (not a sharedref pointing to stale first-encode data) | ||
| 467 | + encoder.fp = BytesIO() | ||
| 468 | + encoder.encode(shared_obj) | ||
| 469 | + second_output = encoder.fp.getvalue() | ||
| 470 | + | ||
| 471 | + # The second output must be decodable on its own | ||
| 472 | + result = impl.loads(second_output) | ||
| 473 | + assert result == ["hello"] | ||
| 474 | + | ||
| 475 | + def test_encode_to_bytes_resets_shared_containers(self, impl): | ||
| 476 | + """ | ||
| 477 | + encode_to_bytes should also reset shared container tracking between calls. | ||
| 478 | + """ | ||
| 479 | + fp = BytesIO() | ||
| 480 | + encoder = impl.CBOREncoder(fp, value_sharing=True) | ||
| 481 | + shared_obj = ["hello"] | ||
| 482 | + | ||
| 483 | + # First encode | ||
| 484 | + encoder.encode_to_bytes([shared_obj, shared_obj]) | ||
| 485 | + | ||
| 486 | + # Second encode should produce valid standalone CBOR | ||
| 487 | + result_bytes = encoder.encode_to_bytes(shared_obj) | ||
| 488 | + result = impl.loads(result_bytes) | ||
| 489 | + assert result == ["hello"] | ||
| 490 | + | ||
| 491 | + def test_encoder_hook_does_not_reset_state(self, impl): | ||
| 492 | + """ | ||
| 493 | + When a custom encoder hook calls encode(), the shared container | ||
| 494 | + tracking should be preserved (not reset mid-operation). | ||
| 495 | + """ | ||
| 496 | + | ||
| 497 | + class Custom: | ||
| 498 | + def __init__(self, value): | ||
| 499 | + self.value = value | ||
| 500 | + | ||
| 501 | + def custom_encoder(encoder, obj): | ||
| 502 | + # Hook encodes the wrapped value | ||
| 503 | + encoder.encode(obj.value) | ||
| 504 | + | ||
| 505 | + # Encode a Custom wrapping a list | ||
| 506 | + data = impl.dumps(Custom(["a", "b"]), default=custom_encoder) | ||
| 507 | + | ||
| 508 | + # Verify the output decodes correctly | ||
| 509 | + result = impl.loads(data) | ||
| 510 | + assert result == ["a", "b"] | ||
| 511 | + | ||
| 512 | + # Test nested Custom objects - hook should work recursively | ||
| 513 | + data2 = impl.dumps(Custom(Custom(["x"])), default=custom_encoder) | ||
| 514 | + result2 = impl.loads(data2) | ||
| 515 | + assert result2 == ["x"] | ||
