diff --git a/Lib/test/test_structseq_mutation.py b/Lib/test/test_structseq_mutation.py new file mode 100644 index 000000000000000..45efc26a1ae6eae --- /dev/null +++ b/Lib/test/test_structseq_mutation.py @@ -0,0 +1,94 @@ +"""Regression tests for gh-155322: mutating a heap PyStructSequence type's +writable size attributes (n_fields / n_sequence_fields / n_unnamed_fields) +must not crash the interpreter when a new instance is constructed. +""" +import os +import time +import unittest + +try: + import resource +except ImportError: + resource = None + + +class StructSeqMutationTests(unittest.TestCase): + + def _check_overflow_raises(self, structseq_type, sample_args): + structseq_type(sample_args) + + original = structseq_type.n_fields + try: + structseq_type.n_fields = 100000 + with self.assertRaises(TypeError): + structseq_type(sample_args) + finally: + structseq_type.n_fields = original + + structseq_type(sample_args) + + def test_terminal_size_n_fields_overflow(self): + self._check_overflow_raises(os.terminal_size, (80, 24)) + + def test_stat_result_n_fields_overflow(self): + st = os.stat(".") + self._check_overflow_raises(os.stat_result, tuple(st)) + + def test_struct_time_n_fields_overflow(self): + t = time.localtime() + self._check_overflow_raises(time.struct_time, tuple(t)) + + @unittest.skipIf(resource is None, "resource module not available") + def test_struct_rusage_n_fields_overflow(self): + ru = resource.getrusage(resource.RUSAGE_SELF) + self._check_overflow_raises(resource.struct_rusage, tuple(ru)) + + def test_terminal_size_n_sequence_fields_overflow(self): + original = os.terminal_size.n_sequence_fields + try: + os.terminal_size.n_sequence_fields = 100000 + with self.assertRaises(TypeError): + os.terminal_size((80, 24)) + finally: + os.terminal_size.n_sequence_fields = original + os.terminal_size((80, 24)) + + def test_terminal_size_negative_n_fields(self): + original = os.terminal_size.n_fields + try: + os.terminal_size.n_fields = -1 + with self.assertRaises(TypeError): + os.terminal_size((80, 24)) + finally: + os.terminal_size.n_fields = original + os.terminal_size((80, 24)) + + def test_n_sequence_fields_exceeds_n_fields(self): + orig_seq = time.struct_time.n_sequence_fields + orig_fields = time.struct_time.n_fields + try: + time.struct_time.n_fields = 2 + time.struct_time.n_sequence_fields = 3 + with self.assertRaises(TypeError): + time.struct_time(tuple(time.localtime())) + finally: + time.struct_time.n_fields = orig_fields + time.struct_time.n_sequence_fields = orig_seq + time.struct_time(tuple(time.localtime())) + + def test_n_unnamed_fields_exceeds_n_sequence_fields(self): + orig_seq = time.struct_time.n_sequence_fields + orig_unnamed = time.struct_time.n_unnamed_fields + try: + time.struct_time.n_sequence_fields = 2 + time.struct_time.n_unnamed_fields = 5 + with self.assertRaises(TypeError): + time.struct_time(tuple(time.localtime())) + finally: + time.struct_time.n_sequence_fields = orig_seq + time.struct_time.n_unnamed_fields = orig_unnamed + time.struct_time(tuple(time.localtime())) + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-19-45-42.gh-issue-155322.cTmIoM.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-19-45-42.gh-issue-155322.cTmIoM.rst new file mode 100644 index 000000000000000..704cbb643d0ebe0 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-19-45-42.gh-issue-155322.cTmIoM.rst @@ -0,0 +1,2 @@ +Fix a crash in heap-allocated PyStructSequence types when the writable +n_fields attribute is modified before constructing a new instance. diff --git a/Objects/structseq.c b/Objects/structseq.c index 9130fe6a133b1e4..c6e7e05a8bb523c 100644 --- a/Objects/structseq.c +++ b/Objects/structseq.c @@ -38,7 +38,18 @@ get_type_attr_as_size(PyTypeObject *tp, PyObject *name) name, tp->tp_name); return -1; } - return PyLong_AsSsize_t(v); + Py_ssize_t result = PyLong_AsSsize_t(v); + if (result < 0 && !PyErr_Occurred()) { + // gh-155322: a legitimate (non-overflow) negative value, e.g. the + // user set n_fields = -1. Callers treat "< 0" as "an exception is + // already set" and bail without formatting their own message, so + // we must set one here rather than silently returning -1. + PyErr_Format(PyExc_TypeError, + "%.500s: attribute '%U' must be a non-negative integer", + tp->tp_name, name); + return -1; + } + return result; } #define VISIBLE_SIZE(op) Py_SIZE(op) @@ -52,13 +63,65 @@ get_type_attr_as_size(PyTypeObject *tp, PyObject *name) get_type_attr_as_size(tp, &_Py_ID(n_unnamed_fields)) #define UNNAMED_FIELDS(op) UNNAMED_FIELDS_TP(Py_TYPE(op)) +// gh-155322: Hidden slots stored in tp_basicsize beyond tp_members. +static Py_ssize_t +structseq_hidden_size(PyTypeObject *type) +{ + return (type->tp_basicsize - offsetof(PyStructSequence, ob_item)) + / sizeof(PyObject *); +} + static Py_ssize_t get_real_size(PyObject *op) { // Compute the real size from the visible size (i.e., Py_SIZE()) and the // number of non-sequence fields accounted for in tp_basicsize. - Py_ssize_t hidden = Py_TYPE(op)->tp_basicsize - offsetof(PyStructSequence, ob_item); - return Py_SIZE(op) + hidden / sizeof(PyObject *); + return Py_SIZE(op) + structseq_hidden_size(Py_TYPE(op)); +} + +// gh-155322: Upper bound on visible fields derived from tp_members. +static Py_ssize_t +structseq_named_member_count(PyTypeObject *type) +{ + Py_ssize_t count = 0; + if (type->tp_members != NULL) { + while (type->tp_members[count].name != NULL) { + count++; + } + } + return count; +} + +// gh-155322: Validate user-modifiable size attributes against the immutable +// structseq layout. +static int +structseq_validate_sizes(PyTypeObject *type, Py_ssize_t n_fields, + Py_ssize_t n_sequence_fields, + Py_ssize_t n_unnamed_fields) +{ + Py_ssize_t hidden = structseq_hidden_size(type); + Py_ssize_t named_max = structseq_named_member_count(type); + + if (n_unnamed_fields < 0 || n_unnamed_fields > n_sequence_fields) { + PyErr_Format(PyExc_TypeError, + "%.500s: n_unnamed_fields (%zd) is invalid", + type->tp_name, n_unnamed_fields); + return -1; + } + if (n_sequence_fields < 0 || n_sequence_fields > named_max) { + PyErr_Format(PyExc_TypeError, + "%.500s: n_sequence_fields (%zd) is invalid", + type->tp_name, n_sequence_fields); + return -1; + } + if (n_fields != n_sequence_fields + hidden) { + PyErr_Format(PyExc_TypeError, + "%.500s: n_fields (%zd) is inconsistent with " + "n_sequence_fields (%zd)", + type->tp_name, n_fields, n_sequence_fields); + return -1; + } + return 0; } PyObject * @@ -74,6 +137,11 @@ PyStructSequence_New(PyTypeObject *type) return NULL; } + // gh-155322: Validate the type's declared layout before allocating. + if (structseq_validate_sizes(type, size, vsize, 0) < 0) { + return NULL; + } + obj = PyObject_GC_NewVar(PyStructSequence, type, size); if (obj == NULL) return NULL; @@ -183,6 +251,11 @@ structseq_new_impl(PyTypeObject *type, PyObject *arg, PyObject *dict) return NULL; } + // gh-155322: Validate the declared layout before indexing tp_members. + if (structseq_validate_sizes(type, max_len, min_len, n_unnamed_fields) < 0) { + return NULL; + } + arg = PySequence_Fast(arg, "constructor requires a sequence"); if (!arg) {