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 protected]>
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 protected]>
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

Attachment: signature.asc
Description: PGP signature

Reply via email to