On Fri, Feb 27, 2026 at 12:05:28PM +0900, Michael Paquier wrote:
> - 0001 is a test suite that I have been relying on for some time,
> introduced as the test module test_saslprep.  One artifact that Heikki
> has mentioned to me offline while discussing this tool is that we
> could also have a check for the entire range of valid UTF8 codepoints
> to make sure that we never return an empty password for all these
> codepoints.  This check is slightly expensive (3s on my laptop, which
> is not bad still a bit expensive), so I have implemented that as a TAP
> test controlled by a PG_TEST_EXTRA.  The only exception for the empty
> password case is the nul character, that we disallow in CREATE/ALTER
> ROLE.  This test suite also adds a test to cover 390b3cbbb2af with an
> incomplete UTF8 sequence, as a nice bonus.

While thinking more about this one, I have come up with a smarter
query based on set_byte() to build a full range of byteas for the
ASCII characters to check, leading to this simpler pattern:
SELECT set_byte('\x00'::bytea, 0, a) FROM generate_series(0, 127);

A second thing that I have adjusted is the output for non-printable
characters, using a CASE/WHEN shortcut.  Attached is an updated
version of the patch set with these adjustments.
--
Michael
From 95b2aa062f88a2da953cf68e30dcd0d01d387455 Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email protected]>
Date: Mon, 2 Mar 2026 15:55:11 +0900
Subject: [PATCH v2 1/2] test_saslprep: Add test module to stress SASLprep

This includes two functions:
- test_saslprep(), that performs pg_saslprep on a bytea.
- test_saslprep_ranges(), able to check for all valid ranges of UTF-8
endpoints how pg_saslprep() works.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 src/test/modules/test_saslprep/.gitignore     |   4 +
 src/test/modules/test_saslprep/Makefile       |  25 ++
 src/test/modules/test_saslprep/README         |  25 ++
 .../test_saslprep/expected/test_saslprep.out  | 152 ++++++++++
 src/test/modules/test_saslprep/meson.build    |  38 +++
 .../test_saslprep/sql/test_saslprep.sql       |  19 ++
 .../test_saslprep/t/001_saslprep_ranges.pl    |  38 +++
 .../test_saslprep/test_saslprep--1.0.sql      |  30 ++
 .../modules/test_saslprep/test_saslprep.c     | 277 ++++++++++++++++++
 .../test_saslprep/test_saslprep.control       |   5 +
 doc/src/sgml/regress.sgml                     |  10 +
 13 files changed, 625 insertions(+)
 create mode 100644 src/test/modules/test_saslprep/.gitignore
 create mode 100644 src/test/modules/test_saslprep/Makefile
 create mode 100644 src/test/modules/test_saslprep/README
 create mode 100644 src/test/modules/test_saslprep/expected/test_saslprep.out
 create mode 100644 src/test/modules/test_saslprep/meson.build
 create mode 100644 src/test/modules/test_saslprep/sql/test_saslprep.sql
 create mode 100644 src/test/modules/test_saslprep/t/001_saslprep_ranges.pl
 create mode 100644 src/test/modules/test_saslprep/test_saslprep--1.0.sql
 create mode 100644 src/test/modules/test_saslprep/test_saslprep.c
 create mode 100644 src/test/modules/test_saslprep/test_saslprep.control

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4ac5c84db439..ddce25cfd90f 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -45,6 +45,7 @@ SUBDIRS = \
                  test_regex \
                  test_resowner \
                  test_rls_hooks \
+                 test_saslprep \
                  test_shm_mq \
                  test_slru \
                  test_tidstore \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index e2b3eef41368..645f09a2260c 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -46,6 +46,7 @@ subdir('test_rbtree')
 subdir('test_regex')
 subdir('test_resowner')
 subdir('test_rls_hooks')
+subdir('test_saslprep')
 subdir('test_shm_mq')
 subdir('test_slru')
 subdir('test_tidstore')
diff --git a/src/test/modules/test_saslprep/.gitignore 
b/src/test/modules/test_saslprep/.gitignore
new file mode 100644
index 000000000000..5dcb3ff97235
--- /dev/null
+++ b/src/test/modules/test_saslprep/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_saslprep/Makefile 
b/src/test/modules/test_saslprep/Makefile
new file mode 100644
index 000000000000..f74375ee4ab4
--- /dev/null
+++ b/src/test/modules/test_saslprep/Makefile
@@ -0,0 +1,25 @@
+# src/test/modules/test_saslprep/Makefile
+
+MODULE_big = test_saslprep
+OBJS = \
+       $(WIN32RES) \
+       test_saslprep.o
+PGFILEDESC = "test_saslprep - test SASLprep implementation"
+
+EXTENSION = test_saslprep
+DATA = test_saslprep--1.0.sql
+
+REGRESS = test_saslprep
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_saslprep
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_saslprep/README 
b/src/test/modules/test_saslprep/README
new file mode 100644
index 000000000000..37e32fdc5669
--- /dev/null
+++ b/src/test/modules/test_saslprep/README
@@ -0,0 +1,25 @@
+src/test/modules/test_saslprep
+
+Tests for SASLprep
+==================
+
+This repository contains a test suite for stressing the SASLprep
+implementation internal to PostgreSQL.
+
+It provides a set of functions able to check the validity of a SASLprep
+operation for a single byte as well as a range of these, acting as thin
+wrappers standing on top of pg_saslprep().
+
+Running the tests
+=================
+
+NOTE: A portion of the tests requires --enable-tap-tests, with
+PG_TEST_EXTRA=saslprep set to run the TAP test suite.
+
+Run
+    make check PG_TEST_EXTRA=saslprep
+or
+    make installcheck PG_TEST_EXTRA=saslprep
+
+The SQL test suite can run with or without PG_TEST_EXTRA=saslprep
+set.
diff --git a/src/test/modules/test_saslprep/expected/test_saslprep.out 
b/src/test/modules/test_saslprep/expected/test_saslprep.out
new file mode 100644
index 000000000000..f72dbffa0a11
--- /dev/null
+++ b/src/test/modules/test_saslprep/expected/test_saslprep.out
@@ -0,0 +1,152 @@
+-- Tests for SASLprep
+CREATE EXTENSION test_saslprep;
+-- Incomplete UTF-8 sequence.
+SELECT test_saslprep('\xef');
+  test_saslprep  
+-----------------
+ (,INVALID_UTF8)
+(1 row)
+
+-- Range of ASCII characters.
+SELECT
+    CASE
+      WHEN a = 0   THEN '<NUL>'
+      WHEN a < 32  THEN '<CTL_' || a::text || '>'
+      WHEN a = 127 THEN '<DEL>'
+      ELSE chr(a) END AS dat,
+    set_byte('\x00'::bytea, 0, a) AS byt,
+    test_saslprep(set_byte('\x00'::bytea, 0, a)) AS saslprep
+  FROM generate_series(0,127) AS a;
+   dat    | byt  |     saslprep      
+----------+------+-------------------
+ <NUL>    | \x00 | ("\\x",SUCCESS)
+ <CTL_1>  | \x01 | ("\\x01",SUCCESS)
+ <CTL_2>  | \x02 | ("\\x02",SUCCESS)
+ <CTL_3>  | \x03 | ("\\x03",SUCCESS)
+ <CTL_4>  | \x04 | ("\\x04",SUCCESS)
+ <CTL_5>  | \x05 | ("\\x05",SUCCESS)
+ <CTL_6>  | \x06 | ("\\x06",SUCCESS)
+ <CTL_7>  | \x07 | ("\\x07",SUCCESS)
+ <CTL_8>  | \x08 | ("\\x08",SUCCESS)
+ <CTL_9>  | \x09 | ("\\x09",SUCCESS)
+ <CTL_10> | \x0a | ("\\x0a",SUCCESS)
+ <CTL_11> | \x0b | ("\\x0b",SUCCESS)
+ <CTL_12> | \x0c | ("\\x0c",SUCCESS)
+ <CTL_13> | \x0d | ("\\x0d",SUCCESS)
+ <CTL_14> | \x0e | ("\\x0e",SUCCESS)
+ <CTL_15> | \x0f | ("\\x0f",SUCCESS)
+ <CTL_16> | \x10 | ("\\x10",SUCCESS)
+ <CTL_17> | \x11 | ("\\x11",SUCCESS)
+ <CTL_18> | \x12 | ("\\x12",SUCCESS)
+ <CTL_19> | \x13 | ("\\x13",SUCCESS)
+ <CTL_20> | \x14 | ("\\x14",SUCCESS)
+ <CTL_21> | \x15 | ("\\x15",SUCCESS)
+ <CTL_22> | \x16 | ("\\x16",SUCCESS)
+ <CTL_23> | \x17 | ("\\x17",SUCCESS)
+ <CTL_24> | \x18 | ("\\x18",SUCCESS)
+ <CTL_25> | \x19 | ("\\x19",SUCCESS)
+ <CTL_26> | \x1a | ("\\x1a",SUCCESS)
+ <CTL_27> | \x1b | ("\\x1b",SUCCESS)
+ <CTL_28> | \x1c | ("\\x1c",SUCCESS)
+ <CTL_29> | \x1d | ("\\x1d",SUCCESS)
+ <CTL_30> | \x1e | ("\\x1e",SUCCESS)
+ <CTL_31> | \x1f | ("\\x1f",SUCCESS)
+          | \x20 | ("\\x20",SUCCESS)
+ !        | \x21 | ("\\x21",SUCCESS)
+ "        | \x22 | ("\\x22",SUCCESS)
+ #        | \x23 | ("\\x23",SUCCESS)
+ $        | \x24 | ("\\x24",SUCCESS)
+ %        | \x25 | ("\\x25",SUCCESS)
+ &        | \x26 | ("\\x26",SUCCESS)
+ '        | \x27 | ("\\x27",SUCCESS)
+ (        | \x28 | ("\\x28",SUCCESS)
+ )        | \x29 | ("\\x29",SUCCESS)
+ *        | \x2a | ("\\x2a",SUCCESS)
+ +        | \x2b | ("\\x2b",SUCCESS)
+ ,        | \x2c | ("\\x2c",SUCCESS)
+ -        | \x2d | ("\\x2d",SUCCESS)
+ .        | \x2e | ("\\x2e",SUCCESS)
+ /        | \x2f | ("\\x2f",SUCCESS)
+ 0        | \x30 | ("\\x30",SUCCESS)
+ 1        | \x31 | ("\\x31",SUCCESS)
+ 2        | \x32 | ("\\x32",SUCCESS)
+ 3        | \x33 | ("\\x33",SUCCESS)
+ 4        | \x34 | ("\\x34",SUCCESS)
+ 5        | \x35 | ("\\x35",SUCCESS)
+ 6        | \x36 | ("\\x36",SUCCESS)
+ 7        | \x37 | ("\\x37",SUCCESS)
+ 8        | \x38 | ("\\x38",SUCCESS)
+ 9        | \x39 | ("\\x39",SUCCESS)
+ :        | \x3a | ("\\x3a",SUCCESS)
+ ;        | \x3b | ("\\x3b",SUCCESS)
+ <        | \x3c | ("\\x3c",SUCCESS)
+ =        | \x3d | ("\\x3d",SUCCESS)
+ >        | \x3e | ("\\x3e",SUCCESS)
+ ?        | \x3f | ("\\x3f",SUCCESS)
+ @        | \x40 | ("\\x40",SUCCESS)
+ A        | \x41 | ("\\x41",SUCCESS)
+ B        | \x42 | ("\\x42",SUCCESS)
+ C        | \x43 | ("\\x43",SUCCESS)
+ D        | \x44 | ("\\x44",SUCCESS)
+ E        | \x45 | ("\\x45",SUCCESS)
+ F        | \x46 | ("\\x46",SUCCESS)
+ G        | \x47 | ("\\x47",SUCCESS)
+ H        | \x48 | ("\\x48",SUCCESS)
+ I        | \x49 | ("\\x49",SUCCESS)
+ J        | \x4a | ("\\x4a",SUCCESS)
+ K        | \x4b | ("\\x4b",SUCCESS)
+ L        | \x4c | ("\\x4c",SUCCESS)
+ M        | \x4d | ("\\x4d",SUCCESS)
+ N        | \x4e | ("\\x4e",SUCCESS)
+ O        | \x4f | ("\\x4f",SUCCESS)
+ P        | \x50 | ("\\x50",SUCCESS)
+ Q        | \x51 | ("\\x51",SUCCESS)
+ R        | \x52 | ("\\x52",SUCCESS)
+ S        | \x53 | ("\\x53",SUCCESS)
+ T        | \x54 | ("\\x54",SUCCESS)
+ U        | \x55 | ("\\x55",SUCCESS)
+ V        | \x56 | ("\\x56",SUCCESS)
+ W        | \x57 | ("\\x57",SUCCESS)
+ X        | \x58 | ("\\x58",SUCCESS)
+ Y        | \x59 | ("\\x59",SUCCESS)
+ Z        | \x5a | ("\\x5a",SUCCESS)
+ [        | \x5b | ("\\x5b",SUCCESS)
+ \        | \x5c | ("\\x5c",SUCCESS)
+ ]        | \x5d | ("\\x5d",SUCCESS)
+ ^        | \x5e | ("\\x5e",SUCCESS)
+ _        | \x5f | ("\\x5f",SUCCESS)
+ `        | \x60 | ("\\x60",SUCCESS)
+ a        | \x61 | ("\\x61",SUCCESS)
+ b        | \x62 | ("\\x62",SUCCESS)
+ c        | \x63 | ("\\x63",SUCCESS)
+ d        | \x64 | ("\\x64",SUCCESS)
+ e        | \x65 | ("\\x65",SUCCESS)
+ f        | \x66 | ("\\x66",SUCCESS)
+ g        | \x67 | ("\\x67",SUCCESS)
+ h        | \x68 | ("\\x68",SUCCESS)
+ i        | \x69 | ("\\x69",SUCCESS)
+ j        | \x6a | ("\\x6a",SUCCESS)
+ k        | \x6b | ("\\x6b",SUCCESS)
+ l        | \x6c | ("\\x6c",SUCCESS)
+ m        | \x6d | ("\\x6d",SUCCESS)
+ n        | \x6e | ("\\x6e",SUCCESS)
+ o        | \x6f | ("\\x6f",SUCCESS)
+ p        | \x70 | ("\\x70",SUCCESS)
+ q        | \x71 | ("\\x71",SUCCESS)
+ r        | \x72 | ("\\x72",SUCCESS)
+ s        | \x73 | ("\\x73",SUCCESS)
+ t        | \x74 | ("\\x74",SUCCESS)
+ u        | \x75 | ("\\x75",SUCCESS)
+ v        | \x76 | ("\\x76",SUCCESS)
+ w        | \x77 | ("\\x77",SUCCESS)
+ x        | \x78 | ("\\x78",SUCCESS)
+ y        | \x79 | ("\\x79",SUCCESS)
+ z        | \x7a | ("\\x7a",SUCCESS)
+ {        | \x7b | ("\\x7b",SUCCESS)
+ |        | \x7c | ("\\x7c",SUCCESS)
+ }        | \x7d | ("\\x7d",SUCCESS)
+ ~        | \x7e | ("\\x7e",SUCCESS)
+ <DEL>    | \x7f | ("\\x7f",SUCCESS)
+(128 rows)
+
+DROP EXTENSION test_saslprep;
diff --git a/src/test/modules/test_saslprep/meson.build 
b/src/test/modules/test_saslprep/meson.build
new file mode 100644
index 000000000000..2fcc403ca072
--- /dev/null
+++ b/src/test/modules/test_saslprep/meson.build
@@ -0,0 +1,38 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_saslprep_sources = files(
+  'test_saslprep.c',
+)
+
+if host_system == 'windows'
+  test_saslprep_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_saslprep',
+    '--FILEDESC', 'test_saslprep - test SASLprep implementation',])
+endif
+
+test_saslprep = shared_module('test_saslprep',
+  test_saslprep_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_saslprep
+
+test_install_data += files(
+  'test_saslprep.control',
+  'test_saslprep--1.0.sql',
+)
+
+tests += {
+  'name': 'test_saslprep',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'test_saslprep',
+    ],
+  },
+  'tap': {
+    'tests': [
+      't/001_saslprep_ranges.pl',
+    ],
+  },
+}
diff --git a/src/test/modules/test_saslprep/sql/test_saslprep.sql 
b/src/test/modules/test_saslprep/sql/test_saslprep.sql
new file mode 100644
index 000000000000..00bad48eca70
--- /dev/null
+++ b/src/test/modules/test_saslprep/sql/test_saslprep.sql
@@ -0,0 +1,19 @@
+-- Tests for SASLprep
+
+CREATE EXTENSION test_saslprep;
+
+-- Incomplete UTF-8 sequence.
+SELECT test_saslprep('\xef');
+
+-- Range of ASCII characters.
+SELECT
+    CASE
+      WHEN a = 0   THEN '<NUL>'
+      WHEN a < 32  THEN '<CTL_' || a::text || '>'
+      WHEN a = 127 THEN '<DEL>'
+      ELSE chr(a) END AS dat,
+    set_byte('\x00'::bytea, 0, a) AS byt,
+    test_saslprep(set_byte('\x00'::bytea, 0, a)) AS saslprep
+  FROM generate_series(0,127) AS a;
+
+DROP EXTENSION test_saslprep;
diff --git a/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl 
b/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl
new file mode 100644
index 000000000000..b2b40e9108b6
--- /dev/null
+++ b/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl
@@ -0,0 +1,38 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test all ranges of valid UTF-8 codepoints under SASLprep.
+#
+# This test is expensive and enabled with PG_TEST_EXTRA, which is
+# why it exists as a TAP test.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bsaslprep\b/)
+{
+       plan skip_all => "test saslprep not enabled in PG_TEST_EXTRA";
+}
+
+# Initialize node
+my $node = PostgreSQL::Test::Cluster->new('main');
+
+$node->init;
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION test_saslprep;');
+
+# Among all the valid UTF-8 codepoint ranges, our implementation of
+# SASLprep should never return an empty password if the operation is
+# considered a success.
+# The only exception is the nul character, prohibited in input of
+# CREATE/ALTER ROLE.
+my $result = $node->safe_psql(
+       'postgres', qq[SELECT * FROM test_saslprep_ranges()
+  WHERE status = 'SUCCESS' AND res IN (NULL, '')
+]);
+
+is($result, 'U+0000|SUCCESS|\x00|\x', "Only nul authorized for all valid UTF8 
codepoints");
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/test_saslprep/test_saslprep--1.0.sql 
b/src/test/modules/test_saslprep/test_saslprep--1.0.sql
new file mode 100644
index 000000000000..01e5244809e7
--- /dev/null
+++ b/src/test/modules/test_saslprep/test_saslprep--1.0.sql
@@ -0,0 +1,30 @@
+/* src/test/modules/test_saslprep/test_saslprep--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_saslprep" to load this file. \quit
+
+--
+-- test_saslprep(bytea)
+--
+-- Tests single byte sequence in SASLprep.
+--
+CREATE FUNCTION test_saslprep(IN src bytea,
+    OUT res bytea,
+    OUT status text)
+RETURNS record
+AS 'MODULE_PATHNAME'
+LANGUAGE C STRICT;
+
+--
+-- test_saslprep_ranges
+--
+-- Tests all possible ranges of byte sequences in SASLprep.
+--
+CREATE FUNCTION test_saslprep_ranges(
+    OUT codepoint text,
+    OUT status text,
+    OUT src bytea,
+    OUT res bytea)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME'
+LANGUAGE C STRICT;
diff --git a/src/test/modules/test_saslprep/test_saslprep.c 
b/src/test/modules/test_saslprep/test_saslprep.c
new file mode 100644
index 000000000000..c57627cc53f8
--- /dev/null
+++ b/src/test/modules/test_saslprep/test_saslprep.c
@@ -0,0 +1,277 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_saslprep.c
+ *             Test harness for the SASLprep implementation.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *             src/test/modules/test_saslprep/test_saslprep.c
+ *
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "common/saslprep.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "mb/pg_wchar.h"
+#include "miscadmin.h"
+#include "utils/builtins.h"
+
+PG_MODULE_MAGIC;
+
+static const char *
+saslprep_status_to_text(pg_saslprep_rc rc)
+{
+       const char *status = "???";
+
+       switch (rc)
+       {
+               case SASLPREP_OOM:
+                       status = "OOM";
+                       break;
+               case SASLPREP_SUCCESS:
+                       status = "SUCCESS";
+                       break;
+               case SASLPREP_INVALID_UTF8:
+                       status = "INVALID_UTF8";
+                       break;
+               case SASLPREP_PROHIBITED:
+                       status = "PROHIBITED";
+                       break;
+       }
+
+       return status;
+}
+
+/*
+ * Simple function to test SASLprep with arbitrary bytes as input.
+ *
+ * This takes a bytea in input, returning in output the generating data as
+ * bytea with the status returned by pg_saslprep().
+ */
+PG_FUNCTION_INFO_V1(test_saslprep);
+Datum
+test_saslprep(PG_FUNCTION_ARGS)
+{
+       bytea      *string = PG_GETARG_BYTEA_PP(0);
+       char       *src;
+       Size            src_len;
+       char       *input_data;
+       char       *result;
+       Size            result_len;
+       bytea      *result_bytea = NULL;
+       const char *status = NULL;
+       Datum      *values;
+       bool       *nulls;
+       TupleDesc       tupdesc;
+       pg_saslprep_rc rc;
+
+       /* determine result type */
+       if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+               elog(ERROR, "return type must be a row type");
+
+       values = palloc0_array(Datum, tupdesc->natts);
+       nulls = palloc0_array(bool, tupdesc->natts);
+
+       src_len = VARSIZE_ANY_EXHDR(string);
+       src = VARDATA_ANY(string);
+
+       /*
+        * Copy the input given, to make SASLprep() act on a sanitized string.
+        */
+       input_data = palloc0(src_len + 1);
+       strlcpy(input_data, src, src_len + 1);
+
+       rc = pg_saslprep(input_data, &result);
+       status = saslprep_status_to_text(rc);
+
+       if (result)
+       {
+               result_len = strlen(result);
+               result_bytea = palloc(result_len + VARHDRSZ);
+               SET_VARSIZE(result_bytea, result_len + VARHDRSZ);
+               memcpy(VARDATA(result_bytea), result, result_len);
+               values[0] = PointerGetDatum(result_bytea);
+       }
+       else
+               nulls[0] = true;
+
+       values[1] = CStringGetTextDatum(status);
+
+       PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, 
nulls)));
+}
+
+/* Context structure for set-returning function with ranges */
+typedef struct
+{
+       int                     current_range;
+       char32_t        current_codepoint;
+} pg_saslprep_test_context;
+
+/*
+ * UTF-8 code point ranges.
+ */
+typedef struct
+{
+       char32_t        start_codepoint;
+       char32_t        end_codepoint;
+} pg_utf8_codepoint_range;
+
+static const pg_utf8_codepoint_range pg_utf8_test_ranges[] = {
+       /* 1, 2, 3 bytes */
+       {0x0000, 0xD7FF}, /* Basic Multilingual Plane, before surrogates */
+       {0xE000, 0xFFFF}, /* Basic Multilingual Plane, after surrogates */
+       /* 4 bytes */
+       {0x10000, 0x1FFFF}, /* Supplementary Multilingual Plane */
+       {0x20000, 0x2FFFF}, /* Supplementary Ideographic Plane */
+       {0x30000, 0x3FFFF}, /* Tertiary Ideographic Plane */
+       {0x40000, 0xDFFFF}, /* Unassigned planes */
+       {0xE0000, 0xEFFFF}, /* Supplementary Special-purpose Plane */
+       {0xF0000, 0xFFFFF}, /* Private Use Area A */
+       {0x100000, 0x10FFFF}, /* Private Use Area B */
+};
+
+#define PG_UTF8_TEST_RANGES_LEN \
+       (sizeof(pg_utf8_test_ranges) / sizeof(pg_utf8_test_ranges[0]))
+
+
+/*
+ * test_saslprep_ranges
+ *
+ * Test SASLprep across various UTF-8 ranges.
+ */
+PG_FUNCTION_INFO_V1(test_saslprep_ranges);
+Datum
+test_saslprep_ranges(PG_FUNCTION_ARGS)
+{
+       FuncCallContext *funcctx;
+       pg_saslprep_test_context *ctx;
+       HeapTuple       tuple;
+       Datum           result;
+
+       /* First call setup */
+       if (SRF_IS_FIRSTCALL())
+       {
+               MemoryContext oldcontext;
+               TupleDesc       tupdesc;
+
+               funcctx = SRF_FIRSTCALL_INIT();
+               oldcontext = 
MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+
+               if (get_call_result_type(fcinfo, NULL, &tupdesc) != 
TYPEFUNC_COMPOSITE)
+                       elog(ERROR, "return type must be a row type");
+               funcctx->tuple_desc = tupdesc;
+
+               /* Allocate context with range setup */
+               ctx = (pg_saslprep_test_context *) 
palloc(sizeof(pg_saslprep_test_context));
+               ctx->current_range = 0;
+               ctx->current_codepoint = pg_utf8_test_ranges[0].start_codepoint;
+               funcctx->user_fctx = ctx;
+
+               MemoryContextSwitchTo(oldcontext);
+       }
+
+       funcctx = SRF_PERCALL_SETUP();
+       ctx = (pg_saslprep_test_context *) funcctx->user_fctx;
+
+       while (ctx->current_range < PG_UTF8_TEST_RANGES_LEN)
+       {
+               char32_t        codepoint = ctx->current_codepoint;
+               unsigned char utf8_buf[5];
+               char            input_str[6];
+               char       *output = NULL;
+               pg_saslprep_rc rc;
+               int                     utf8_len;
+               const char *status;
+               bytea      *input_bytea;
+               bytea      *output_bytea;
+               char            codepoint_str[16];
+               Datum           values[4] = {0};
+               bool            nulls[4] = {0};
+               const pg_utf8_codepoint_range *range =
+                       &pg_utf8_test_ranges[ctx->current_range];
+
+               CHECK_FOR_INTERRUPTS();
+
+               /* Switch to next range if finished with the previous one */
+               if (ctx->current_codepoint > range->end_codepoint)
+               {
+                       ctx->current_range++;
+                       if (ctx->current_range < PG_UTF8_TEST_RANGES_LEN)
+                               ctx->current_codepoint =
+                                       
pg_utf8_test_ranges[ctx->current_range].start_codepoint;
+                       continue;
+               }
+
+               codepoint = ctx->current_codepoint;
+
+               /* Convert code point to UTF-8 */
+               utf8_len = unicode_utf8len(codepoint);
+               if (unlikely(utf8_len == 0))
+               {
+                       ctx->current_codepoint++;
+                       continue;
+               }
+               unicode_to_utf8(codepoint, utf8_buf);
+
+               /* Create null-terminated string */
+               memcpy(input_str, utf8_buf, utf8_len);
+               input_str[utf8_len] = '\0';
+
+               /* Test with pg_saslprep */
+               rc = pg_saslprep(input_str, &output);
+
+               /* Prepare output values */
+               MemSet(nulls, false, sizeof(nulls));
+
+               /* codepoint as text U+XXXX format */
+               if (codepoint <= 0xFFFF)
+                       snprintf(codepoint_str, sizeof(codepoint_str), 
"U+%04X", codepoint);
+               else
+                       snprintf(codepoint_str, sizeof(codepoint_str), 
"U+%06X", codepoint);
+               values[0] = CStringGetTextDatum(codepoint_str);
+
+               /* status */
+               status = saslprep_status_to_text(rc);
+               values[1] = CStringGetTextDatum(status);
+
+               /* input_bytes */
+               input_bytea = (bytea *) palloc(VARHDRSZ + utf8_len);
+               SET_VARSIZE(input_bytea, VARHDRSZ + utf8_len);
+               memcpy(VARDATA(input_bytea), utf8_buf, utf8_len);
+               values[2] = PointerGetDatum(input_bytea);
+
+               /* output_bytes */
+               if (output != NULL)
+               {
+                       int                     output_len = strlen(output);
+
+                       output_bytea = (bytea *) palloc(VARHDRSZ + output_len);
+                       SET_VARSIZE(output_bytea, VARHDRSZ + output_len);
+                       memcpy(VARDATA(output_bytea), output, output_len);
+                       values[3] = PointerGetDatum(output_bytea);
+                       pfree(output);
+               }
+               else
+               {
+                       nulls[3] = true;
+                       values[3] = (Datum) 0;
+               }
+
+               /* Build and return tuple */
+               tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
+               result = HeapTupleGetDatum(tuple);
+
+               /* Move to next code point */
+               ctx->current_codepoint++;
+
+               SRF_RETURN_NEXT(funcctx, result);
+       }
+
+       /* All done */
+       SRF_RETURN_DONE(funcctx);
+}
diff --git a/src/test/modules/test_saslprep/test_saslprep.control 
b/src/test/modules/test_saslprep/test_saslprep.control
new file mode 100644
index 000000000000..13015c43880f
--- /dev/null
+++ b/src/test/modules/test_saslprep/test_saslprep.control
@@ -0,0 +1,5 @@
+# test_saslprep extension
+comment = 'Test SASLprep implementation'
+default_version = '1.0'
+module_pathname = '$libdir/test_saslprep'
+relocatable = true
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index d80dd46c5fdb..285d06195336 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -342,6 +342,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl 
load_balance libpq_encryption'
      </listitem>
     </varlistentry>
 
+    <varlistentry>
+     <term><literal>saslprep</literal></term>
+     <listitem>
+      <para>
+       Runs the TAP test suite under 
<filename>src/test/modules/test_saslprep</filename>.
+       Not enabled by default because it is resource-intensive.
+      </para>
+     </listitem>
+    </varlistentry>
+
     <varlistentry>
      <term><literal>sepgsql</literal></term>
      <listitem>
-- 
2.53.0

From 49a1d26e006cf45389db82521b62a26bcdf0487e Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email protected]>
Date: Fri, 27 Feb 2026 11:42:50 +0900
Subject: [PATCH v2 2/2] Make implementation of SASLprep compliant for ASCII
 characters

---
 src/common/saslprep.c                         | 12 ----
 .../test_saslprep/expected/test_saslprep.out  | 66 +++++++++----------
 .../test_saslprep/t/001_saslprep_ranges.pl    |  4 +-
 3 files changed, 34 insertions(+), 48 deletions(-)

diff --git a/src/common/saslprep.c b/src/common/saslprep.c
index 2ad2cefc14fb..38d50dd823c4 100644
--- a/src/common/saslprep.c
+++ b/src/common/saslprep.c
@@ -1061,18 +1061,6 @@ pg_saslprep(const char *input, char **output)
        /* Ensure we return *output as NULL on failure */
        *output = NULL;
 
-       /*
-        * Quick check if the input is pure ASCII.  An ASCII string requires no
-        * further processing.
-        */
-       if (pg_is_ascii(input))
-       {
-               *output = STRDUP(input);
-               if (!(*output))
-                       goto oom;
-               return SASLPREP_SUCCESS;
-       }
-
        /*
         * Convert the input from UTF-8 to an array of Unicode codepoints.
         *
diff --git a/src/test/modules/test_saslprep/expected/test_saslprep.out 
b/src/test/modules/test_saslprep/expected/test_saslprep.out
index f72dbffa0a11..92f93365343e 100644
--- a/src/test/modules/test_saslprep/expected/test_saslprep.out
+++ b/src/test/modules/test_saslprep/expected/test_saslprep.out
@@ -19,38 +19,38 @@ SELECT
   FROM generate_series(0,127) AS a;
    dat    | byt  |     saslprep      
 ----------+------+-------------------
- <NUL>    | \x00 | ("\\x",SUCCESS)
- <CTL_1>  | \x01 | ("\\x01",SUCCESS)
- <CTL_2>  | \x02 | ("\\x02",SUCCESS)
- <CTL_3>  | \x03 | ("\\x03",SUCCESS)
- <CTL_4>  | \x04 | ("\\x04",SUCCESS)
- <CTL_5>  | \x05 | ("\\x05",SUCCESS)
- <CTL_6>  | \x06 | ("\\x06",SUCCESS)
- <CTL_7>  | \x07 | ("\\x07",SUCCESS)
- <CTL_8>  | \x08 | ("\\x08",SUCCESS)
- <CTL_9>  | \x09 | ("\\x09",SUCCESS)
- <CTL_10> | \x0a | ("\\x0a",SUCCESS)
- <CTL_11> | \x0b | ("\\x0b",SUCCESS)
- <CTL_12> | \x0c | ("\\x0c",SUCCESS)
- <CTL_13> | \x0d | ("\\x0d",SUCCESS)
- <CTL_14> | \x0e | ("\\x0e",SUCCESS)
- <CTL_15> | \x0f | ("\\x0f",SUCCESS)
- <CTL_16> | \x10 | ("\\x10",SUCCESS)
- <CTL_17> | \x11 | ("\\x11",SUCCESS)
- <CTL_18> | \x12 | ("\\x12",SUCCESS)
- <CTL_19> | \x13 | ("\\x13",SUCCESS)
- <CTL_20> | \x14 | ("\\x14",SUCCESS)
- <CTL_21> | \x15 | ("\\x15",SUCCESS)
- <CTL_22> | \x16 | ("\\x16",SUCCESS)
- <CTL_23> | \x17 | ("\\x17",SUCCESS)
- <CTL_24> | \x18 | ("\\x18",SUCCESS)
- <CTL_25> | \x19 | ("\\x19",SUCCESS)
- <CTL_26> | \x1a | ("\\x1a",SUCCESS)
- <CTL_27> | \x1b | ("\\x1b",SUCCESS)
- <CTL_28> | \x1c | ("\\x1c",SUCCESS)
- <CTL_29> | \x1d | ("\\x1d",SUCCESS)
- <CTL_30> | \x1e | ("\\x1e",SUCCESS)
- <CTL_31> | \x1f | ("\\x1f",SUCCESS)
+ <NUL>    | \x00 | (,PROHIBITED)
+ <CTL_1>  | \x01 | (,PROHIBITED)
+ <CTL_2>  | \x02 | (,PROHIBITED)
+ <CTL_3>  | \x03 | (,PROHIBITED)
+ <CTL_4>  | \x04 | (,PROHIBITED)
+ <CTL_5>  | \x05 | (,PROHIBITED)
+ <CTL_6>  | \x06 | (,PROHIBITED)
+ <CTL_7>  | \x07 | (,PROHIBITED)
+ <CTL_8>  | \x08 | (,PROHIBITED)
+ <CTL_9>  | \x09 | (,PROHIBITED)
+ <CTL_10> | \x0a | (,PROHIBITED)
+ <CTL_11> | \x0b | (,PROHIBITED)
+ <CTL_12> | \x0c | (,PROHIBITED)
+ <CTL_13> | \x0d | (,PROHIBITED)
+ <CTL_14> | \x0e | (,PROHIBITED)
+ <CTL_15> | \x0f | (,PROHIBITED)
+ <CTL_16> | \x10 | (,PROHIBITED)
+ <CTL_17> | \x11 | (,PROHIBITED)
+ <CTL_18> | \x12 | (,PROHIBITED)
+ <CTL_19> | \x13 | (,PROHIBITED)
+ <CTL_20> | \x14 | (,PROHIBITED)
+ <CTL_21> | \x15 | (,PROHIBITED)
+ <CTL_22> | \x16 | (,PROHIBITED)
+ <CTL_23> | \x17 | (,PROHIBITED)
+ <CTL_24> | \x18 | (,PROHIBITED)
+ <CTL_25> | \x19 | (,PROHIBITED)
+ <CTL_26> | \x1a | (,PROHIBITED)
+ <CTL_27> | \x1b | (,PROHIBITED)
+ <CTL_28> | \x1c | (,PROHIBITED)
+ <CTL_29> | \x1d | (,PROHIBITED)
+ <CTL_30> | \x1e | (,PROHIBITED)
+ <CTL_31> | \x1f | (,PROHIBITED)
           | \x20 | ("\\x20",SUCCESS)
  !        | \x21 | ("\\x21",SUCCESS)
  "        | \x22 | ("\\x22",SUCCESS)
@@ -146,7 +146,7 @@ SELECT
  |        | \x7c | ("\\x7c",SUCCESS)
  }        | \x7d | ("\\x7d",SUCCESS)
  ~        | \x7e | ("\\x7e",SUCCESS)
- <DEL>    | \x7f | ("\\x7f",SUCCESS)
+ <DEL>    | \x7f | (,PROHIBITED)
 (128 rows)
 
 DROP EXTENSION test_saslprep;
diff --git a/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl 
b/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl
index b2b40e9108b6..cf455571dd2b 100644
--- a/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl
+++ b/src/test/modules/test_saslprep/t/001_saslprep_ranges.pl
@@ -25,14 +25,12 @@ $node->safe_psql('postgres', 'CREATE EXTENSION 
test_saslprep;');
 # Among all the valid UTF-8 codepoint ranges, our implementation of
 # SASLprep should never return an empty password if the operation is
 # considered a success.
-# The only exception is the nul character, prohibited in input of
-# CREATE/ALTER ROLE.
 my $result = $node->safe_psql(
        'postgres', qq[SELECT * FROM test_saslprep_ranges()
   WHERE status = 'SUCCESS' AND res IN (NULL, '')
 ]);
 
-is($result, 'U+0000|SUCCESS|\x00|\x', "Only nul authorized for all valid UTF8 
codepoints");
+is($result, '', "No empty or NULL values for all valid UTF8 codepoints");
 
 $node->stop;
 done_testing();
-- 
2.53.0

Attachment: signature.asc
Description: PGP signature

Reply via email to