From 1bb64029b4d581d016fbbb0d64990974e624266a Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Tue, 3 Nov 2026 09:55:18 +0800
Subject: [PATCH v3 1/2] Reject empty elements in extension_control_path

Empty path elements were expanded to /extension by the automatic suffix
addition, causing an unintended search of that directory. Reject empty
elements before appending the suffix when locating or listing extensions.

When checking for an extension to provide an unknown-language hint,
skip empty elements and search the remaining directories.
This preserves the original CREATE FUNCTION or DO error while still
allowing a hint.

Preserve the documented $system default for an entirely empty setting.
Document the restriction on empty elements and add TAP coverage for them
and for unknown-language errors and hints.

Author: Chao Li <lic@highgo.com>
Reviewed-by: Prashant Chinnam <crprashant@hotmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Reviewed-by: Matheus Alcantara <matheusssilv97@gmail.com>
Discussion: https://postgr.es/m/DC2D9FA5-45C4-494F-99FC-F9B95AD4FF55@gmail.com
---
 doc/src/sgml/config.sgml                      |  1 +
 src/backend/commands/extension.c              | 67 ++++++++++++-------
 .../t/001_extension_control_path.pl           | 33 +++++++++
 3 files changed, 75 insertions(+), 26 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 7018ca1e3ea..0d68c72a714 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -11783,6 +11783,7 @@ extension_control_path = 'C:\tools\postgresql;H:\my_project\share;$system'
         The default value for this parameter is
         <literal>'$system'</literal>. If the value is set to an empty
         string, the default <literal>'$system'</literal> is also assumed.
+        Empty elements within a nonempty value are not allowed.
        </para>
 
        <para>
diff --git a/src/backend/commands/extension.c b/src/backend/commands/extension.c
index 35443dabecf..57bff97319a 100644
--- a/src/backend/commands/extension.c
+++ b/src/backend/commands/extension.c
@@ -511,9 +511,11 @@ is_extension_script_filename(const char *filename)
 
 /*
  * Return a list of directories declared in the extension_control_path GUC.
+ * If skip_invalid_path is true, ignore empty elements instead of reporting an
+ * error.  This is useful when looking for extensions only to provide a hint.
  */
 static List *
-get_extension_control_directories(void)
+get_extension_control_directories(bool skip_invalid_path)
 {
 #define EXTENSION_SYSTEM_MACRO  "$system"
 	char		sharepath[MAXPGPATH];
@@ -541,9 +543,7 @@ get_extension_control_directories(void)
 		for (;;)
 		{
 			int			len;
-			char	   *mangled;
 			char	   *piece = first_path_var_separator(ecp);
-			ExtensionLocation *location = palloc_object(ExtensionLocation);
 
 			/* Get the length of the next path on ecp */
 			if (piece == NULL)
@@ -551,30 +551,45 @@ get_extension_control_directories(void)
 			else
 				len = piece - ecp;
 
-			/* Copy the next path found on ecp */
-			piece = palloc(len + 1);
-			strlcpy(piece, ecp, len + 1);
-
-			/*
-			 * Substitute the path macro if needed or append "extension"
-			 * suffix if it is a custom extension control path.
-			 */
-			if (strcmp(piece, EXTENSION_SYSTEM_MACRO) == 0)
+			if (len == 0)
 			{
-				location->macro = pstrdup(piece);
-				mangled = substitute_path_macro(piece, EXTENSION_SYSTEM_MACRO, system_dir);
+				/* Reject empty path elements if required */
+				if (!skip_invalid_path)
+					ereport(ERROR,
+							errcode(ERRCODE_INVALID_NAME),
+							errmsg("zero-length component in parameter \"%s\"",
+								   "extension_control_path"));
 			}
 			else
 			{
-				location->macro = NULL;
-				mangled = psprintf("%s/extension", piece);
-			}
-			pfree(piece);
+				char	   *mangled;
+				ExtensionLocation *location = palloc_object(ExtensionLocation);
+
+				/* Copy the next path found on ecp */
+				piece = palloc(len + 1);
+				strlcpy(piece, ecp, len + 1);
 
-			/* Canonicalize the path based on the OS and add to the list */
-			canonicalize_path(mangled);
-			location->loc = mangled;
-			paths = lappend(paths, location);
+				/*
+				 * Substitute the path macro if needed or append "extension"
+				 * suffix if it is a custom extension control path.
+				 */
+				if (strcmp(piece, EXTENSION_SYSTEM_MACRO) == 0)
+				{
+					location->macro = pstrdup(piece);
+					mangled = substitute_path_macro(piece, EXTENSION_SYSTEM_MACRO, system_dir);
+				}
+				else
+				{
+					location->macro = NULL;
+					mangled = psprintf("%s/extension", piece);
+				}
+				pfree(piece);
+
+				/* Canonicalize the path based on the OS and add to the list */
+				canonicalize_path(mangled);
+				location->loc = mangled;
+				paths = lappend(paths, location);
+			}
 
 			/* Break if ecp is empty or move to the next path on ecp */
 			if (ecp[len] == '\0')
@@ -604,7 +619,7 @@ find_extension_control_filename(ExtensionControlFile *control)
 
 	basename = psprintf("%s.control", control->name);
 
-	paths = get_extension_control_directories();
+	paths = get_extension_control_directories(false);
 	result = find_in_paths(basename, paths);
 
 	if (result)
@@ -2527,7 +2542,7 @@ pg_available_extensions(PG_FUNCTION_ARGS)
 	/* Build tuplestore to hold the result rows */
 	InitMaterializedSRF(fcinfo, 0);
 
-	locations = get_extension_control_directories();
+	locations = get_extension_control_directories(false);
 
 	foreach_ptr(ExtensionLocation, location, locations)
 	{
@@ -2629,7 +2644,7 @@ pg_available_extension_versions(PG_FUNCTION_ARGS)
 	/* Build tuplestore to hold the result rows */
 	InitMaterializedSRF(fcinfo, 0);
 
-	locations = get_extension_control_directories();
+	locations = get_extension_control_directories(false);
 
 	foreach_ptr(ExtensionLocation, location, locations)
 	{
@@ -2821,7 +2836,7 @@ extension_file_exists(const char *extensionName)
 	DIR		   *dir;
 	struct dirent *de;
 
-	locations = get_extension_control_directories();
+	locations = get_extension_control_directories(true);
 
 	foreach_ptr(ExtensionLocation, location, locations)
 	{
diff --git a/src/test/modules/test_extensions/t/001_extension_control_path.pl b/src/test/modules/test_extensions/t/001_extension_control_path.pl
index 4a013a7da4b..94f9ae961a6 100644
--- a/src/test/modules/test_extensions/t/001_extension_control_path.pl
+++ b/src/test/modules/test_extensions/t/001_extension_control_path.pl
@@ -115,6 +115,39 @@ is($ret, "\$system",
 	"\$system location is shown correctly in pg_available_extensions with empty extension_control_path"
 );
 
+# Empty path elements are rejected, rather than expanded to /extension.
+for my $path (
+	"$sep$ext_dir_canonicalized",
+	"$ext_dir_canonicalized$sep",
+	$sep)
+{
+	my ($code, $stdout, $stderr) = $node->psql(
+		'postgres',
+		"set extension_control_path = '$path';
+		 select count(*) from pg_available_extensions");
+	is($code, 3, "empty element is rejected in path '$path'");
+	like($stderr,
+		qr/ERROR:  zero-length component in parameter "extension_control_path"/,
+		"error identifies the empty path element");
+}
+
+# Empty elements must not replace an unknown-language error or prevent a hint
+# from being found using the remaining directories.
+for my $sql (
+	"create function test_invalid_language() returns int language $ext_name as 'x'",
+	"do language $ext_name 'x'")
+{
+	my ($code, $stdout, $stderr) = $node->psql(
+		'postgres',
+		"set extension_control_path = '$sep$ext_dir_canonicalized$sep'; $sql");
+	is($code, 3, "unknown language is rejected by $sql");
+	like($stderr, qr/ERROR:  language "\Q$ext_name\E" does not exist/,
+		"invalid path does not replace the unknown-language error");
+	like($stderr,
+		qr/HINT:  Use CREATE EXTENSION to load the language into the database\./,
+		"remaining directory provides the extension hint");
+}
+
 # Test with an extension that does not exists
 my ($code, $stdout, $stderr) =
   $node->psql('postgres', "CREATE EXTENSION invalid");
-- 
2.50.1 (Apple Git-155)

