From d7f9c6400f49633221983d0c454cf27ecb0302ba Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 31 Aug 2026 12:49:03 +0100 Subject: [PATCH 1/3] gh-156689: Fix out-of-bounds read in `PyAst_CheckMode()` for `mode='func_type'` (#156697) --- Doc/library/ast.rst | 2 +- Lib/test/test_ast/test_ast.py | 9 +++++++++ .../2026-08-31-10-45-11.gh-issue-156689.Jq3xNv.rst | 2 ++ Parser/asdl_c.py | 11 +++++++---- Python/Python-ast.c | 11 +++++++---- 5 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-08-31-10-45-11.gh-issue-156689.Jq3xNv.rst diff --git a/Doc/library/ast.rst b/Doc/library/ast.rst index ab2a668f590afd..e30dadc9733d37 100644 --- a/Doc/library/ast.rst +++ b/Doc/library/ast.rst @@ -2254,7 +2254,7 @@ and classes for traversing abstract syntax trees: In addition, if ``mode`` is ``'func_type'``, the input syntax is modified to correspond to :pep:`484` "signature type comments", - e.g. ``(str, int) -> List[str]``. + for example ``(str, int) -> List[str]``. Setting ``feature_version`` to a tuple ``(major, minor)`` will result in a "best-effort" attempt to parse using that Python version's grammar. diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 7d35fc4ef7c364..92ef9c633e1d47 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -162,6 +162,15 @@ def test_parse_invalid_ast(self): self.assertRaises(TypeError, ast.parse, ast.Constant(42), optimize=optval) + def test_parse_ast_func_type(self): + # see gh-156689 + tree = ast.parse('(int, str) -> bool', mode='func_type') + self.assertEqual(ast.dump(ast.parse(tree, mode='func_type')), + ast.dump(tree)) + self.assertRaises(TypeError, ast.parse, ast.Constant(42), + mode='func_type') + self.assertRaises(TypeError, ast.parse, tree, mode='exec') + def test_optimization_levels__debug__(self): cases = [(-1, '__debug__'), (0, '__debug__'), (1, False), (2, False)] for (optval, expected) in cases: diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-31-10-45-11.gh-issue-156689.Jq3xNv.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-31-10-45-11.gh-issue-156689.Jq3xNv.rst new file mode 100644 index 00000000000000..868997320b2f7c --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-31-10-45-11.gh-issue-156689.Jq3xNv.rst @@ -0,0 +1,2 @@ +Fix an out-of-bounds read in :func:`compile` and :func:`ast.parse` when an AST +object is passed with ``mode='func_type'``. diff --git a/Parser/asdl_c.py b/Parser/asdl_c.py index d53886866f54f8..b2581e48810b89 100755 --- a/Parser/asdl_c.py +++ b/Parser/asdl_c.py @@ -2122,22 +2122,25 @@ class PartingShots(StaticVisitor): return result; } -/* mode is 0 for "exec", 1 for "eval" and 2 for "single" input */ +/* mode is 0 for "exec", 1 for "eval", 2 for "single" and 3 for "func_type" + input */ int PyAst_CheckMode(PyObject *ast, int mode) { - const char * const req_name[] = {"Module", "Expression", "Interactive"}; + const char * const req_name[] = {"Module", "Expression", "Interactive", + "FunctionType"}; struct ast_state *state = get_ast_state(); if (state == NULL) { return -1; } - PyObject *req_type[3]; + PyObject *req_type[4]; req_type[0] = state->Module_type; req_type[1] = state->Expression_type; req_type[2] = state->Interactive_type; + req_type[3] = state->FunctionType_type; - assert(0 <= mode && mode <= 2); + assert(0 <= mode && mode <= 3); int isinstance = PyObject_IsInstance(ast, req_type[mode]); if (isinstance == -1) { return -1; diff --git a/Python/Python-ast.c b/Python/Python-ast.c index f36072dfce098c..383384fc1706b4 100644 --- a/Python/Python-ast.c +++ b/Python/Python-ast.c @@ -18549,22 +18549,25 @@ PyObject* PyAST_mod2obj(mod_ty t) return result; } -/* mode is 0 for "exec", 1 for "eval" and 2 for "single" input */ +/* mode is 0 for "exec", 1 for "eval", 2 for "single" and 3 for "func_type" + input */ int PyAst_CheckMode(PyObject *ast, int mode) { - const char * const req_name[] = {"Module", "Expression", "Interactive"}; + const char * const req_name[] = {"Module", "Expression", "Interactive", + "FunctionType"}; struct ast_state *state = get_ast_state(); if (state == NULL) { return -1; } - PyObject *req_type[3]; + PyObject *req_type[4]; req_type[0] = state->Module_type; req_type[1] = state->Expression_type; req_type[2] = state->Interactive_type; + req_type[3] = state->FunctionType_type; - assert(0 <= mode && mode <= 2); + assert(0 <= mode && mode <= 3); int isinstance = PyObject_IsInstance(ast, req_type[mode]); if (isinstance == -1) { return -1; From d87ee279a1f6dfdb478ede5f0ba0360123d85916 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 31 Aug 2026 17:11:29 +0300 Subject: [PATCH 2/3] gh-156707: Do not follow junctions in os_helper.rmtree() on Windows (GH-156710) os.lstat() reports a junction as a directory, so the junction was followed and files in the directory it points to could be removed. Now the junction itself is removed, as in os.walk() and shutil.rmtree(). --- Lib/test/support/os_helper.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/test/support/os_helper.py b/Lib/test/support/os_helper.py index e1e2e69cb3d833..12d34dedfe1bba 100644 --- a/Lib/test/support/os_helper.py +++ b/Lib/test/support/os_helper.py @@ -434,7 +434,10 @@ def _rmtree_inner(path): file=sys.__stderr__) mode = 0 if stat.S_ISDIR(mode): - _waitfor(_rmtree_inner, fullname, waitall=True) + # Do not follow junctions, which os.lstat() reports + # as directories. + if not os.path.isjunction(fullname): + _waitfor(_rmtree_inner, fullname, waitall=True) _force_run(fullname, os.rmdir, fullname) else: _force_run(fullname, os.unlink, fullname) From 5ef7fa169fecf37031e4fe31b9c52ab048c9c2e5 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 31 Aug 2026 19:20:11 +0200 Subject: [PATCH 3/3] gh-155742: Get singleton in PyBytesWriter_FinishWithSize() (#155795) If the result size of 1 byte, return the singleton rather than creating a new bytes string. --- Lib/test/test_capi/test_bytes.py | 46 +++++++++++++++++-- ...-08-14-14-47-08.gh-issue-155742.UGI3Pn.rst | 2 + Modules/_testcapi/bytes.c | 13 +++++- Objects/bytesobject.c | 10 ++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-08-14-14-47-08.gh-issue-155742.UGI3Pn.rst diff --git a/Lib/test/test_capi/test_bytes.py b/Lib/test/test_capi/test_bytes.py index d20e5016f969c2..38cda931e7d54f 100644 --- a/Lib/test/test_capi/test_bytes.py +++ b/Lib/test/test_capi/test_bytes.py @@ -299,11 +299,11 @@ def test_join(self): bytes_join(b'', NULL) -class BytesWriterTest(unittest.TestCase): - result_type = bytes +class BaseWriterTest: + result_type = NotImplementedError def create_writer(self, alloc=0, string=b''): - return _testcapi.PyBytesWriter(alloc, string, 0) + raise NotImplementedError def test_create(self): # Test PyBytesWriter_Create() @@ -388,10 +388,48 @@ def test_example_highlevel(self): self.assertEqual(_testcapi.byteswriter_highlevel(), b'Hello World!') -class ByteArrayWriterTest(BytesWriterTest): +class BytesWriterTest(BaseWriterTest, unittest.TestCase): + result_type = bytes + + def create_writer(self, alloc=0, string=b''): + # Test PyBytesWriter_Create() + return _testcapi.PyBytesWriter(alloc, string, 0) + + # Only PyBytesWriter_Create() returns singletons + def test_singletons(self): + empty = b'' + singletons = {ch: bytes((ch,)) for ch in range(256)} + small_buffer = _testcapi.PyBytesWriter_small_buffer + + writer = self.create_writer() + self.assertIs(writer.finish(), empty) + + # Test writer larger than small_buffer + writer = self.create_writer() + unused_text = b'x' * (small_buffer * 2) + writer.write_bytes(unused_text, len(unused_text)) + self.assertIs(writer.finish_with_size(0), empty) + + for ch in range(256): + text = bytes((ch,)) + + writer = self.create_writer() + writer.write_bytes(text, 1) + self.assertIs(writer.finish(), singletons[ch]) + + # Test writer larger than small_buffer + writer = self.create_writer() + writer.write_bytes(text, 1) + unused_text = b'x' * (small_buffer * 2) + writer.write_bytes(unused_text, len(unused_text)) + self.assertIs(writer.finish_with_size(1), singletons[ch]) + + +class ByteArrayWriterTest(BaseWriterTest, unittest.TestCase): result_type = bytearray def create_writer(self, alloc=0, string=b''): + # Test private _PyBytesWriter_CreateByteArray() return _testcapi.PyBytesWriter(alloc, string, 1) diff --git a/Misc/NEWS.d/next/C_API/2026-08-14-14-47-08.gh-issue-155742.UGI3Pn.rst b/Misc/NEWS.d/next/C_API/2026-08-14-14-47-08.gh-issue-155742.UGI3Pn.rst new file mode 100644 index 00000000000000..8920547968a4fb --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-08-14-14-47-08.gh-issue-155742.UGI3Pn.rst @@ -0,0 +1,2 @@ +:c:func:`PyBytesWriter_FinishWithSize` now returns single byte singletons if +*size* equals to ``1``. Patch by Victor Stinner. diff --git a/Modules/_testcapi/bytes.c b/Modules/_testcapi/bytes.c index f12fc7f5f3a2a8..a868c684cc987c 100644 --- a/Modules/_testcapi/bytes.c +++ b/Modules/_testcapi/bytes.c @@ -4,6 +4,8 @@ #include "parts.h" #include "util.h" +#include // offsetof() + #include "pycore_bytesobject.h" // _PyBytesWriter_CreateByteArray() @@ -150,8 +152,8 @@ writer_write_bytes(PyObject *self_raw, PyObject *args) } char *bytes; - Py_ssize_t size; - if (!PyArg_ParseTuple(args, "yn", &bytes, &size)) { + Py_ssize_t unused_size, size; + if (!PyArg_ParseTuple(args, "y#n", &bytes, &unused_size, &size)) { return NULL; } @@ -377,5 +379,12 @@ _PyTestCapi_Init_Bytes(PyObject *m) } Py_DECREF(writer_type); + // PyBytesWriter.obj is the second member, small_buffer is the first member + long size = (long)offsetof(PyBytesWriter, obj); + if (PyModule_AddIntConstant(m, "PyBytesWriter_small_buffer", size) < 0) { + Py_DECREF(writer_type); + return -1; + } + return 0; } diff --git a/Objects/bytesobject.c b/Objects/bytesobject.c index ef35dad82e8aae..4c3da93f101970 100644 --- a/Objects/bytesobject.c +++ b/Objects/bytesobject.c @@ -3766,13 +3766,23 @@ PyBytesWriter_FinishWithSize(PyBytesWriter *writer, Py_ssize_t size) } } } + result = writer->obj; writer->obj = NULL; + + if (size == 1 && !writer->use_bytearray) { + // Get the single byte singleton + unsigned char ch = PyBytes_AS_STRING(result)[0]; + PyObject *op = (PyObject*)CHARACTER(ch); + assert(_Py_IsImmortal(op)); + Py_SETREF(result, op); + } } else if (writer->use_bytearray) { result = PyByteArray_FromStringAndSize(writer->small_buffer, size); } else { + // The function returns single byte singleton if size equals 1 result = PyBytes_FromStringAndSize(writer->small_buffer, size); } PyBytesWriter_Discard(writer);