Subject: Non-compliant SASLprep implementation for ASCII characters

From: Michael Paquier <[email-masked]>
Date: Fri, 27 Feb 2026 12:05:28 +0900

Hi all,

While reviewing some of the SCRAM code, I have been reminded about the
following bit of code in saslprep.c:
    /*
     * 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;
    }

And after cross-checking that with RFCs 3454 (Stringprep) and 4013
(SASLprep), I got reminded of the fact that this implementation
artifact is wrong because not all ASCII characters are allowed:
- 0x00~0x1F (0~31), control characters, are prohibited.
- 0x7F (127, DEL) is prohibited.

The rest of the ASCII character range is OK.

Another question one may ask is: does making our SCRAM implementation
compliant impact our SCRAM implementation at all?  The answer to this
question is no.  If we are dealing with an ASCII-only password with
prohibited characters, our calls of pg_saslprep() deal with the SCRAM
verifiers generated by CREATE/ALTER role and the SASLprep() calls done
during an exchange the same way: even if we have prohibited ASCII
characters, the bytes are fed as-is to the scram build code.  All our
callers of pg_saslprep() make sure that the same thing happens.

In short, I see no downside in just making our implementation
compliant, which should be actually beneficial for future callers of
this routine, should we have any.  One point can be made for the
efficiency of checking ASCII-only passwords, but the default count of
4096 used for the computation of the SCRAM verifiers outweights that
point by far IMO: the SCRAM computation is more expensive than this
ASCII-only shortcut anyway.

Attached are two patches, that I'd like to propose for this commit
fest:
- 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.
- 0002 is the change to make the implementation compliant, impacting
the tests.  This removes nul from the list of valid cases, and the SQL
tests show the compliant behavior.

Even if we don't do 0002, 0001 shows benefits of its own.

I am adding that to the upcoming CF.
Thanks,
--
Michael
From 6b106f52ad9e1933b727e05539261e51f9209075 Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email-masked]>
Date: Fri, 27 Feb 2026 11:40:53 +0900
Subject: [PATCH v1 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  | 150 ++++++++++
 src/test/modules/test_saslprep/meson.build    |  38 +++
 .../test_saslprep/sql/test_saslprep.sql       |  14 +
 .../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, 618 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 44c7163c1cd5..0bb5dc7e2088 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -44,6 +44,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 2634a519935a..d20a97e8af32 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -45,6 +45,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..5a0ded7b4214
--- /dev/null
+++ b/src/test/modules/test_saslprep/expected/test_saslprep.out
@@ -0,0 +1,150 @@
+-- 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, skip nul (0) and '\' (92) as invalid bytea.
+SELECT chr(a) AS dat, chr(a)::bytea AS byt, test_saslprep(chr(a)::bytea)
+  FROM generate_series(1,91) as a;
+   dat    | byt  |   test_saslprep   
+----------+------+-------------------
+ \x01     | \x01 | ("\\x01",SUCCESS)
+ \x02     | \x02 | ("\\x02",SUCCESS)
+ \x03     | \x03 | ("\\x03",SUCCESS)
+ \x04     | \x04 | ("\\x04",SUCCESS)
+ \x05     | \x05 | ("\\x05",SUCCESS)
+ \x06     | \x06 | ("\\x06",SUCCESS)
+ \x07     | \x07 | ("\\x07",SUCCESS)
+ \x08     | \x08 | ("\\x08",SUCCESS)
+          | \x09 | ("\\x09",SUCCESS)
+         +| \x0a | ("\\x0a",SUCCESS)
+          |      | 
+ \x0B     | \x0b | ("\\x0b",SUCCESS)
+ \x0C     | \x0c | ("\\x0c",SUCCESS)
+ \r       | \x0d | ("\\x0d",SUCCESS)
+ \x0E     | \x0e | ("\\x0e",SUCCESS)
+ \x0F     | \x0f | ("\\x0f",SUCCESS)
+ \x10     | \x10 | ("\\x10",SUCCESS)
+ \x11     | \x11 | ("\\x11",SUCCESS)
+ \x12     | \x12 | ("\\x12",SUCCESS)
+ \x13     | \x13 | ("\\x13",SUCCESS)
+ \x14     | \x14 | ("\\x14",SUCCESS)
+ \x15     | \x15 | ("\\x15",SUCCESS)
+ \x16     | \x16 | ("\\x16",SUCCESS)
+ \x17     | \x17 | ("\\x17",SUCCESS)
+ \x18     | \x18 | ("\\x18",SUCCESS)
+ \x19     | \x19 | ("\\x19",SUCCESS)
+ \x1A     | \x1a | ("\\x1a",SUCCESS)
+ \x1B     | \x1b | ("\\x1b",SUCCESS)
+ \x1C     | \x1c | ("\\x1c",SUCCESS)
+ \x1D     | \x1d | ("\\x1d",SUCCESS)
+ \x1E     | \x1e | ("\\x1e",SUCCESS)
+ \x1F     | \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)
+(91 rows)
+
+SELECT chr(a) AS dat, chr(a)::bytea AS byt, test_saslprep(chr(a)::bytea)
+  FROM generate_series(93,127) as a;
+ dat  | byt  |   test_saslprep   
+------+------+-------------------
+ ]    | \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)
+ \x7F | \x7f | ("\\x7f",SUCCESS)
+(35 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..0b6c3d3a8e61
--- /dev/null
+++ b/src/test/modules/test_saslprep/sql/test_saslprep.sql
@@ -0,0 +1,14 @@
+-- Tests for SASLprep
+
+CREATE EXTENSION test_saslprep;
+
+-- Incomplete UTF-8 sequence.
+SELECT test_saslprep('\xef');
+
+-- Range of ASCII characters, skip nul (0) and '\' (92) as invalid bytea.
+SELECT chr(a) AS dat, chr(a)::bytea AS byt, test_saslprep(chr(a)::bytea)
+  FROM generate_series(1,91) as a;
+SELECT chr(a) AS dat, chr(a)::bytea AS byt, test_saslprep(chr(a)::bytea)
+  FROM generate_series(93,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 639d385eaee847ed09e575b3664a80783de795e8 Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email-masked]>
Date: Fri, 27 Feb 2026 11:42:50 +0900
Subject: [PATCH v1 2/2] Make implementation of SASLprep compliant for ASCII
 characters

---
 src/common/saslprep.c                         | 12 ----
 .../test_saslprep/expected/test_saslprep.out  | 64 +++++++++----------
 .../test_saslprep/t/001_saslprep_ranges.pl    |  4 +-
 3 files changed, 33 insertions(+), 47 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 5a0ded7b4214..deeab303fa52 100644
--- a/src/test/modules/test_saslprep/expected/test_saslprep.out
+++ b/src/test/modules/test_saslprep/expected/test_saslprep.out
@@ -12,38 +12,38 @@ SELECT chr(a) AS dat, chr(a)::bytea AS byt, test_saslprep(chr(a)::bytea)
   FROM generate_series(1,91) as a;
    dat    | byt  |   test_saslprep   
 ----------+------+-------------------
- \x01     | \x01 | ("\\x01",SUCCESS)
- \x02     | \x02 | ("\\x02",SUCCESS)
- \x03     | \x03 | ("\\x03",SUCCESS)
- \x04     | \x04 | ("\\x04",SUCCESS)
- \x05     | \x05 | ("\\x05",SUCCESS)
- \x06     | \x06 | ("\\x06",SUCCESS)
- \x07     | \x07 | ("\\x07",SUCCESS)
- \x08     | \x08 | ("\\x08",SUCCESS)
-          | \x09 | ("\\x09",SUCCESS)
-         +| \x0a | ("\\x0a",SUCCESS)
+ \x01     | \x01 | (,PROHIBITED)
+ \x02     | \x02 | (,PROHIBITED)
+ \x03     | \x03 | (,PROHIBITED)
+ \x04     | \x04 | (,PROHIBITED)
+ \x05     | \x05 | (,PROHIBITED)
+ \x06     | \x06 | (,PROHIBITED)
+ \x07     | \x07 | (,PROHIBITED)
+ \x08     | \x08 | (,PROHIBITED)
+          | \x09 | (,PROHIBITED)
+         +| \x0a | (,PROHIBITED)
           |      | 
- \x0B     | \x0b | ("\\x0b",SUCCESS)
- \x0C     | \x0c | ("\\x0c",SUCCESS)
- \r       | \x0d | ("\\x0d",SUCCESS)
- \x0E     | \x0e | ("\\x0e",SUCCESS)
- \x0F     | \x0f | ("\\x0f",SUCCESS)
- \x10     | \x10 | ("\\x10",SUCCESS)
- \x11     | \x11 | ("\\x11",SUCCESS)
- \x12     | \x12 | ("\\x12",SUCCESS)
- \x13     | \x13 | ("\\x13",SUCCESS)
- \x14     | \x14 | ("\\x14",SUCCESS)
- \x15     | \x15 | ("\\x15",SUCCESS)
- \x16     | \x16 | ("\\x16",SUCCESS)
- \x17     | \x17 | ("\\x17",SUCCESS)
- \x18     | \x18 | ("\\x18",SUCCESS)
- \x19     | \x19 | ("\\x19",SUCCESS)
- \x1A     | \x1a | ("\\x1a",SUCCESS)
- \x1B     | \x1b | ("\\x1b",SUCCESS)
- \x1C     | \x1c | ("\\x1c",SUCCESS)
- \x1D     | \x1d | ("\\x1d",SUCCESS)
- \x1E     | \x1e | ("\\x1e",SUCCESS)
- \x1F     | \x1f | ("\\x1f",SUCCESS)
+ \x0B     | \x0b | (,PROHIBITED)
+ \x0C     | \x0c | (,PROHIBITED)
+ \r       | \x0d | (,PROHIBITED)
+ \x0E     | \x0e | (,PROHIBITED)
+ \x0F     | \x0f | (,PROHIBITED)
+ \x10     | \x10 | (,PROHIBITED)
+ \x11     | \x11 | (,PROHIBITED)
+ \x12     | \x12 | (,PROHIBITED)
+ \x13     | \x13 | (,PROHIBITED)
+ \x14     | \x14 | (,PROHIBITED)
+ \x15     | \x15 | (,PROHIBITED)
+ \x16     | \x16 | (,PROHIBITED)
+ \x17     | \x17 | (,PROHIBITED)
+ \x18     | \x18 | (,PROHIBITED)
+ \x19     | \x19 | (,PROHIBITED)
+ \x1A     | \x1a | (,PROHIBITED)
+ \x1B     | \x1b | (,PROHIBITED)
+ \x1C     | \x1c | (,PROHIBITED)
+ \x1D     | \x1d | (,PROHIBITED)
+ \x1E     | \x1e | (,PROHIBITED)
+ \x1F     | \x1f | (,PROHIBITED)
           | \x20 | ("\\x20",SUCCESS)
  !        | \x21 | ("\\x21",SUCCESS)
  "        | \x22 | ("\\x22",SUCCESS)
@@ -144,7 +144,7 @@ SELECT chr(a) AS dat, chr(a)::bytea AS byt, test_saslprep(chr(a)::bytea)
  |    | \x7c | ("\\x7c",SUCCESS)
  }    | \x7d | ("\\x7d",SUCCESS)
  ~    | \x7e | ("\\x7e",SUCCESS)
- \x7F | \x7f | ("\\x7f",SUCCESS)
+ \x7F | \x7f | (,PROHIBITED)
 (35 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

---

From: Michael Paquier <[email-masked]>
Date: Mon, 2 Mar 2026 16:02:24 +0900

> 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-masked]>
Date: Mon, 2 Mar 2026 15:55:11 +0900
Subject: [PATCH v2 1/2] test_saslprep: Add test module to stress SASLprep

 .../test_saslprep/expected/test_saslprep.out  | 152 ++++++++++
 .../test_saslprep/sql/test_saslprep.sql       |  19 ++
 13 files changed, 625 insertions(+)

index 4ac5c84db439..ddce25cfd90f 100644
@@ -45,6 +45,7 @@ SUBDIRS = \
index e2b3eef41368..645f09a2260c 100644
@@ -46,6 +46,7 @@ subdir('test_rbtree')
index 000000000000..f72dbffa0a11
@@ -0,0 +1,152 @@
+-- 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)
+ \        | \x5c | ("\\x5c",SUCCESS)
+ <DEL>    | \x7f | ("\\x7f",SUCCESS)
+(128 rows)
index 000000000000..00bad48eca70
@@ -0,0 +1,19 @@
+-- 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;
 
+    <varlistentry>

From 49a1d26e006cf45389db82521b62a26bcdf0487e Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email-masked]>
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(-)

-	/*
-	 * 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)

 
 $node->stop;
 done_testing();
-- 
2.53.0

---

From: John Naylor <[email-masked]>
Date: Wed, 18 Mar 2026 18:34:03 +0700

> Even if we don't do 0002, 0001 shows benefits of its own.

Seems sensible to me. I only have minor nitpicks:

It's more natural to say "wrappers around", at least that's what comes to me.

The exceptional path only has two lines of code, so it's unclear what
this hint is trying to do. This module isn't run by default anyway

Regular "memset" with a 4-byte constant input is easily inline-able by
the compiler, and I think we should use our homegrown implementation
only when there is a specific reason for it. (I know there are many
dozens of uses without a reason already, but...)

-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");

I don't quite understand "only nul authorized..." -- I understand the
explanation in your email, but I having difficulty with the way it's
phrased here. (Although it'll be moot if we go ahead with 0002)

-- 
John Naylor
Amazon Web Services

---

From: Michael Paquier <[email-masked]>
Date: Thu, 19 Mar 2026 13:25:52 +0900

> Seems sensible to me. I only have minor nitpicks:

Thanks for the review of the module.

> It's more natural to say "wrappers around", at least that's what comes to me.

Fixed.

> this hint is trying to do. This module isn't run by default anyway

Removed that.

> dozens of uses without a reason already, but...)

Removed that.

> phrased here. (Although it'll be moot if we go ahead with 0002)

Yes, still better to keep the state of the tree cleaner at all times,
especially if 0002 gets reverted.  I have used a simpler "valid
codepoints returning an empty password".

Applied the result for the module, to have at least the coverage part.
The last piece is refreshed, and attached for now.
--
Michael
From a806e48445ee7d9d75dbe70e0da76b703650faa4 Mon Sep 17 00:00:00 2001
From: Michael Paquier <[email-masked]>
Date: Thu, 19 Mar 2026 13:18:28 +0900
Subject: [PATCH v3] Make implementation of SASLprep compliant for ASCII
 characters

---

-	/*
 
 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 b353017c0651..4a7cb5aaa588 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 currently 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', "valid codepoints returning an empty password");
+is($result, '', "valid codepoints returning an empty password");
 
 $node->stop;
 done_testing();
-- 
2.53.0

---

From: Michael Paquier <[email-masked]>
Date: Tue, 24 Mar 2026 08:33:10 +0900

> The last piece is refreshed, and attached for now.

I have worked on the final piece of this thread, and applied it.

I am also attaching a small module, called scram_utils(), that I have
used to validate this change by creating SCRAM verifiers with
non-printable ASCII characters, like:
SELECT scram_utils_verifier_bytea('myrole', '\x010203', 200, 10);

This function passes down the password data to scram_build_secret()
after applying pg_saslprep(), reusing the original password if
the SASLprep was not a success.  That's the same as what we do in
pg_be_scram_build_secret() but I wanted control over the salt length
and the number of iterations for each function call (implemented that
years ago with tested SCRAM), hence the split.

Then use for example something like that for the input:
export PGPASSWORD=$(printf '%b%b%b' '\01\02\03')

The validation between the non-compliant and the compliant
implementation then comes down to:
- Generate the rolpassword on HEAD patched (new) and unpatched (old).
- Check connections with libpq patched (new) and unpatched (old), with
client->server as of new->old, old->new, new->new.
--
Michael

---

From: Alexander Lakhin <[email-masked]>
Date: Sun, 12 Apr 2026 09:00:00 +0300

Hello Michael,

> The last piece is refreshed, and attached for now.

When running make check for src/test/modules/test_saslprep under Valgrind,
I've discovered:
# --- /pgtest/postgresql.git/src/test/modules/test_saslprep/expected/test_saslprep.out 2026-04-12 07:44:47.090517505 +0300
# +++ /pgtest/postgresql.git/src/test/modules/test_saslprep/results/test_saslprep.out 2026-04-12 08:03:29.353348951 +0300
# @@ -2,151 +2,7 @@
#  CREATE EXTENSION test_saslprep;
#  -- Incomplete UTF-8 sequence.
#  SELECT test_saslprep('\xef');
# -  test_saslprep
# ------------------
# - (,INVALID_UTF8)
# -(1 row)
# -
...
-
-DROP EXTENSION test_saslprep;
+server closed the connection unexpectedly
+   This probably means the server terminated abnormally
+   before or while processing the request.
+connection to server was lost

src/test/modules/test_saslprep/log/postmaster.log
2026-04-12 08:03:26.064 EEST postmaster[1043298] LOG:  database system is ready to accept connections
2026-04-12 08:03:26.078 EEST dead-end client backend[1043325] [unknown] FATAL:  the database system is starting up
==00:00:00:04.413 1043360== Invalid read of size 1
==00:00:00:04.413 1043360==    at 0x484F234: strlen (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==00:00:00:04.413 1043360==    by 0x5277C6F: strlcpy (strlcpy.c:24)
==00:00:00:04.413 1043360==    by 0x48648CF: test_saslprep (test_saslprep.c:87)
==00:00:00:04.413 1043360==    by 0x541A45: ExecInterpExpr (execExprInterp.c:979)
==00:00:00:04.413 1043360==    by 0x5446A1: ExecInterpExprStillValid (execExprInterp.c:2301)
==00:00:00:04.413 1043360==    by 0x5AD1FD: ExecEvalExprNoReturn (executor.h:433)
==00:00:00:04.413 1043360==    by 0x5AD2BB: ExecEvalExprNoReturnSwitchContext (executor.h:474)
==00:00:00:04.413 1043360==    by 0x5AD31C: ExecProject (executor.h:506)
==00:00:00:04.413 1043360==    by 0x5AD53F: ExecResult (nodeResult.c:135)
==00:00:00:04.413 1043360==    by 0x55F5E4: ExecProcNodeFirst (execProcnode.c:469)
==00:00:00:04.413 1043360==    by 0x55108F: ExecProcNode (executor.h:327)
==00:00:00:04.413 1043360==    by 0x554149: ExecutePlan (execMain.c:1736)
==00:00:00:04.413 1043360==  Address 0x7439295 is 6,917 bytes inside a block of size 8,192 alloc'd
==00:00:00:04.413 1043360==    at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==00:00:00:04.413 1043360==    by 0xADBE27: AllocSetContextCreateInternal (aset.c:444)
==00:00:00:04.413 1043360==    by 0x762E31: PostmasterMain (postmaster.c:534)
==00:00:00:04.413 1043360==    by 0x5E9543: main (main.c:231)
==00:00:00:04.413 1043360==
...
==00:00:00:04.413 1043360==
==00:00:00:04.414 1043360== Exit program on first error (--exit-on-first-error=yes)
2026-04-12 08:03:29.349 EEST postmaster[1043298] LOG:  client backend (PID 1043360) exited with exit code 1
2026-04-12 08:03:29.349 EEST postmaster[1043298] DETAIL:  Failed process was running: SELECT test_saslprep('\xef');

The corresponding code:
     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);

That is, strlcpy() tries to evaluate strlen() for src, which contains only
one byte without null terminator.

skink tests this module successfully [1] by some reason:
================================== 151/394 ===================================
test:         test_saslprep - postgresql:test_saslprep/regress
start time:   13:26:00
duration:     8.04s
result:       exit status 0
command:      ...
----------------------------------- stdout -----------------------------------
# executing test in /home/bf/bf-build/skink-master/HEAD/pgsql.build/testrun/test_saslprep/regress group test_saslprep 
test regress
# initializing database system by copying initdb template
# using temp instance on port 40096 with PID 3982971
ok 1         - test_saslprep                            1971 ms
1..1
# All 1 tests passed.
# test succeeded

[1] https://buildfarm.postgresql.org/cgi-bin/show_stage_log.pl?nm=skink&dt=2026-04-11%2011%3A31%3A01&stg=misc-check

Best regards,
Alexander

---

From: Michael Paquier <[email-masked]>
Date: Sun, 12 Apr 2026 20:47:10 +0900

> one byte without null terminator.

Thanks for the report.  I don't know why skink is not complaining, but
I do see the failure, and I am able to fix it with the attached.  Does
it work on your side?
--
Michael
diff --git a/src/test/modules/test_saslprep/test_saslprep.c b/src/test/modules/test_saslprep/test_saslprep.c
index 70ff7069bf70..121212d4fa21 100644
--- a/src/test/modules/test_saslprep/test_saslprep.c
+++ b/src/test/modules/test_saslprep/test_saslprep.c
@@ -84,7 +84,8 @@ test_saslprep(PG_FUNCTION_ARGS)
 	 * 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);
+	memcpy(input_data, src, src_len);
+	input_data[src_len] = '\0';
 
 	rc = pg_saslprep(input_data, &result);
 	status = saslprep_status_to_text(rc);

---

From: Alexander Lakhin <[email-masked]>
Date: Sun, 12 Apr 2026 16:00:00 +0300

> I do see the failure, and I am able to fix it with the attached.  Does

Yes, it works. Thank you for paying attention to the issue!

Maybe it would make sense to find out why skink doesn't detect this (just
in case there are or will be similar defects hiding) before pushing the
fix...

Best regards,
Alexander

---

From: Michael Paquier <[email-masked]>
Date: Mon, 13 Apr 2026 09:12:32 +0900

> in case there are or will be similar defects hiding) before pushing the

Other fixes can also be applied separately, tackled by their
respective committers.

Saying that, I have also done an installcheck with an instance running
with valgrind, and did not spot something popping out.  The log file I
have used for the output was looking a bit weird, as if valgrind had
the idea to overwrite some portions of it, so perhaps I have missed
something.

I have fixed this one for now, thanks for the report.
--
Michael