Skip to content

Commit cdbb078

Browse files
committed
[3.14] gh-159739: Validate newline replacement in TextIOWrapper.write (GH-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. (cherry picked from commit c6796a1) Co-authored-by: Jelle Zijlstra <jelle@openai.com>
1 parent a2916fe commit cdbb078

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

‎Lib/test/test_io.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4099,6 +4099,41 @@ class CTextIOWrapperTest(TextIOWrapperTest):
40994099
io = io
41004100
shutdown_error = "LookupError: unknown encoding: ascii"
41014101

4102+
def test_write_newline_replace_returns_non_string(self):
4103+
class Text(str):
4104+
def replace(self, *args):
4105+
return None
4106+
4107+
with self.TextIOWrapper(self.BytesIO(), newline='\r\n') as stream:
4108+
with self.assertRaisesRegex(TypeError, 'replace.*str'):
4109+
stream.write(Text('a\n'))
4110+
4111+
def test_write_newline_replace_changes_stream(self):
4112+
def fail_init(stream, buffer):
4113+
with self.assertRaises(LookupError):
4114+
stream.__init__(buffer, encoding='nonexistent-encoding')
4115+
4116+
actions = (
4117+
(lambda stream, buffer: stream.close(), ValueError, 'closed'),
4118+
(lambda stream, buffer: stream.detach(), ValueError, 'detached'),
4119+
(lambda stream, buffer: stream.__init__(buffer),
4120+
self.UnsupportedOperation, 'not writable'),
4121+
(fail_init, ValueError, 'uninitialized'),
4122+
)
4123+
for action, error, message in actions:
4124+
with self.subTest(message=message):
4125+
with (self.BytesIO() as raw,
4126+
self.BufferedReader(self.BytesIO()) as readonly):
4127+
stream = self.TextIOWrapper(raw, newline='\r\n')
4128+
4129+
class Text(str):
4130+
def replace(self, *args):
4131+
action(stream, readonly)
4132+
return 'a\r\n'
4133+
4134+
with self.assertRaisesRegex(error, message):
4135+
stream.write(Text('a\n'))
4136+
41024137
def test_reentrant_seek_during_tell(self):
41034138
# gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the
41044139
# snapshot, so tell() re-decodes and calls the decoder's getstate(); a
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
@@ -1722,6 +1722,19 @@ _textiowrapper_writeflush(textio *self)
17221722
return 0;
17231723
}
17241724

1725+
/* Return borrowed Py_None on success, or NULL on error. */
1726+
static PyObject *
1727+
textiowrapper_check_write(textio *self)
1728+
{
1729+
CHECK_ATTACHED(self);
1730+
CHECK_CLOSED(self);
1731+
1732+
if (self->encoder == NULL) {
1733+
return _unsupported(self->state, "not writable");
1734+
}
1735+
return Py_None;
1736+
}
1737+
17251738
/*[clinic input]
17261739
@critical_section
17271740
_io.TextIOWrapper.write
@@ -1739,11 +1752,8 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text)
17391752
int haslf = 0;
17401753
int needflush = 0, text_needflush = 0;
17411754

1742-
CHECK_ATTACHED(self);
1743-
CHECK_CLOSED(self);
1744-
1745-
if (self->encoder == NULL) {
1746-
return _unsupported(self->state, "not writable");
1755+
if (textiowrapper_check_write(self) == NULL) {
1756+
return NULL;
17471757
}
17481758

17491759
Py_INCREF(text);
@@ -1760,6 +1770,17 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text)
17601770
Py_DECREF(text);
17611771
if (newtext == NULL)
17621772
return NULL;
1773+
if (!PyUnicode_Check(newtext)) {
1774+
PyErr_Format(PyExc_TypeError,
1775+
"replace() should return a str, not %T", newtext);
1776+
Py_DECREF(newtext);
1777+
return NULL;
1778+
}
1779+
/* replace() may have closed, detached or reinitialized the stream. */
1780+
if (textiowrapper_check_write(self) == NULL) {
1781+
Py_DECREF(newtext);
1782+
return NULL;
1783+
}
17631784
text = newtext;
17641785
}
17651786

0 commit comments

Comments
 (0)