On Mon, 2026-07-13 at 13:20 -0400, Tom Lane wrote:
> I wrote:
> > Well, there aren't going to be any ASCII characters that produce 12
> > bytes, and probably not any LATIN1 characters either, so I think we
> > could assume that the shortest input character that could produce
> > such a long output would be 3 bytes.
>
> Ah sorry, I should have written BMP not LATIN1. Maybe it still holds
> across that set, but I'm less sure.
Attached a patch. I added some perl code to check that the expansion
factor of the UTF8 representation doesn't exceed 3, and added a static
assert in the C code to compare to MaxAllocSize.
Regards,
Jeff Davis
From 1fa065c524600609a495433069f43bdbb88e2e05 Mon Sep 17 00:00:00 2001
From: Jeff Davis <[email protected]>
Date: Mon, 13 Jul 2026 19:31:56 -0700
Subject: [PATCH v1] Clarify use of size_t for case mapping.
It cannot overflow, but add some static checking to be sure.
Reported-by: Tom Lane <[email protected]>
Discussion: https://postgr.es/m/[email protected]
---
src/backend/utils/adt/pg_locale_builtin.c | 10 ++++++
.../unicode/generate-unicode_case_table.pl | 32 +++++++++++++++++++
src/common/unicode_case.c | 6 ++++
src/include/common/unicode_case.h | 8 +++++
4 files changed, 56 insertions(+)
diff --git a/src/backend/utils/adt/pg_locale_builtin.c b/src/backend/utils/adt/pg_locale_builtin.c
index 5619daf43c3..c2cf68a518b 100644
--- a/src/backend/utils/adt/pg_locale_builtin.c
+++ b/src/backend/utils/adt/pg_locale_builtin.c
@@ -17,9 +17,19 @@
#include "common/unicode_category.h"
#include "miscadmin.h"
#include "utils/builtins.h"
+#include "utils/memutils.h"
#include "utils/pg_locale.h"
#include "utils/syscache.h"
+/*
+ * The largest text value must fit in MaxAllocSize, but then may grow during
+ * case mapping. While the resulting string will not be representable as a new
+ * text value, we must at least be sure not to overflow a size_t while
+ * processing it.
+ */
+StaticAssertDecl(SIZE_MAX / MAX_CASE_UTF8_EXPANSION > MaxAllocSize,
+ "case mapping may overflow size_t");
+
extern pg_locale_t create_pg_locale_builtin(Oid collid,
MemoryContext context);
extern char *get_collation_actual_version_builtin(const char *collcollate);
diff --git a/src/common/unicode/generate-unicode_case_table.pl b/src/common/unicode/generate-unicode_case_table.pl
index c1b94f47f56..b53b84ebc88 100644
--- a/src/common/unicode/generate-unicode_case_table.pl
+++ b/src/common/unicode/generate-unicode_case_table.pl
@@ -11,6 +11,7 @@
use strict;
use warnings FATAL => 'all';
use Getopt::Long;
+use Encode qw(encode_utf8);
use FindBin;
use lib "$FindBin::RealBin/../../tools/";
@@ -25,6 +26,11 @@ my $output_table_file = "$output_path/unicode_case_table.h";
# of a single character. See Unicode section 5.18 "Case Mappings".
my $MAX_CASE_EXPANSION = 3;
+
+# The maximum expansion factor (in bytes) when case mapping a UTF8
+# string.
+my $MAX_CASE_UTF8_EXPANSION = 3;
+
my $FH;
my %simple = ();
@@ -44,6 +50,10 @@ while (my $line = <$FH>)
die "Simple_Titlecase $code out of range" if $simple_titlecase > 0x10FFFF;
die "Simple_Uppercase $code out of range" if $simple_uppercase > 0x10FFFF;
+ check_utf8_expansion($code, ($simple_uppercase));
+ check_utf8_expansion($code, ($simple_lowercase));
+ check_utf8_expansion($code, ($simple_titlecase));
+
if ($simple_lowercase || $simple_titlecase || $simple_uppercase)
{
$simple{$code} = {
@@ -113,6 +123,10 @@ while (my $line = <$FH>)
die "uppercase expansion for 0x$elts[0] exceeds maximum: '$elts[3]'"
if (scalar @upper) > $MAX_CASE_EXPANSION;
+ check_utf8_expansion($code, @lower);
+ check_utf8_expansion($code, @title);
+ check_utf8_expansion($code, @upper);
+
# pad arrays to a fixed length of 3
while (scalar @upper < $MAX_CASE_EXPANSION) { push @upper, 0x000000 }
while (scalar @lower < $MAX_CASE_EXPANSION) { push @lower, 0x000000 }
@@ -174,6 +188,8 @@ while (my $line = <$FH>)
die "unsupported status type '$status'"
if $status ne 'S' && $status ne 'C' && $status ne 'F';
+ check_utf8_expansion($code, @fold);
+
# initialize simple case mappings if they don't exist
$simple{$code} ||= {
Simple_Lowercase => $code,
@@ -522,6 +538,22 @@ EOS
close $OT;
+# compare byte length of UTF8 representation of the original codepoint
+# to the codepoint(s) it maps to, and track the largest expansion
+sub check_utf8_expansion
+{
+ my ($c, @a) = @_;
+
+ my $utf8len1 = length(encode_utf8(chr $c));
+ my $utf8len2 = length(encode_utf8(join '', map { chr } @a));
+ my $factor = 0;
+ $factor++ while ($factor * $utf8len1 < $utf8len2);
+
+ my $c_hex = sprintf "0x%06x", $c;
+ die "UTF8 expansion of $c_hex exceeds $MAX_CASE_UTF8_EXPANSION"
+ if ($factor > $MAX_CASE_UTF8_EXPANSION);
+}
+
# The function generates C code with a series of nested if-else conditions
# to search for the matching interval.
sub branch
diff --git a/src/common/unicode_case.c b/src/common/unicode_case.c
index 399f08235a9..5f68154c8aa 100644
--- a/src/common/unicode_case.c
+++ b/src/common/unicode_case.c
@@ -237,6 +237,12 @@ convert_case(char *dst, size_t dstsize, const char *src, size_t srclen,
size_t result_len = 0;
size_t boundary = 0;
+ /*
+ * Must be guaranteed by caller to avoid overflow (text values limited to
+ * MaxAllocSize anyway).
+ */
+ Assert(srclen < SIZE_MAX / MAX_CASE_UTF8_EXPANSION);
+
Assert((str_casekind == CaseTitle && wbnext && wbstate) ||
(str_casekind != CaseTitle && !wbnext && !wbstate));
diff --git a/src/include/common/unicode_case.h b/src/include/common/unicode_case.h
index 1cbc0c14bc2..a2b752667ca 100644
--- a/src/include/common/unicode_case.h
+++ b/src/include/common/unicode_case.h
@@ -14,6 +14,14 @@
#ifndef UNICODE_CASE_H
#define UNICODE_CASE_H
+/*
+ * The maximum expansion factor (in bytes) when case mapping a UTF8 string.
+ *
+ * Not guaranteed by the standard, but checked while constructing the mapping
+ * tables from Unicode data.
+ */
+#define MAX_CASE_UTF8_EXPANSION 3
+
typedef size_t (*WordBoundaryNext) (void *wbstate);
char32_t unicode_lowercase_simple(char32_t code);
--
2.43.0