]> git.ipfire.org Git - thirdparty/postgresql.git/commitdiff
test_saslprep: Test module for SASLprep()
authorMichael Paquier <michael@paquier.xyz>
Thu, 19 Mar 2026 04:03:30 +0000 (13:03 +0900)
committerMichael Paquier <michael@paquier.xyz>
Thu, 19 Mar 2026 04:03:30 +0000 (13:03 +0900)
This module 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
codepoints pg_saslprep() handles each one of them.

This provides a detailed coverage of our implementation of SASLprep()
used for SCRAM, with:
- ASCII characters.
- Incomplete UTF-8 sequences, for 390b3cbbb2af (later backpatched).
- A more advanced check for all the valid UTF-8 ranges of codepoints, to
check for cases where these generate an empty password, based on an
original suggestion from Heikki Linnakangas.  This part consumes
resources and time, so it is implemented as a TAP test under a
new PG_TEST_EXTRA value.

A different patch is still under discussion to tweak our internal
SASLprep() implementation, and this module can be used to track any
changes in behavior.

Author: Michael Paquier <michael@paquier.xyz>
Reviewed-by: John Naylor <johncnaylorls@gmail.com>
Discussion: https://postgr.es/m/aaEJ-El2seZHeFcG@paquier.xyz

13 files changed:
doc/src/sgml/regress.sgml
src/test/modules/Makefile
src/test/modules/meson.build
src/test/modules/test_saslprep/.gitignore [new file with mode: 0644]
src/test/modules/test_saslprep/Makefile [new file with mode: 0644]
src/test/modules/test_saslprep/README [new file with mode: 0644]
src/test/modules/test_saslprep/expected/test_saslprep.out [new file with mode: 0644]
src/test/modules/test_saslprep/meson.build [new file with mode: 0644]
src/test/modules/test_saslprep/sql/test_saslprep.sql [new file with mode: 0644]
src/test/modules/test_saslprep/t/001_saslprep_ranges.pl [new file with mode: 0644]
src/test/modules/test_saslprep/test_saslprep--1.0.sql [new file with mode: 0644]
src/test/modules/test_saslprep/test_saslprep.c [new file with mode: 0644]
src/test/modules/test_saslprep/test_saslprep.control [new file with mode: 0644]

index 43f208df2724397cc6b561b22dddcced2130add6..873387ec16811bcc86aa26baefe852ebc91aeb24 100644 (file)
@@ -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>
index a1540269cf5b4a8d20a38cfc5885f9eb6a54df92..28ce3b35eda4efb04b098a7807a97a713499bcaf 100644 (file)
@@ -46,6 +46,7 @@ SUBDIRS = \
                  test_regex \
                  test_resowner \
                  test_rls_hooks \
+                 test_saslprep \
                  test_shm_mq \
                  test_slru \
                  test_tidstore \
index 7c052803c983082e228c447393450d394ba3260b..3ac291656c1d4a5ec69c9cf289693ca911e51366 100644 (file)
@@ -47,6 +47,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 (file)
index 0000000..5dcb3ff
--- /dev/null
@@ -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 (file)
index 0000000..f74375e
--- /dev/null
@@ -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 (file)
index 0000000..3064436
--- /dev/null
@@ -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
+wrappers around 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 (file)
index 0000000..f72dbff
--- /dev/null
@@ -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 (file)
index 0000000..2fcc403
--- /dev/null
@@ -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 (file)
index 0000000..00bad48
--- /dev/null
@@ -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 (file)
index 0000000..b353017
--- /dev/null
@@ -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 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");
+
+$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 (file)
index 0000000..01e5244
--- /dev/null
@@ -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 (file)
index 0000000..b4deab1
--- /dev/null
@@ -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 (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 (file)
index 0000000..13015c4
--- /dev/null
@@ -0,0 +1,5 @@
+# test_saslprep extension
+comment = 'Test SASLprep implementation'
+default_version = '1.0'
+module_pathname = '$libdir/test_saslprep'
+relocatable = true