From a8f901f22954028f995b3f21712aaa54e603fdb1 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Sun, 18 Oct 2026 07:52:08 -0400
Subject: [PATCH v1] Fix ILIKE '%' followed by a non-ASCII byte in single-byte
 C-ctype databases
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Since 1e493158d3d, C_IMatchText (the ILIKE path for a single-byte
encoding with a C ctype) lowercases with pg_ascii_tolower(), which
returns unsigned char.  MatchText stores the first pattern byte after a
'%' in a char and compares it with GETCHAR(*t); on platforms where char
is signed, a byte >= 0x80 is negative in firstpat but positive on the
other side, so the comparison never succeeds and the '%' never finds a
match.  'xÆy' ILIKE '%Æ%' in a LATIN1 database with LOCALE 'C' returns
false, where 18 returns true.  Declare firstpat as int.

The regression database is normally UTF-8, which never takes this path,
so add a TAP test that creates a LATIN1 database.

Backpatch-through: 19
---
 src/backend/utils/adt/like_match.c            |  7 ++-
 src/test/modules/test_misc/meson.build        |  1 +
 .../test_misc/t/016_ilike_single_byte.pl      | 43 +++++++++++++++++++
 3 files changed, 50 insertions(+), 1 deletion(-)
 create mode 100644 src/test/modules/test_misc/t/016_ilike_single_byte.pl

diff --git a/src/backend/utils/adt/like_match.c b/src/backend/utils/adt/like_match.c
index defcaa96fb5..830d2b98653 100644
--- a/src/backend/utils/adt/like_match.c
+++ b/src/backend/utils/adt/like_match.c
@@ -109,7 +109,12 @@ MatchText(const char *t, int tlen, const char *p, int plen, pg_locale_t locale)
 		 */
 		if (*p == '%')
 		{
-			char		firstpat;
+			/*
+			 * int, not char: under MATCH_LOWER, GETCHAR() yields an unsigned
+			 * char, which a signed char would not compare equal to for bytes
+			 * >= 0x80.
+			 */
+			int			firstpat;
 
 			/*
 			 * % processing is essentially a search for a text position at
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 5d81f5b13be..1b6ffc3a185 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -24,6 +24,7 @@ tests += {
       't/013_temp_obj_multisession.pl',
       't/014_log_statement_max_length.pl',
       't/015_temp_schema_exit_deferrable.pl',
+      't/016_ilike_single_byte.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
     'runningcheck': false,
diff --git a/src/test/modules/test_misc/t/016_ilike_single_byte.pl b/src/test/modules/test_misc/t/016_ilike_single_byte.pl
new file mode 100644
index 00000000000..f01c40cc565
--- /dev/null
+++ b/src/test/modules/test_misc/t/016_ilike_single_byte.pl
@@ -0,0 +1,43 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# ILIKE in a single-byte encoding with a C ctype matches via C_IMatchText
+# (like.c), which folds bytes with pg_ascii_tolower().  A '%' followed by a
+# non-ASCII byte must still find it.  The main regression database is
+# usually UTF-8, which never takes this path, so use a LATIN1 database.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('ilike_single_byte');
+$node->init;
+$node->start;
+
+$node->safe_psql('postgres',
+	q{CREATE DATABASE latin1 TEMPLATE template0 ENCODING 'LATIN1' LOCALE 'C'});
+
+# Build every string from bytes, so the test does not depend on the client
+# encoding: \xc6 is LATIN1 'Æ', \xe6 is 'æ', \xe9 is 'é'.
+my $result = $node->safe_psql(
+	'latin1', q{
+SELECT string_agg(r::text, ',' ORDER BY n)
+  FROM (VALUES
+    (1, convert_from('\x78c679', 'LATIN1') ILIKE convert_from('\x25c625', 'LATIN1')),
+    (2, convert_from('\x6162c6', 'LATIN1') ILIKE convert_from('\x25c6', 'LATIN1')),
+    (3, convert_from('\x786579', 'LATIN1') ILIKE convert_from('\x25e925', 'LATIN1')),
+    (4, convert_from('\x78c6e679', 'LATIN1') ILIKE convert_from('\x25e625', 'LATIN1')),
+    (5, convert_from('\xc66364', 'LATIN1') ILIKE convert_from('\xc625', 'LATIN1')),
+    (6, convert_from('\x78c679', 'LATIN1') NOT ILIKE convert_from('\x25c625', 'LATIN1')),
+    (7, convert_from('\x78e679', 'LATIN1') ILIKE convert_from('\x25c625', 'LATIN1'))
+  ) AS v(n, r)});
+
+# 1: '%Æ%' finds Æ mid-string; 2: at the end; 3: '%é%' does not match 'xey';
+# 4: '%æ%' finds æ after Æ; 5: no leading '%' (always worked);
+# 6: the NOT ILIKE counterpart of 1; 7: Æ and æ are different bytes and the
+# C ctype folds ASCII only, so '%Æ%' does not match 'xæy'.
+is($result, 'true,true,false,true,true,false,false',
+	'ILIKE with % before a non-ASCII byte, LATIN1 / C');
+
+done_testing();
-- 
2.54.0

