diff --git a/Include/internal/pycore_bytesobject.h b/Include/internal/pycore_bytesobject.h index 8f764f0fa6d6e1..e709940ad98431 100644 --- a/Include/internal/pycore_bytesobject.h +++ b/Include/internal/pycore_bytesobject.h @@ -88,6 +88,22 @@ extern void _PyBytes_CheckOverflow( const char *type_name); #endif + +// Return the cached hash value, or -1 if not cached yet. +static inline Py_hash_t +_PyBytes_GET_CACHED_HASH(PyBytesObject *self) +{ +_Py_COMP_DIAG_PUSH +_Py_COMP_DIAG_IGNORE_DEPR_DECLS +#ifdef Py_GIL_DISABLED + return _Py_atomic_load_ssize_relaxed(&self->ob_shash); +#else + return self->ob_shash; +#endif +_Py_COMP_DIAG_POP +} + + /* --- PyBytesWriter ------------------------------------------------------ */ struct PyBytesWriter { diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 419bee5583de47..ad5091b54d7911 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -5,14 +5,15 @@ """ import array +import codecs import contextlib +import copy +import functools import operator import os +import pickle import re import sys -import copy -import functools -import pickle import tempfile import textwrap import threading @@ -1706,6 +1707,29 @@ def test_take_bytes_optimization(self): bytes_header_size = sys.getsizeof(b'') self.assertEqual(ba.__alloc__(), 499 + bytes_header_size) + def test_take_bytes_hash(self): + # gh-158219: bytearray constructor must not use a bytes object + # if its hash value is already cached. + + def encode(string, errors='strict'): + encoded = string.encode('utf-8') + hash(encoded) # a codec may hash its own output + return encoded, len(string) + + def hashing_codec(name): + if name != 'test_take_bytes_hash': + return None + return codecs.CodecInfo(encode, None, name=name) + + codecs.register(hashing_codec) + self.addCleanup(codecs.unregister, hashing_codec) + + ba = bytearray('hello', 'test_take_bytes_hash') + ba[0] = ord('H') + taken = ba.take_bytes() + self.assertEqual(taken, b'Hello') + self.assertEqual(hash(taken), hash(b'Hello')) + def test_take_bytes_reentrant_resize(self): # gh-153570: n.__index__() can resize the bytearray, so take_bytes() # must re-read the size afterwards. It cached the size before the diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-28-11-52-49.gh-issue-158219.tNjBVV.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-28-11-52-49.gh-issue-158219.tNjBVV.rst new file mode 100644 index 00000000000000..691a588e03c2b2 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-28-11-52-49.gh-issue-158219.tNjBVV.rst @@ -0,0 +1,2 @@ +Fix :class:`bytearray` constructor: do not use a bytes object if its hash +value is already computed. Patch by Cody Maloney and Victor Stinner. diff --git a/Objects/bytearrayobject.c b/Objects/bytearrayobject.c index 16c38403547818..caf8bad88a280d 100644 --- a/Objects/bytearrayobject.c +++ b/Objects/bytearrayobject.c @@ -58,8 +58,13 @@ bytearray_reinit_from_bytes(PyByteArrayObject *self, Py_ssize_t size) Py_ssize_t alloc = PyBytes_GET_SIZE(self->ob_bytes_object); assert(0 <= size && size <= alloc); - /* Only the empty bytes may be immortal. */ - assert((alloc == 0) == _Py_IsImmortal(self->ob_bytes_object)); + if (alloc != 0) { + assert(_PyBytes_IsMutable(self->ob_bytes_object)); + } + else { + // Use the empty bytes string singleton for an empty bytearray + assert(_Py_IsImmortal(self->ob_bytes_object)); + } self->ob_bytes = self->ob_start = PyBytes_AS_STRING(self->ob_bytes_object); Py_SET_SIZE(self, size); @@ -1030,7 +1035,8 @@ bytearray___init___impl(PyByteArrayObject *self, PyObject *arg, /* Most encodes return a new unique bytes, just use it as buffer. */ if (_PyObject_IsUniquelyReferenced(encoded) - && PyBytes_CheckExact(encoded)) + && PyBytes_CheckExact(encoded) + && _PyBytes_GET_CACHED_HASH((PyBytesObject*)encoded) == -1) { Py_ssize_t size = PyBytes_GET_SIZE(encoded); self->ob_bytes_object = encoded; diff --git a/Objects/bytesobject.c b/Objects/bytesobject.c index 91cbfa23e30b20..683306fe724a5b 100644 --- a/Objects/bytesobject.c +++ b/Objects/bytesobject.c @@ -63,18 +63,7 @@ _Py_COMP_DIAG_IGNORE_DEPR_DECLS _Py_COMP_DIAG_POP } -static inline Py_hash_t -get_ob_shash(PyBytesObject *a) -{ -_Py_COMP_DIAG_PUSH -_Py_COMP_DIAG_IGNORE_DEPR_DECLS -#ifdef Py_GIL_DISABLED - return _Py_atomic_load_ssize_relaxed(&a->ob_shash); -#else - return a->ob_shash; -#endif -_Py_COMP_DIAG_POP -} +#define get_ob_shash(op) _PyBytes_GET_CACHED_HASH(op) /* @@ -3346,6 +3335,12 @@ _PyBytes_IsMutable(PyObject *self) unsigned char ch = PyBytes_AS_STRING(self)[0]; assert(self != (PyObject*)CHARACTER(ch)); } + + // gh-158219: The hash value must not be cached yet. Otherwise, it means + // that the bytes object was already used in Python somehow (ex: as a + // dictionary key). + assert(get_ob_shash((PyBytesObject *)self) == -1); + return 1; } #endif