From a4d2907760bcb31f6e0ef4cfa2f41e37051b58a0 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 21 Aug 2026 10:35:55 +0300 Subject: [PATCH 1/2] gh-155877: Reject a NUL sharing a curses cell, keep a lone one setcchar() takes a NUL-terminated string, so a NUL in a cell dropped the rest of it: complexchar('a\0' + combining) kept only 'a', and an empty cell read back as complexchar('') rather than complexchar('\0') as on a narrow build. Reject a NUL among other characters, read an empty cell as a NUL, and give a NUL its own cell in complexstr(). --- Lib/test/test_curses.py | 31 +++++++++++++++++++++++++++++++ Modules/_cursesmodule.c | 14 +++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 6a7a8e320438f8..3cbc291c57cd1f 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1017,6 +1017,37 @@ def test_output_string_pair_restored(self): curses.A_BOLD) self.assertEqual(win.attr_get()[1], pair) + def test_cell_embedded_null_chars(self): + # A NUL cannot share a cell with another character: setcchar() takes a + # NUL-terminated string, so the rest of the cell would be dropped. + for text in ['a\0', 'a\0\u0301', 'a\0b', '\0a']: + with self.subTest(text=text): + self.assertRaises(ValueError, curses.complexchar, text) + if WIDE_BUILD: + self.assertRaises(ValueError, self.stdscr.addch, text) + + def test_cell_null_char(self): + # A lone NUL is a character like any other, as addch(0) always was. + stdscr = self.stdscr + cell = curses.complexchar('\0') + self.assertEqual(str(cell), '\0') + self.assertEqual(eval(repr(cell), {'curses': curses}), cell) + stdscr.erase() + stdscr.addch(0, 0, 0) + expected = stdscr.instr(0, 0, 4) + for ch in ['\0', cell]: + with self.subTest(ch=ch): + stdscr.erase() + stdscr.addch(0, 0, ch) + self.assertEqual(stdscr.instr(0, 0, 4), expected) + # A cell holding a NUL reads back as the cell that writes it. + win = curses.newwin(3, 8, 0, 0) + win.insch(0, 0, '\0') + self.assertEqual(win.in_wch(0, 0), cell) + # complexstr() splits a NUL into a cell of its own. + self.assertEqual(len(curses.complexstr('a\0b')), 3) + self.assertEqual(curses.complexstr('\0')[0], cell) + def test_add_string_behavior(self): # addstr() advances the cursor past the written text; addnstr() # writes at most n characters. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index ea399e25213aef..9b5078dbd16c43 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -561,6 +561,10 @@ PyCurses_ConvertToWideCell(PyObject *obj, wchar_t *wch) setcchar() would silently drop a trailing spacing character, or fail with a generic error for a control-character base. */ if (nch > 1) { + if (wmemchr(wch, L'\0', nch) != NULL) { + PyErr_SetString(PyExc_ValueError, "embedded null character"); + return -1; + } int bad = wcwidth(wch[0]) < 0; for (Py_ssize_t i = 1; !bad && i < nch; i++) { bad = wcwidth(wch[i]) != 0; @@ -908,7 +912,9 @@ curses_cell_text(cursesmodule_state *state, const curses_cell_t *cell) PyErr_SetString(state->error, "getcchar() returned ERR"); return NULL; } - return PyUnicode_FromWideChar(wstr, -1); + /* setcchar() stores no text for a NUL (it takes a NUL-terminated string), + so an empty cell holds a NUL character, as on a narrow build. */ + return PyUnicode_FromWideChar(wstr, wstr[0] == L'\0' ? 1 : -1); #else char ch = (char)(*cell & A_CHARTEXT); return PyUnicode_Decode(&ch, 1, curses_screen_encoding, NULL); @@ -1334,7 +1340,9 @@ complexstr_from_string(cursesmodule_state *state, PyObject *str, wchar_t cell[CCHARW_MAX + 1]; Py_ssize_t k = 0; cell[k++] = wbuf[i++]; - while (i < n && k < CCHARW_MAX && wcwidth(wbuf[i]) == 0) { + while (i < n && k < CCHARW_MAX && wbuf[i] != L'\0' && + wcwidth(wbuf[i]) == 0) + { cell[k++] = wbuf[i++]; } cell[k] = L'\0'; @@ -1343,7 +1351,7 @@ complexstr_from_string(cursesmodule_state *state, PyObject *str, control character (wcwidth < 0) may stand alone but cannot carry combining marks. */ int width = wcwidth(cell[0]); - if (width == 0 || (k > 1 && width < 0)) { + if ((width == 0 && cell[0] != L'\0') || (k > 1 && width < 0)) { PyErr_Format(PyExc_ValueError, "a character cell must be a single spacing character " "optionally followed by up to %d combining characters", From fdddd458f371ea9c7b8d471b7b740d8fc3c5bc94 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 21 Aug 2026 11:08:34 +0300 Subject: [PATCH 2/2] Reject a NUL in complexstr() A NUL cell ends a batch write and a cell array read (add_wchnstr(3X)), so addstr() would write only the cells before it and in_wchstr() would read only those, on both a wide and a narrow build. addstr() already rejects an embedded NUL in a str. --- Lib/test/test_curses.py | 6 +++--- Modules/_cursesmodule.c | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 3cbc291c57cd1f..ca22a4080d78b1 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1044,9 +1044,9 @@ def test_cell_null_char(self): win = curses.newwin(3, 8, 0, 0) win.insch(0, 0, '\0') self.assertEqual(win.in_wch(0, 0), cell) - # complexstr() splits a NUL into a cell of its own. - self.assertEqual(len(curses.complexstr('a\0b')), 3) - self.assertEqual(curses.complexstr('\0')[0], cell) + # A string of cells cannot hold a NUL: it would end a batch write. + self.assertRaises(ValueError, curses.complexstr, 'a\0b') + self.assertRaises(ValueError, curses.complexstr, '\0') def test_add_string_behavior(self): # addstr() advances the cursor past the written text; addnstr() diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 9b5078dbd16c43..17c8cc79bd6d3e 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -1324,6 +1324,16 @@ static PyObject * complexstr_from_string(cursesmodule_state *state, PyObject *str, attr_t attr, int pair) { + /* A NUL cell ends a batch write and a cell array read (add_wchnstr(3X)), + so a string of cells cannot hold one, as addstr() cannot either. */ + Py_ssize_t nul = PyUnicode_FindChar(str, 0, 0, PyUnicode_GET_LENGTH(str), 1); + if (nul < -1) { + return NULL; + } + if (nul >= 0) { + PyErr_SetString(PyExc_ValueError, "embedded null character"); + return NULL; + } #ifdef HAVE_NCURSESW Py_ssize_t n; wchar_t *wbuf = PyUnicode_AsWideCharString(str, &n); @@ -1340,9 +1350,7 @@ complexstr_from_string(cursesmodule_state *state, PyObject *str, wchar_t cell[CCHARW_MAX + 1]; Py_ssize_t k = 0; cell[k++] = wbuf[i++]; - while (i < n && k < CCHARW_MAX && wbuf[i] != L'\0' && - wcwidth(wbuf[i]) == 0) - { + while (i < n && k < CCHARW_MAX && wcwidth(wbuf[i]) == 0) { cell[k++] = wbuf[i++]; } cell[k] = L'\0'; @@ -1351,7 +1359,7 @@ complexstr_from_string(cursesmodule_state *state, PyObject *str, control character (wcwidth < 0) may stand alone but cannot carry combining marks. */ int width = wcwidth(cell[0]); - if ((width == 0 && cell[0] != L'\0') || (k > 1 && width < 0)) { + if (width == 0 || (k > 1 && width < 0)) { PyErr_Format(PyExc_ValueError, "a character cell must be a single spacing character " "optionally followed by up to %d combining characters",