Skip to content

Commit 56ee379

Browse files
gh-154682: Fix corruption of locale-encoded text on FreeBSD, NetBSD, DragonFly BSD and macOS
On these platforms wchar_t values are not Unicode code points in non-UTF-8 locales, so mbstowcs() and wcstombs() cannot be used to convert locale-encoded text, neither can wcsftime() output be used, and wcscoll(), wcsxfrm() and the wide character curses API cannot be used with Unicode wchar_t. Use iconv() in _Py_DecodeLocaleEx() and _Py_EncodeLocaleEx(), use strftime() instead of wcsftime() in time.strftime(), use strcoll() and strxfrm() on the locale-encoded strings in the locale module, and convert between Unicode and the native wchar_t form on the curses library boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b618874 commit 56ee379

9 files changed

Lines changed: 637 additions & 0 deletions

File tree

Include/internal/pycore_fileutils.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,28 @@ extern int _Py_GetForceASCII(void);
225225
encoding. */
226226
extern void _Py_ResetForceASCII(void);
227227

228+
#ifdef HAVE_LANGINFO_H
229+
# include <langinfo.h> // CODESET
230+
#endif
231+
232+
/* On FreeBSD, NetBSD, DragonFly BSD and macOS, wchar_t values are not
233+
Unicode code points in non-UTF-8 locales. Locale-encoded text is converted
234+
with iconv() (see Python/fileutils.c), and the wide character
235+
functions which use the locale (such as wcscoll()) cannot be used
236+
with Unicode wchar_t. */
237+
#if (defined(__FreeBSD__) || defined(__NetBSD__) || defined(__DragonFly__) \
238+
|| defined(__APPLE__)) \
239+
&& defined(HAVE_ICONV) \
240+
&& defined(HAVE_LANGINFO_H) && defined(CODESET) && SIZEOF_WCHAR_T == 4
241+
# define _Py_NON_UNICODE_WCHAR_T
242+
243+
// Convert between Unicode code points and the native wchar_t form for
244+
// C library functions which use the latter (such as the curses library).
245+
// Export for '_curses'.
246+
PyAPI_FUNC(int) _Py_UnicodeToLocaleWchar_InPlace(wchar_t *str, Py_ssize_t size);
247+
PyAPI_FUNC(void) _Py_LocaleWcharToUnicode_InPlace(wchar_t *str, Py_ssize_t size);
248+
#endif
249+
228250

229251
extern int _Py_GetLocaleconvNumeric(
230252
struct lconv *lc,

Lib/test/test_locale.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,53 @@ def test_strxfrm(self):
350350
self.assertRaises(ValueError, locale.strxfrm, 'a\0')
351351

352352

353+
class TestCollationAcrossEncodings(unittest.TestCase):
354+
# gh-154682: collation must give the same results in locales which
355+
# only differ in the encoding.
356+
357+
@support.thread_unsafe('setlocale is not thread-safe')
358+
@unittest.skipIf(sys.platform.startswith("netbsd"),
359+
"gh-124108: NetBSD doesn't support UTF-8 for LC_COLLATE")
360+
@unittest.skipIf(sys.platform.startswith("dragonfly") or
361+
sys.platform == "darwin",
362+
"no collation data for non-UTF-8 locales")
363+
def test_across_encodings(self):
364+
def sign(x):
365+
return (x > 0) - (x < 0)
366+
cases = [
367+
('uk_UA.UTF-8', 'uk_UA.KOI8-U', 'гґдиіїя'),
368+
('uk_UA.UTF-8', 'uk_UA.CP1251', 'гґдиіїя'),
369+
('el_GR.UTF-8', 'el_GR.ISO8859-7', 'αβδω'),
370+
('de_DE.UTF-8', 'de_DE.ISO8859-1', 'aäbößz'),
371+
('tr_TR.UTF-8', 'tr_TR.ISO8859-9', 'cçdıisşz'),
372+
('ca_ES.UTF-8', 'ca_ES.ISO8859-1', 'cçde'),
373+
]
374+
oldloc = locale.setlocale(locale.LC_ALL)
375+
self.addCleanup(locale.setlocale, locale.LC_ALL, oldloc)
376+
tested = False
377+
for utf8_loc, legacy_loc, chars in cases:
378+
try:
379+
locale.setlocale(locale.LC_ALL, utf8_loc)
380+
except locale.Error:
381+
continue
382+
expected_order = sorted(chars, key=locale.strxfrm)
383+
expected_signs = [[sign(locale.strcoll(a, b)) for b in chars]
384+
for a in chars]
385+
try:
386+
locale.setlocale(locale.LC_ALL, legacy_loc)
387+
except locale.Error:
388+
continue
389+
with self.subTest(locale=legacy_loc):
390+
self.assertEqual(sorted(chars, key=locale.strxfrm),
391+
expected_order)
392+
signs = [[sign(locale.strcoll(a, b)) for b in chars]
393+
for a in chars]
394+
self.assertEqual(signs, expected_signs)
395+
tested = True
396+
if not tested:
397+
self.skipTest('no suitable locales')
398+
399+
353400
class TestEnUSCollation(BaseLocalizedTest, TestCollation):
354401
# Test string collation functions with a real English locale
355402

Lib/test/test_readline.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ def test_write_read_append(self):
125125
readline.append_history_file(-2147483648, hfilename)
126126

127127
def test_nonascii_history(self):
128+
try:
129+
"entrée".encode(locale.getencoding())
130+
except UnicodeEncodeError as err:
131+
self.skipTest("Locale cannot encode test data: " + format(err))
128132
readline.clear_history()
129133
try:
130134
readline.add_history("entrée 1")

Lib/test/test_time.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import decimal
44
import enum
55
import fractions
6+
import locale
67
import math
78
import platform
89
import sys
@@ -232,6 +233,41 @@ def test_strftime(self):
232233

233234
self.assertRaises(TypeError, time.strftime, b'%S', tt)
234235

236+
@support.thread_unsafe('setlocale is not thread-safe')
237+
def test_strftime_locale_encodings(self):
238+
# gh-154682: the result must be the same in locales which only
239+
# differ in the encoding.
240+
pairs = [
241+
('uk_UA.UTF-8', 'uk_UA.KOI8-U'),
242+
('uk_UA.UTF-8', 'uk_UA.CP1251'),
243+
('el_GR.UTF-8', 'el_GR.ISO8859-7'),
244+
('ja_JP.UTF-8', 'ja_JP.eucJP'),
245+
('de_DE.UTF-8', 'de_DE.ISO8859-1'),
246+
('tr_TR.UTF-8', 'tr_TR.ISO8859-9'),
247+
('ca_ES.UTF-8', 'ca_ES.ISO8859-1'),
248+
]
249+
oldloc = locale.setlocale(locale.LC_ALL)
250+
self.addCleanup(locale.setlocale, locale.LC_ALL, oldloc)
251+
# Wednesday, March: non-ASCII in all tested locales
252+
# (including Turkish and German)
253+
tt = (2026, 3, 4, 12, 34, 56, 2, 63, 0)
254+
tested = False
255+
for utf8_loc, legacy_loc in pairs:
256+
try:
257+
locale.setlocale(locale.LC_ALL, utf8_loc)
258+
except locale.Error:
259+
continue
260+
expected = time.strftime('%A, %B', tt)
261+
try:
262+
locale.setlocale(locale.LC_ALL, legacy_loc)
263+
except locale.Error:
264+
continue
265+
with self.subTest(locale=legacy_loc):
266+
self.assertEqual(time.strftime('%A, %B', tt), expected)
267+
tested = True
268+
if not tested:
269+
self.skipTest('no suitable locales')
270+
235271
def test_strftime_invalid_format(self):
236272
tt = time.gmtime(self.t)
237273
with SuppressCrashReport():
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Fix corruption of locale-encoded text on FreeBSD, NetBSD, DragonFly BSD
2+
and macOS
3+
in non-UTF-8 locales,
4+
e.g. in the results of :func:`time.strftime` and :func:`locale.nl_langinfo`,
5+
in localized error messages,
6+
and in decoding of the command line arguments,
7+
fix wrong collation in :func:`locale.strcoll` and :func:`locale.strxfrm`,
8+
and fix display and input of non-ASCII characters in :mod:`curses`.
9+
``wchar_t`` values are not Unicode code points on these platforms,
10+
so ``iconv()`` is now used instead of ``mbstowcs()`` and ``wcstombs()``.

Modules/_cursesmodule.c

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,11 @@ PyCurses_ConvertToWideCell(PyObject *obj, wchar_t *wch)
527527
(int)CCHARW_MAX, PyUnicode_GET_LENGTH(obj));
528528
return -1;
529529
}
530+
#ifdef _Py_NON_UNICODE_WCHAR_T
531+
if (_Py_UnicodeToLocaleWchar_InPlace(wch, nch) < 0) {
532+
return -1;
533+
}
534+
#endif
530535
/* A lone control character is allowed (like addch(ord('\n'))), but in a
531536
multi-character cell the base must be a printable spacing character and
532537
the rest zero-width combining characters. Check explicitly: otherwise
@@ -622,6 +627,13 @@ PyCurses_ConvertToString(PyCursesWindowObject *win, PyObject *obj,
622627
*wstr = PyUnicode_AsWideCharString(obj, NULL);
623628
if (*wstr == NULL)
624629
return 0;
630+
#ifdef _Py_NON_UNICODE_WCHAR_T
631+
if (_Py_UnicodeToLocaleWchar_InPlace(*wstr, wcslen(*wstr)) < 0) {
632+
PyMem_Free(*wstr);
633+
*wstr = NULL;
634+
return 0;
635+
}
636+
#endif
625637
return 2;
626638
#else
627639
assert (wstr == NULL);
@@ -880,6 +892,9 @@ curses_cell_text(cursesmodule_state *state, const curses_cell_t *cell)
880892
PyErr_SetString(state->error, "getcchar() returned ERR");
881893
return NULL;
882894
}
895+
#ifdef _Py_NON_UNICODE_WCHAR_T
896+
_Py_LocaleWcharToUnicode_InPlace(wstr, wcslen(wstr));
897+
#endif
883898
return PyUnicode_FromWideChar(wstr, -1);
884899
#else
885900
char ch = (char)(*cell & A_CHARTEXT);
@@ -1296,6 +1311,12 @@ complexstr_from_string(cursesmodule_state *state, PyObject *str,
12961311
if (wbuf == NULL) {
12971312
return NULL;
12981313
}
1314+
#ifdef _Py_NON_UNICODE_WCHAR_T
1315+
if (_Py_UnicodeToLocaleWchar_InPlace(wbuf, n) < 0) {
1316+
PyMem_Free(wbuf);
1317+
return NULL;
1318+
}
1319+
#endif
12991320
curses_cell_t *cells = n > 0 ? PyMem_New(curses_cell_t, n) : NULL;
13001321
if (n > 0 && cells == NULL) {
13011322
PyMem_Free(wbuf);
@@ -1585,6 +1606,9 @@ complexstr_str(PyObject *self)
15851606
}
15861607
pos += wcslen(buf + pos);
15871608
}
1609+
#ifdef _Py_NON_UNICODE_WCHAR_T
1610+
_Py_LocaleWcharToUnicode_InPlace(buf, pos);
1611+
#endif
15881612
PyObject *res = PyUnicode_FromWideChar(buf, pos);
15891613
PyMem_Free(buf);
15901614
return res;
@@ -3275,6 +3299,13 @@ _curses_window_getkey_impl(PyCursesWindowObject *self, int group_right_1,
32753299
rtn += 256;
32763300
}
32773301
#endif
3302+
#endif
3303+
#ifdef _Py_NON_UNICODE_WCHAR_T
3304+
{
3305+
wchar_t wch = (wchar_t)rtn;
3306+
_Py_LocaleWcharToUnicode_InPlace(&wch, 1);
3307+
return PyUnicode_FromOrdinal(wch);
3308+
}
32783309
#endif
32793310
return PyUnicode_FromOrdinal(rtn);
32803311
} else {
@@ -3325,8 +3356,16 @@ _curses_window_get_wch_impl(PyCursesWindowObject *self, int group_right_1,
33253356
}
33263357
if (ct == KEY_CODE_YES)
33273358
return PyLong_FromLong(rtn);
3359+
#ifdef _Py_NON_UNICODE_WCHAR_T
3360+
else {
3361+
wchar_t wch = (wchar_t)rtn;
3362+
_Py_LocaleWcharToUnicode_InPlace(&wch, 1);
3363+
return PyUnicode_FromOrdinal(wch);
3364+
}
3365+
#else
33283366
else
33293367
return PyUnicode_FromOrdinal(rtn);
3368+
#endif
33303369
#else
33313370
/* Without the wide library, read one key with wgetch(): a value above 255
33323371
is a function key (returned as an int); a byte is decoded with the
@@ -3801,6 +3840,9 @@ PyCursesWindow_get_wstr(PyObject *op, PyObject *args)
38013840
for (Py_ssize_t i = 0; i < len; i++) {
38023841
wbuf[i] = (wchar_t)buf[i];
38033842
}
3843+
#ifdef _Py_NON_UNICODE_WCHAR_T
3844+
_Py_LocaleWcharToUnicode_InPlace(wbuf, len);
3845+
#endif
38043846
PyObject *res = PyUnicode_FromWideChar(wbuf, len);
38053847
PyMem_Free(wbuf);
38063848
PyMem_Free(buf);
@@ -3866,6 +3908,9 @@ PyCursesWindow_in_wstr(PyObject *op, PyObject *args)
38663908
PyMem_Free(buf);
38673909
return Py_GetConstant(Py_CONSTANT_EMPTY_STR);
38683910
}
3911+
#ifdef _Py_NON_UNICODE_WCHAR_T
3912+
_Py_LocaleWcharToUnicode_InPlace(buf, wcslen(buf));
3913+
#endif
38693914
PyObject *res = PyUnicode_FromWideChar(buf, -1);
38703915
PyMem_Free(buf);
38713916
return res;
@@ -5854,6 +5899,9 @@ _curses_erasewchar_impl(PyObject *module)
58545899
curses_set_error(module, "erasewchar", NULL);
58555900
return NULL;
58565901
}
5902+
#ifdef _Py_NON_UNICODE_WCHAR_T
5903+
_Py_LocaleWcharToUnicode_InPlace(&ch, 1);
5904+
#endif
58575905
return PyUnicode_FromWideChar(&ch, 1);
58585906
#else
58595907
/* Without the wide library, decode the single-byte erase character
@@ -7076,6 +7124,9 @@ _curses_killwchar_impl(PyObject *module)
70767124
curses_set_error(module, "killwchar", NULL);
70777125
return NULL;
70787126
}
7127+
#ifdef _Py_NON_UNICODE_WCHAR_T
7128+
_Py_LocaleWcharToUnicode_InPlace(&ch, 1);
7129+
#endif
70797130
return PyUnicode_FromWideChar(&ch, 1);
70807131
#else
70817132
/* Without the wide library, decode the single-byte kill character
@@ -8018,6 +8069,19 @@ _curses_wunctrl(PyObject *module, PyObject *ch)
80188069
curses_set_null_error(module, "wunctrl", NULL);
80198070
return NULL;
80208071
}
8072+
#ifdef _Py_NON_UNICODE_WCHAR_T
8073+
{
8074+
/* wunctrl() returns a pointer to a static buffer; make a copy
8075+
before converting in place. */
8076+
wchar_t wbuf[8];
8077+
size_t len = wcslen(res);
8078+
if (len < Py_ARRAY_LENGTH(wbuf)) {
8079+
wcscpy(wbuf, res);
8080+
_Py_LocaleWcharToUnicode_InPlace(wbuf, len);
8081+
return PyUnicode_FromWideChar(wbuf, len);
8082+
}
8083+
}
8084+
#endif
80218085
return PyUnicode_FromWideChar(res, -1);
80228086
#else
80238087
/* Without the wide library, fall back to the single-byte unctrl() and
@@ -8107,6 +8171,10 @@ _curses_ungetch(PyObject *module, PyObject *ch)
81078171
wchar_t wch;
81088172
if (!PyCurses_ConvertToWchar_t(ch, &wch))
81098173
return NULL;
8174+
#ifdef _Py_NON_UNICODE_WCHAR_T
8175+
if (_Py_UnicodeToLocaleWchar_InPlace(&wch, 1) < 0)
8176+
return NULL;
8177+
#endif
81108178
return curses_check_err(module, unget_wch(wch), "unget_wch", "ungetch");
81118179
}
81128180
#endif
@@ -8138,6 +8206,10 @@ _curses_unget_wch(PyObject *module, PyObject *ch)
81388206
if (!PyCurses_ConvertToWchar_t(ch, &wch))
81398207
return NULL;
81408208
#ifdef HAVE_NCURSESW
8209+
#ifdef _Py_NON_UNICODE_WCHAR_T
8210+
if (_Py_UnicodeToLocaleWchar_InPlace(&wch, 1) < 0)
8211+
return NULL;
8212+
#endif
81418213
return curses_check_err(module, unget_wch(wch), "unget_wch", NULL);
81428214
#else
81438215
/* Without the wide library there is no unget_wch(): encode the character as
@@ -8226,6 +8298,12 @@ _curses_slk_set_impl(PyObject *module, int labnum, PyObject *label,
82268298
if (wstr == NULL) {
82278299
return NULL;
82288300
}
8301+
#ifdef _Py_NON_UNICODE_WCHAR_T
8302+
if (_Py_UnicodeToLocaleWchar_InPlace(wstr, wcslen(wstr)) < 0) {
8303+
PyMem_Free(wstr);
8304+
return NULL;
8305+
}
8306+
#endif
82298307
rtn = slk_wset(labnum, wstr, justify);
82308308
PyMem_Free(wstr);
82318309
#else

0 commit comments

Comments
 (0)