postgres.git / summary / log / commit / refs

commit    55ea764269b6fe8b3fcc221a970c09e12ef7a96b
Author:   Álvaro Herrera <alvherre@kurilemu.de>
Date:     Wed Aug 05 09:40:39 2026 +0000

    Fix calculating length of match to localized month/weekday names
    
    seq_search_localized() returns the length of the matching prefix in
    *len, but because it internally case-folds the inputs, it gets
    confused on the length. The caller expects to get the length of the
    prefix in the original string, but what it actually returns is the
    length of the prefix after case-folding, which can be different if the
    case-folded characters have different byte-length than the original,
    or with ICU, if the case-folding changes the number of characters
    (e.g. "ß", the German double s).
    
    To fix, once we have determined that we have a match, work harder to
    find the match's length in the original string.  This adds some
    overhead, but the strings are expected to be short.
    
    The function does "case-folding" by converting a string to upper-case,
    then to lower-case, which is a little ugly given that we have
    dedicated functions for case-folding nowadays. But switching to that
    doesn't seem appropriate to backpatch in a security fix, and that's
    not available in older stable versions, anyway.
    
    Author: Heikki Linnakangas <heikki.linnakangas@iki.fi>
    Reported-by: Xint Code
    Reviewed-by: Jeff Davis <pgsql@j-davis.com>


src/backend/utils/adt/formatting.c | 146 ++++++++++++++++++++--- src/test/regress/expected/collate.linux.utf8.out | 18 +++ src/test/regress/sql/collate.linux.utf8.sql | 3 + 3 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/backend/utils/adt/formatting.c b/src/backend/utils/adt/formatting.c index d52d71b0a8c..641b7aa679e 100644 --- a/src/backend/utils/adt/formatting.c +++ b/src/backend/utils/adt/formatting.c @@ -103,6 +103,7 @@ #define DCH_MAX_ITEM_SIZ 12 /* max localized day name */ #define NUM_MAX_ITEM_SIZ 8 /* roman number (RN has 15 chars) */ +#define MAX_L10N_DATA 80 /* max localized day or month name */ /* * Format parser structs @@ -2328,6 +2329,41 @@ seq_search_ascii(const char *name, const char *const *array, size_t *len) return -1; } +/* + * Compare 'name' with 'element' in a case-insensitive way, by first + * converting 'name' to upper case, then lower case. ('element' is already + * case-folded that way.) + * + * A helper function for seq_search_localized(). + */ +static bool +casefold_str_cmp(const char *name, size_t name_len, + const char *element, size_t element_len, + pg_locale_t mylocale) +{ + /* + * 'name' is expected to fit in MAX_L10N_DATA, even with the case + * conversions. + */ + char upper_substr[MAX_L10N_DATA]; + size_t upper_substr_len; + char lower_substr[MAX_L10N_DATA]; + size_t lower_substr_len; + + upper_substr_len = pg_strupper(upper_substr, sizeof(upper_substr), + name, name_len, + mylocale); + if (upper_substr_len > sizeof(upper_substr) - 1) + return false; /* shouldn't happen */ + lower_substr_len = pg_strlower(lower_substr, sizeof(lower_substr), + upper_substr, upper_substr_len, + mylocale); + if (lower_substr_len > sizeof(lower_substr) - 1) + return false; /* shouldn't happen */ + + return strcmp(lower_substr, element) == 0; +} + /* * Sequentially search an array of possibly non-English words for * a case-insensitive match to the initial character(s) of "name". @@ -2342,8 +2378,11 @@ seq_search_ascii(const char *name, const char *const *array, size_t *len) static int seq_search_localized(const char *name, char **array, size_t *len, Oid collid) { + size_t name_len = strlen(name); + const char *name_end = name + name_len; char *upper_name; char *lower_name; + pg_locale_t mylocale; *len = 0; @@ -2366,36 +2405,109 @@ seq_search_localized(const char *name, char **array, size_t *len, Oid collid) } } + mylocale = pg_newlocale_from_collation(collid); + /* * Fold to upper case, then to lower case, so that we can match reliably * even in languages in which case conversions are not injective. */ - upper_name = str_toupper(name, strlen(name), collid); + upper_name = str_toupper(name, name_len, collid); lower_name = str_tolower(upper_name, strlen(upper_name), collid); pfree(upper_name); for (char **a = array; *a != NULL; a++) { - char *upper_element; - char *lower_element; - size_t element_len; + char upper_element[MAX_L10N_DATA]; + size_t upper_element_len; + char lower_element[MAX_L10N_DATA]; + size_t lower_element_len; /* Likewise upper/lower-case array element */ - upper_element = str_toupper(*a, strlen(*a), collid); - lower_element = str_tolower(upper_element, strlen(upper_element), - collid); - pfree(upper_element); - element_len = strlen(lower_element); - - /* Match? */ - if (strncmp(lower_name, lower_element, element_len) == 0) + upper_element_len = pg_strupper(upper_element, sizeof(upper_element), + *a, strlen(*a), + mylocale); + if (upper_element_len > sizeof(upper_element) - 1) + continue; /* shouldn't happen */ + lower_element_len = pg_strlower(lower_element, sizeof(lower_element), + upper_element, upper_element_len, + mylocale); + if (lower_element_len > sizeof(lower_element) - 1) + continue; /* shouldn't happen */ + + /* Is 'lower_element' a prefix of 'lower_name' ? */ + if (strncmp(lower_name, lower_element, lower_element_len) == 0) { - *len = element_len; - pfree(lower_element); - pfree(lower_name); - return a - array; + /* + * We have a match, but we still need to figure out how long the + * match is. The case conversions could have changed the lengths + * of either string, or both. + */ + const char *ep; + const char *element_end; + size_t element_nchars; + size_t substr_len; + size_t substr_nchars; + + /* + * First, check the easy case that the string matches as whole. + */ + if (strlen(lower_name) == lower_element_len) + { + *len = name_len; + pfree(lower_name); + return a - array; + } + + /* + * Another good guess is that the case conversions did not change + * the number of characters. + */ + + /* count characters in the element */ + ep = lower_element; + element_end = lower_element + lower_element_len; + for (element_nchars = 0; ep < element_end; element_nchars++) + ep += pg_mblen_range(ep, element_end); + + /* + * count the byte length of a substring of 'name' having the same + * character count as the element + */ + substr_len = 0; + for (substr_nchars = 0; + substr_nchars < element_nchars && substr_len < name_len; + substr_nchars++) + { + substr_len += pg_mblen_range(name + substr_len, name_end); + } + + if (casefold_str_cmp(name, substr_len, lower_element, lower_element_len, mylocale)) + { + *len = substr_len; + pfree(lower_name); + return a - array; + } + + /* + * As last resort, try the case conversion and comparison for + * every substring from the beginning of the original string until + * we find a match. + */ + substr_len = 0; + while (substr_len < name_len) + { + substr_len += pg_mblen_range(name + substr_len, name_end); + + if (casefold_str_cmp(name, substr_len, + lower_element, lower_element_len, + mylocale)) + { + *len = substr_len; + pfree(lower_name); + return a - array; + } + } } - pfree(lower_element); } pfree(lower_name); diff --git a/src/test/regress/expected/collate.linux.utf8.out b/src/test/regress/expected/collate.linux.utf8.out index c6e84c27b69..e0a39e4c300 100644 --- a/src/test/regress/expected/collate.linux.utf8.out +++ b/src/test/regress/expected/collate.linux.utf8.out @@ -479,6 +479,24 @@ SELECT to_date('01 Şub 2010', 'DD TMMON YYYY'); SELECT to_date('1234567890ab 2010', 'TMMONTH YYYY'); -- fail ERROR: invalid value "1234567890ab" for "MONTH" DETAIL: The given value did not match any of the allowed values for this field. +SELECT to_date('01 Aralık 2010', 'DD TMMONTH YYYY'); + to_date +------------ + 12-01-2010 +(1 row) + +SELECT to_date('01 aralık 2010', 'DD TMMONTH YYYY'); + to_date +------------ + 12-01-2010 +(1 row) + +SELECT to_date('2010 01 araLık', 'YYYY DD TMMONTH'); + to_date +------------ + 12-01-2010 +(1 row) + -- backwards parsing CREATE VIEW collview1 AS SELECT * FROM collate_test1 WHERE b COLLATE "C" >= 'bbc'; CREATE VIEW collview2 AS SELECT a, b FROM collate_test1 ORDER BY b COLLATE "C"; diff --git a/src/test/regress/sql/collate.linux.utf8.sql b/src/test/regress/sql/collate.linux.utf8.sql index 132d13af0a8..6d726ee9c99 100644 --- a/src/test/regress/sql/collate.linux.utf8.sql +++ b/src/test/regress/sql/collate.linux.utf8.sql @@ -188,6 +188,9 @@ SELECT to_date('01 ŞUB 2010', 'DD TMMON YYYY'); SELECT to_date('01 Şub 2010', 'DD TMMON YYYY'); SELECT to_date('1234567890ab 2010', 'TMMONTH YYYY'); -- fail +SELECT to_date('01 Aralık 2010', 'DD TMMONTH YYYY'); +SELECT to_date('01 aralık 2010', 'DD TMMONTH YYYY'); +SELECT to_date('2010 01 araLık', 'YYYY DD TMMONTH'); -- backwards parsing [parent: 8ce749f8f65c]