From 62158812f1401c5ad291302bdc8bf4fb38353dc6 Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Thu, 2 Jul 2026 12:06:27 +0800
Subject: [PATCH v1] Escape CR/LF in invalid global object name errors

Commit b380a56a3 added checks to reject CR and LF in database,
role, and tablespace names, but the error messages still included the raw
rejected name. This could make client output and server log entries
confusing, since LF splits the message across physical lines and CR can
overwrite the beginning of the line.

Add a common helper to escape CR and LF in names used for line-oriented
error/report output, and use it for the new database, role, and tablespace
validation errors. Also use it in pg_upgrade when reporting old-cluster
database, role, and tablespace names that contain CR or LF.

Author: Chao Li <lic@highgo.com>
---
 src/backend/commands/dbcommands.c             |  7 ++-
 src/backend/commands/tablespace.c             |  7 ++-
 src/backend/commands/user.c                   |  7 ++-
 src/bin/pg_upgrade/check.c                    | 12 +++-
 src/common/string.c                           | 56 +++++++++++++++++++
 src/include/common/string.h                   |  1 +
 .../expected/alter_system_table.out           |  3 +-
 .../unsafe_tests/expected/rolenames.out       |  3 +-
 src/test/regress/expected/tablespace.out      |  3 +-
 9 files changed, 85 insertions(+), 14 deletions(-)

diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c
index f0819d15ab7..3072a043d99 100644
--- a/src/backend/commands/dbcommands.c
+++ b/src/backend/commands/dbcommands.c
@@ -49,6 +49,7 @@
 #include "commands/seclabel.h"
 #include "commands/tablespace.h"
 #include "common/file_perm.h"
+#include "common/string.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
 #include "pgstat.h"
@@ -748,7 +749,8 @@ createdb(ParseState *pstate, const CreatedbStmt *stmt)
 	if (strpbrk(dbname, "\n\r"))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("database name \"%s\" contains a newline or carriage return character", dbname)));
+				 errmsg("database name \"%s\" contains a newline or carriage return character",
+						pg_escape_name_for_error(dbname))));
 
 	/* Extract options from the statement node tree */
 	foreach(option, stmt->options)
@@ -1929,7 +1931,8 @@ RenameDatabase(const char *oldname, const char *newname)
 	if (strpbrk(newname, "\n\r"))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("database name \"%s\" contains a newline or carriage return character", newname)));
+				 errmsg("database name \"%s\" contains a newline or carriage return character",
+						pg_escape_name_for_error(newname))));
 
 	/*
 	 * Look up the target database's OID, and get exclusive lock on it. We
diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c
index d91fcf0facf..7c925de1fd6 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -67,6 +67,7 @@
 #include "commands/seclabel.h"
 #include "commands/tablespace.h"
 #include "common/file_perm.h"
+#include "common/string.h"
 #include "miscadmin.h"
 #include "postmaster/bgwriter.h"
 #include "storage/fd.h"
@@ -247,7 +248,8 @@ CreateTableSpace(CreateTableSpaceStmt *stmt)
 	if (strpbrk(stmt->tablespacename, "\n\r"))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("tablespace name \"%s\" contains a newline or carriage return character", stmt->tablespacename)));
+				 errmsg("tablespace name \"%s\" contains a newline or carriage return character",
+						pg_escape_name_for_error(stmt->tablespacename))));
 
 	in_place = allow_in_place_tablespaces && strlen(location) == 0;
 
@@ -982,7 +984,8 @@ RenameTableSpace(const char *oldname, const char *newname)
 	if (strpbrk(newname, "\n\r"))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("tablespace name \"%s\" contains a newline or carriage return character", newname)));
+				 errmsg("tablespace name \"%s\" contains a newline or carriage return character",
+						pg_escape_name_for_error(newname))));
 
 	/*
 	 * If built with appropriate switch, whine when regression-testing
diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index be11c49f919..5deda77ab9b 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -30,6 +30,7 @@
 #include "commands/defrem.h"
 #include "commands/seclabel.h"
 #include "commands/user.h"
+#include "common/string.h"
 #include "libpq/crypt.h"
 #include "miscadmin.h"
 #include "port/pg_bitutils.h"
@@ -175,7 +176,8 @@ CreateRole(ParseState *pstate, CreateRoleStmt *stmt)
 	if (strpbrk(stmt->role, "\n\r"))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("role name \"%s\" contains a newline or carriage return character", stmt->role)));
+				 errmsg("role name \"%s\" contains a newline or carriage return character",
+						pg_escape_name_for_error(stmt->role))));
 
 	/* The defaults can vary depending on the original statement type */
 	switch (stmt->stmt_type)
@@ -1358,7 +1360,8 @@ RenameRole(const char *oldname, const char *newname)
 	if (strpbrk(newname, "\n\r"))
 		ereport(ERROR,
 				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
-				 errmsg("role name \"%s\" contains a newline or carriage return character", newname)));
+				 errmsg("role name \"%s\" contains a newline or carriage return character",
+						pg_escape_name_for_error(newname))));
 
 	rel = table_open(AuthIdRelationId, RowExclusiveLock);
 	dsc = RelationGetDescr(rel);
diff --git a/src/bin/pg_upgrade/check.c b/src/bin/pg_upgrade/check.c
index 7e34e15a9d9..59a3c3e4ba0 100644
--- a/src/bin/pg_upgrade/check.c
+++ b/src/bin/pg_upgrade/check.c
@@ -12,10 +12,11 @@
 #include "catalog/pg_am_d.h"
 #include "catalog/pg_authid_d.h"
 #include "catalog/pg_class_d.h"
+#include "common/string.h"
+#include "common/unicode_version.h"
 #include "fe_utils/string_utils.h"
 #include "mb/pg_wchar.h"
 #include "pg_upgrade.h"
-#include "common/unicode_version.h"
 
 static void check_new_cluster_is_empty(void);
 static void check_is_install_user(ClusterInfo *cluster);
@@ -2621,10 +2622,17 @@ check_old_cluster_global_names(ClusterInfo *cluster)
 		/* If name has \n or \r, then report it. */
 		if (strpbrk(objname, "\n\r"))
 		{
+			char	   *escaped_objname;
+
 			if (script == NULL && (script = fopen_priv(output_path, "w")) == NULL)
 				pg_fatal("could not open file \"%s\": %m", output_path);
 
-			fprintf(script, "%d : %s name = \"%s\"\n", ++count, objtype, objname);
+			escaped_objname = pg_escape_name_for_error(objname);
+			if (escaped_objname == NULL)
+				pg_fatal("out of memory");
+			fprintf(script, "%d : %s name = \"%s\"\n", ++count, objtype,
+					escaped_objname);
+			pg_free(escaped_objname);
 		}
 	}
 
diff --git a/src/common/string.c b/src/common/string.c
index 41c74a1502d..25938211965 100644
--- a/src/common/string.c
+++ b/src/common/string.c
@@ -125,6 +125,62 @@ pg_clean_ascii(const char *str, int alloc_flags)
 }
 
 
+/*
+ * pg_escape_name_for_error -- Escape a name for line-oriented error reports
+ *
+ * Makes a newly allocated copy of the string passed in, which must be
+ * '\0'-terminated. In the backend, the copy is palloc'd; in the frontend, it
+ * is malloc'd.
+ *
+ * This preserves valid non-ASCII bytes, but escapes characters that would make
+ * a name span multiple physical lines.
+ */
+char *
+pg_escape_name_for_error(const char *str)
+{
+	size_t		dstlen;
+	char	   *dst;
+	const char *p;
+	size_t		i = 0;
+
+	/*
+	 * In the worst case, every byte is either '\n' or '\r' and becomes two
+	 * bytes, plus a null terminator.
+	 */
+	dstlen = strlen(str) * 2 + 1;
+
+#ifdef FRONTEND
+	dst = malloc(dstlen);
+#else
+	dst = palloc(dstlen);
+#endif
+
+	if (!dst)
+		return NULL;
+
+	for (p = str; *p != '\0'; p++)
+	{
+		switch (*p)
+		{
+			case '\n':
+				dst[i++] = '\\';
+				dst[i++] = 'n';
+				break;
+			case '\r':
+				dst[i++] = '\\';
+				dst[i++] = 'r';
+				break;
+			default:
+				dst[i++] = *p;
+				break;
+		}
+	}
+
+	dst[i] = '\0';
+	return dst;
+}
+
+
 /*
  * pg_is_ascii -- Check if string is made only of ASCII characters
  */
diff --git a/src/include/common/string.h b/src/include/common/string.h
index 2a7c31ea74e..5da2d3572b7 100644
--- a/src/include/common/string.h
+++ b/src/include/common/string.h
@@ -28,6 +28,7 @@ extern bool pg_str_endswith(const char *str, const char *end);
 extern int	strtoint(const char *pg_restrict str, char **pg_restrict endptr,
 					 int base);
 extern char *pg_clean_ascii(const char *str, int alloc_flags);
+extern char *pg_escape_name_for_error(const char *str);
 extern int	pg_strip_crlf(char *str);
 extern bool pg_is_ascii(const char *str);
 
diff --git a/src/test/modules/unsafe_tests/expected/alter_system_table.out b/src/test/modules/unsafe_tests/expected/alter_system_table.out
index 9ea6061f4ae..c03bbcfd3b1 100644
--- a/src/test/modules/unsafe_tests/expected/alter_system_table.out
+++ b/src/test/modules/unsafe_tests/expected/alter_system_table.out
@@ -67,8 +67,7 @@ DETAIL:  The prefix "pg_" is reserved for system tablespaces.
 -- contains \n\r tablespace name
 CREATE TABLESPACE "invalid
 name" LOCATION '/no/such/location';
-ERROR:  tablespace name "invalid
-name" contains a newline or carriage return character
+ERROR:  tablespace name "invalid\nname" contains a newline or carriage return character
 -- triggers
 CREATE FUNCTION tf1() RETURNS trigger
 LANGUAGE plpgsql
diff --git a/src/test/modules/unsafe_tests/expected/rolenames.out b/src/test/modules/unsafe_tests/expected/rolenames.out
index 9dfb8475e16..d7f4d8d485a 100644
--- a/src/test/modules/unsafe_tests/expected/rolenames.out
+++ b/src/test/modules/unsafe_tests/expected/rolenames.out
@@ -104,8 +104,7 @@ ERROR:  role name "pg_abcdef" is reserved
 DETAIL:  Role names starting with "pg_" are reserved.
 CREATE ROLE "invalid
 rolename"; -- error
-ERROR:  role name "invalid
-rolename" contains a newline or carriage return character
+ERROR:  role name "invalid\nrolename" contains a newline or carriage return character
 CREATE ROLE regress_testrol0 SUPERUSER LOGIN;
 CREATE ROLE regress_testrolx SUPERUSER LOGIN;
 CREATE ROLE regress_testrol2 SUPERUSER;
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..ac14a021b8f 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -963,8 +963,7 @@ NOTICE:  no matching relations in tablespace "regress_tblspace_renamed" found
 -- Should fail, contains \n in name
 ALTER TABLESPACE regress_tblspace_renamed RENAME TO "invalid
 name";
-ERROR:  tablespace name "invalid
-name" contains a newline or carriage return character
+ERROR:  tablespace name "invalid\nname" contains a newline or carriage return character
 -- Should succeed
 DROP TABLESPACE regress_tblspace_renamed;
 DROP SCHEMA testschema CASCADE;
-- 
2.50.1 (Apple Git-155)

