https://github.com/python/cpython/commit/fd657b067473fdf549dfab4761a739e03894a659
commit: fd657b067473fdf549dfab4761a739e03894a659
branch: main
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-10-23T00:36:04Z
summary:

gh-160181: Don't modify a hashed bytes buffer in place in io.BytesIO (GH-160182)

Treat the internal buffer as shared once its hash is cached, so the next
modification copies it, as gh-158219 did for bytearray.

ANT-2026-IZN6JY41

files:
A Misc/NEWS.d/next/Library/2026-10-22-00-32-51.gh-issue-160181.nnEeg1.rst
M Lib/test/test_io/test_memoryio.py
M Modules/_io/bytesio.c

diff --git a/Lib/test/test_io/test_memoryio.py 
b/Lib/test/test_io/test_memoryio.py
index 999bd5bc007b2e..6dafc308bf4e62 100644
--- a/Lib/test/test_io/test_memoryio.py
+++ b/Lib/test/test_io/test_memoryio.py
@@ -1076,6 +1076,52 @@ def test_cow_mutable(self):
         memio = self.ioclass(ba)
         self.assertEqual(sys.getrefcount(ba), old_rc)
 
+    def test_cow_hashed(self):
+        # A buffer which was exposed to Python code and hashed must not be
+        # modified in place, even if BytesIO holds the only reference to it:
+        # it would keep the cached hash of its old contents.
+        def write(memio):
+            memio.seek(0)
+            memio.write(b"efgh")
+        def getbuffer(memio):
+            with memio.getbuffer() as view:
+                view[:] = b"efgh"
+        def truncate(memio):
+            memio.truncate(2)
+            memio.seek(0)
+            memio.write(b"efgh")
+        def getvalue(memio):
+            return memio.getvalue()
+        def read(memio):
+            memio.seek(0)
+            return memio.read()
+        def peek(memio):
+            memio.seek(0)
+            return memio.peek(4)
+        for mutation in write, getbuffer, truncate:
+            for expose in getvalue, read, peek:
+                with self.subTest(mutation=mutation.__name__,
+                                  expose=expose.__name__):
+                    memio = self.ioclass()
+                    memio.write(b"abcd")
+                    # Shrink the overallocated buffer to the exact size,
+                    # so read() and peek() can return it without copying.
+                    memio.getvalue()
+                    hash(expose(memio))
+                    mutation(memio)
+                    self.assertEqual(memio.getvalue(), b"efgh")
+                    self.assertEqual(hash(memio.getvalue()), hash(b"efgh"))
+                    self.assertIn(memio.getvalue(), {b"efgh"})
+
+            with self.subTest(mutation=mutation.__name__, expose="__init__"):
+                initial = bytes(bytearray(b"abcd"))
+                hash(initial)
+                memio = self.ioclass(initial)
+                del initial
+                mutation(memio)
+                self.assertEqual(memio.getvalue(), b"efgh")
+                self.assertEqual(hash(memio.getvalue()), hash(b"efgh"))
+
     def test_write_with_export(self):
         memio = self.ioclass(b"abcd")
         memio.seek(2)
diff --git 
a/Misc/NEWS.d/next/Library/2026-10-22-00-32-51.gh-issue-160181.nnEeg1.rst 
b/Misc/NEWS.d/next/Library/2026-10-22-00-32-51.gh-issue-160181.nnEeg1.rst
new file mode 100644
index 00000000000000..5644b99cce1720
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-22-00-32-51.gh-issue-160181.nnEeg1.rst
@@ -0,0 +1,3 @@
+Fix :meth:`io.BytesIO.getvalue` sometimes returning a :class:`bytes` object
+with the hash of earlier contents after the buffer was written to, which made
+:class:`dict` and :class:`set` lookups with it fail.
diff --git a/Modules/_io/bytesio.c b/Modules/_io/bytesio.c
index aa74d9b0c721a4..01f63f8a677d37 100644
--- a/Modules/_io/bytesio.c
+++ b/Modules/_io/bytesio.c
@@ -39,8 +39,8 @@ typedef struct {
 #define bytesiobuf_CAST(op) ((bytesiobuf *)(op))
 
 /* The bytesio object can be in three states:
-  * Py_REFCNT(buf) == 1, exports == 0.
-  * Py_REFCNT(buf) > 1.  exports == 0,
+  * Py_REFCNT(buf) == 1, the hash of buf is not cached, exports == 0.
+  * Py_REFCNT(buf) > 1 or the hash of buf is cached.  exports == 0,
     first modification or export causes the internal buffer copying.
   * exports > 0.  Any modifications are forbidden.  Every exported buffer
     keeps a reference to buf, so it outlives closing of the bytesio object.
@@ -77,10 +77,19 @@ check_exports(bytesio *self)
         return NULL; \
     }
 
+/* If the hash value is cached, the internal bytes object was already used in
+   Python somehow (ex: as a dictionary key).  Modifying it in place would leave
+   the hash of the old contents, even if nothing else references it anymore. */
+#define HASHED_BUF(self) \
+    (_PyBytes_GET_CACHED_HASH((PyBytesObject *)(self)->buf) != -1)
+
 #ifdef Py_GIL_DISABLED
-#define SHARED_BUF(self) ((self)->buf_shared || 
!_PyObject_IsUniquelyReferenced((self)->buf))
+#define SHARED_BUF(self) \
+    ((self)->buf_shared || !_PyObject_IsUniquelyReferenced((self)->buf) \
+     || HASHED_BUF(self))
 #else
-#define SHARED_BUF(self) (!_PyObject_IsUniquelyReferenced((self)->buf))
+#define SHARED_BUF(self) \
+    (!_PyObject_IsUniquelyReferenced((self)->buf) || HASHED_BUF(self))
 #endif
 
 static inline void
@@ -269,6 +278,7 @@ write_bytes_lock_held(bytesio *self, PyObject *b)
             goto done;
         }
     }
+    assert(_PyBytes_IsMutable(self->buf));
 
     if (self->pos > self->string_size) {
         /* In case of overseek, pad with null bytes the buffer region between

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to