Skip to content

Commit c6796a1

Browse files
authored
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.
1 parent 3e19e04 commit c6796a1

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

‎Lib/test/test_io/test_textio.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,41 @@ def _to_memoryview(buf):
14481448
class CTextIOWrapperTest(TextIOWrapperTest, CTestCase):
14491449
shutdown_error = "LookupError: unknown encoding: ascii"
14501450

1451+
def test_write_newline_replace_returns_non_string(self):
1452+
class Text(str):
1453+
def replace(self, *args):
1454+
return None
1455+
1456+
with self.TextIOWrapper(self.BytesIO(), newline='\r\n') as stream:
1457+
with self.assertRaisesRegex(TypeError, 'replace.*str'):
1458+
stream.write(Text('a\n'))
1459+
1460+
def test_write_newline_replace_changes_stream(self):
1461+
def fail_init(stream, buffer):
1462+
with self.assertRaises(LookupError):
1463+
stream.__init__(buffer, encoding='nonexistent-encoding')
1464+
1465+
actions = (
1466+
(lambda stream, buffer: stream.close(), ValueError, 'closed'),
1467+
(lambda stream, buffer: stream.detach(), ValueError, 'detached'),
1468+
(lambda stream, buffer: stream.__init__(buffer),
1469+
self.UnsupportedOperation, 'not writable'),
1470+
(fail_init, ValueError, 'uninitialized'),
1471+
)
1472+
for action, error, message in actions:
1473+
with self.subTest(message=message):
1474+
with (self.BytesIO() as raw,
1475+
self.BufferedReader(self.BytesIO()) as readonly):
1476+
stream = self.TextIOWrapper(raw, newline='\r\n')
1477+
1478+
class Text(str):
1479+
def replace(self, *args):
1480+
action(stream, readonly)
1481+
return 'a\r\n'
1482+
1483+
with self.assertRaisesRegex(error, message):
1484+
stream.write(Text('a\n'))
1485+
14511486
def test_chunk_size(self):
14521487
t = self.TextIOWrapper(self.BytesIO(), encoding="utf-8")
14531488
self.assertGreater(t._CHUNK_SIZE, 0)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Raise :exc:`TypeError` if a string subclass returns a non-string during newline
2+
translation in :class:`io.TextIOWrapper`. Recheck the stream state if the
3+
replacement callback closes, detaches or reinitializes the stream.

‎Modules/_io/textio.c‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,6 +1730,19 @@ _textiowrapper_writeflush(textio *self)
17301730
return 0;
17311731
}
17321732

1733+
/* Return borrowed Py_None on success, or NULL on error. */
1734+
static PyObject *
1735+
textiowrapper_check_write(textio *self)
1736+
{
1737+
CHECK_ATTACHED(self);
1738+
CHECK_CLOSED(self);
1739+
1740+
if (self->encoder == NULL) {
1741+
return _unsupported(self->state, "not writable");
1742+
}
1743+
return Py_None;
1744+
}
1745+
17331746
/*[clinic input]
17341747
@critical_section
17351748
_io.TextIOWrapper.write
@@ -1747,11 +1760,8 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text)
17471760
int haslf = 0;
17481761
int needflush = 0, text_needflush = 0;
17491762

1750-
CHECK_ATTACHED(self);
1751-
CHECK_CLOSED(self);
1752-
1753-
if (self->encoder == NULL) {
1754-
return _unsupported(self->state, "not writable");
1763+
if (textiowrapper_check_write(self) == NULL) {
1764+
return NULL;
17551765
}
17561766

17571767
Py_INCREF(text);
@@ -1768,6 +1778,17 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text)
17681778
Py_DECREF(text);
17691779
if (newtext == NULL)
17701780
return NULL;
1781+
if (!PyUnicode_Check(newtext)) {
1782+
PyErr_Format(PyExc_TypeError,
1783+
"replace() should return a str, not %T", newtext);
1784+
Py_DECREF(newtext);
1785+
return NULL;
1786+
}
1787+
/* replace() may have closed, detached or reinitialized the stream. */
1788+
if (textiowrapper_check_write(self) == NULL) {
1789+
Py_DECREF(newtext);
1790+
return NULL;
1791+
}
17711792
text = newtext;
17721793
}
17731794

0 commit comments

Comments
 (0)