From 5721af00e2034eba1e50f9ea2e74e938dc0db53e Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 21 Oct 2026 21:29:32 -0700 Subject: [PATCH 1/2] gh-159739: Validate newline replacement in TextIOWrapper.write 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. --- Lib/test/test_io/test_textio.py | 9 +++++++++ .../2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst | 1 + Modules/_io/textio.c | 6 ++++++ 3 files changed, 16 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 65b0812f071e215..40d62f40559d1a0 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1448,6 +1448,15 @@ 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_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 000000000000000..828c3bb5e9f0c08 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-21-21-26-10.gh-issue-159739.k8uuGe.rst @@ -0,0 +1 @@ +Raise :exc:`TypeError` if a string subclass returns a non-string during newline translation in :class:`io.TextIOWrapper`. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index f33ab46b10fd2e9..aca376a1a1ba7ff 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1768,6 +1768,12 @@ _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; + } text = newtext; } From eed863577e15cc15e9460c5e82eba8ba78865374 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 22 Oct 2026 11:38:37 -0700 Subject: [PATCH 2/2] gh-159739: Recheck text stream state after newline replacement --- Lib/test/test_io/test_textio.py | 26 +++++++++++++++++++ ...-10-21-21-26-10.gh-issue-159739.k8uuGe.rst | 4 ++- Modules/_io/textio.c | 25 ++++++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 40d62f40559d1a0..04867f113b05a98 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1457,6 +1457,32 @@ def replace(self, *args): 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 index 828c3bb5e9f0c08..24dd0b7c7776e34 100644 --- 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 @@ -1 +1,3 @@ -Raise :exc:`TypeError` if a string subclass returns a non-string during newline translation in :class:`io.TextIOWrapper`. +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 aca376a1a1ba7ff..7523b516f49eb5e 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); @@ -1774,6 +1784,11 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) 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; }