https://github.com/python/cpython/commit/c6796a1268292bca0711b05557130ba3406f9850
commit: c6796a1268292bca0711b05557130ba3406f9850
branch: main
author: Jelle Zijlstra <[email protected]>
committer: JelleZijlstra <[email protected]>
date: 2026-10-22T14:17:22-07:00
summary:

gh-159739: Validate newline replacement in TextIOWrapper.write (#160191)



A str subclass can override replace() and return a non-string while 
TextIOWrapper.write() translates newlines. The C implementation then treats the 
result as Unicode and can crash. Reject an invalid replacement with TypeError. 
Backport to 3.15 and 3.14; on 3.14, put the regression test in 
Lib/test/test_io.py.

files:
A Misc/NEWS.d/next/Library/2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst
M Lib/test/test_io/test_textio.py
M Modules/_io/textio.c

diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py
index 65b0812f071e21..04867f113b05a9 100644
--- a/Lib/test/test_io/test_textio.py
+++ b/Lib/test/test_io/test_textio.py
@@ -1448,6 +1448,41 @@ def _to_memoryview(buf):
 class CTextIOWrapperTest(TextIOWrapperTest, CTestCase):
     shutdown_error = "LookupError: unknown encoding: ascii"
 
+    def test_write_newline_replace_returns_non_string(self):
+        class Text(str):
+            def replace(self, *args):
+                return None
+
+        with self.TextIOWrapper(self.BytesIO(), newline='\r\n') as stream:
+            with self.assertRaisesRegex(TypeError, 'replace.*str'):
+                stream.write(Text('a\n'))
+
+    def test_write_newline_replace_changes_stream(self):
+        def fail_init(stream, buffer):
+            with self.assertRaises(LookupError):
+                stream.__init__(buffer, encoding='nonexistent-encoding')
+
+        actions = (
+            (lambda stream, buffer: stream.close(), ValueError, 'closed'),
+            (lambda stream, buffer: stream.detach(), ValueError, 'detached'),
+            (lambda stream, buffer: stream.__init__(buffer),
+             self.UnsupportedOperation, 'not writable'),
+            (fail_init, ValueError, 'uninitialized'),
+        )
+        for action, error, message in actions:
+            with self.subTest(message=message):
+                with (self.BytesIO() as raw,
+                      self.BufferedReader(self.BytesIO()) as readonly):
+                    stream = self.TextIOWrapper(raw, newline='\r\n')
+
+                    class Text(str):
+                        def replace(self, *args):
+                            action(stream, readonly)
+                            return 'a\r\n'
+
+                    with self.assertRaisesRegex(error, message):
+                        stream.write(Text('a\n'))
+
     def test_chunk_size(self):
         t = self.TextIOWrapper(self.BytesIO(), encoding="utf-8")
         self.assertGreater(t._CHUNK_SIZE, 0)
diff --git 
a/Misc/NEWS.d/next/Library/2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst 
b/Misc/NEWS.d/next/Library/2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst
new file mode 100644
index 00000000000000..24dd0b7c7776e3
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst
@@ -0,0 +1,3 @@
+Raise :exc:`TypeError` if a string subclass returns a non-string during newline
+translation in :class:`io.TextIOWrapper`. Recheck the stream state if the
+replacement callback closes, detaches or reinitializes the stream.
diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c
index f33ab46b10fd2e..7523b516f49eb5 100644
--- a/Modules/_io/textio.c
+++ b/Modules/_io/textio.c
@@ -1730,6 +1730,19 @@ _textiowrapper_writeflush(textio *self)
     return 0;
 }
 
+/* Return borrowed Py_None on success, or NULL on error. */
+static PyObject *
+textiowrapper_check_write(textio *self)
+{
+    CHECK_ATTACHED(self);
+    CHECK_CLOSED(self);
+
+    if (self->encoder == NULL) {
+        return _unsupported(self->state, "not writable");
+    }
+    return Py_None;
+}
+
 /*[clinic input]
 @critical_section
 _io.TextIOWrapper.write
@@ -1747,11 +1760,8 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject 
*text)
     int haslf = 0;
     int needflush = 0, text_needflush = 0;
 
-    CHECK_ATTACHED(self);
-    CHECK_CLOSED(self);
-
-    if (self->encoder == NULL) {
-        return _unsupported(self->state, "not writable");
+    if (textiowrapper_check_write(self) == NULL) {
+        return NULL;
     }
 
     Py_INCREF(text);
@@ -1768,6 +1778,17 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject 
*text)
         Py_DECREF(text);
         if (newtext == NULL)
             return NULL;
+        if (!PyUnicode_Check(newtext)) {
+            PyErr_Format(PyExc_TypeError,
+                         "replace() should return a str, not %T", newtext);
+            Py_DECREF(newtext);
+            return NULL;
+        }
+        /* replace() may have closed, detached or reinitialized the stream. */
+        if (textiowrapper_check_write(self) == NULL) {
+            Py_DECREF(newtext);
+            return NULL;
+        }
         text = newtext;
     }
 

_______________________________________________
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