From dc4be5e3b9836a758bf0d2ef12ec7d88f0c92bb1 Mon Sep 17 00:00:00 2001
From: Ubuntu <ubuntu@ip-172-31-46-230.ec2.internal>
Date: Fri, 24 Oct 2025 19:12:01 +0000
Subject: [PATCH v4 1/2] pg_stat_statements: Fix duplicate constant locations
 caused by composite row expansion

Commit 0f65f3eec introduced squashing of constant lists, but failed to
handle row expansion of composite values correctly. This could result
in the same location being recorded multiple times, which led to
out-of-bounds memory access in generate_normalized_query().

This patch updates fill_in_constant_lengths() to check for squashed
constants only after skipping duplicates, and to correctly update
last_loc. Each location is now processed once while squashable
constants are still ignored.

An oversight in 0f65f3eec also meant duplicate locations were not set
to -1. Before 0f65f3eec, RecordConstLocation always set the length to
-1 and delegated fill_in_constant_lengths() to populate the lengths.
Commit 0f65f3eec changed this, since a squashed list length is set
during RecordConstLocation.

Discussion: https://www.postgresql.org/message-id/2b91e358-0d99-43f7-be44-d2d4dbce37b3%40garret.ru
---
 .../pg_stat_statements/expected/squashing.out | 80 +++++++++++++++++++
 .../pg_stat_statements/pg_stat_statements.c   | 18 +++--
 contrib/pg_stat_statements/sql/squashing.sql  | 26 ++++++
 3 files changed, 117 insertions(+), 7 deletions(-)

diff --git a/contrib/pg_stat_statements/expected/squashing.out b/contrib/pg_stat_statements/expected/squashing.out
index f952f47ef7b..d5bb67c7222 100644
--- a/contrib/pg_stat_statements/expected/squashing.out
+++ b/contrib/pg_stat_statements/expected/squashing.out
@@ -809,6 +809,84 @@ SELECT query, calls FROM pg_stat_statements ORDER BY query COLLATE "C";
  select where $1 IN ($2 /*, ... */)                 |     2
 (2 rows)
 
+-- composite function with row expansion
+create table test_composite(x integer);
+CREATE FUNCTION composite_f(a integer[], out x integer, out y integer) returns
+record as $$            begin
+        x = a[1];
+        y = a[2];
+    end;
+$$ language plpgsql;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
+SELECT ((composite_f(array[1, 2]))).* FROM test_composite;
+ x | y 
+---+---
+(0 rows)
+
+SELECT ((composite_f(array[1, 2, 3]))).* FROM test_composite;
+ x | y 
+---+---
+(0 rows)
+
+SELECT ((composite_f(array[1, 2, 3]))).*, 1, 2, 3, ((composite_f(array[1, 2, 3]))).*, 1, 2
+FROM test_composite
+WHERE x IN (1, 2, 3);
+ x | y | ?column? | ?column? | ?column? | x | y | ?column? | ?column? 
+---+---+----------+----------+----------+---+---+----------+----------
+(0 rows)
+
+SELECT ((composite_f(array[1, $1, 3]))).*, 1 FROM test_composite \bind 1
+;
+ x | y | ?column? 
+---+---+----------
+(0 rows)
+
+-- ROW() expression with row expansion
+SELECT (ROW(ARRAY[1,2])).*;
+  f1   
+-------
+ {1,2}
+(1 row)
+
+SELECT (ROW(ARRAY[1, 2], ARRAY[1, 2, 3])).*;
+  f1   |   f2    
+-------+---------
+ {1,2} | {1,2,3}
+(1 row)
+
+SELECT 1, 2, (ROW(ARRAY[1, 2], ARRAY[1, 2, 3])).*, 3, 4;
+ ?column? | ?column? |  f1   |   f2    | ?column? | ?column? 
+----------+----------+-------+---------+----------+----------
+        1 |        2 | {1,2} | {1,2,3} |        3 |        4
+(1 row)
+
+SELECT (ROW(ARRAY[1, 2], ARRAY[1, $1, 3])).*, 1 \bind 1
+;
+  f1   |   f2    | ?column? 
+-------+---------+----------
+ {1,2} | {1,1,3} |        1
+(1 row)
+
+SELECT query, calls FROM pg_stat_statements ORDER BY query COLLATE "C";
+                                                    query                                                    | calls 
+-------------------------------------------------------------------------------------------------------------+-------
+ SELECT $1, $2, (ROW(ARRAY[$3 /*, ... */], ARRAY[$4 /*, ... */])).*, $5, $6                                  |     1
+ SELECT ((composite_f(array[$1 /*, ... */]))).* FROM test_composite                                          |     2
+ SELECT ((composite_f(array[$1 /*, ... */]))).*, $2 FROM test_composite                                      |     1
+ SELECT ((composite_f(array[$1 /*, ... */]))).*, $2, $3, $4, ((composite_f(array[$5 /*, ... */]))).*, $6, $7+|     1
+ FROM test_composite                                                                                        +| 
+ WHERE x IN ($8 /*, ... */)                                                                                  | 
+ SELECT (ROW(ARRAY[$1 /*, ... */])).*                                                                        |     1
+ SELECT (ROW(ARRAY[$1 /*, ... */], ARRAY[$2 /*, ... */])).*                                                  |     1
+ SELECT (ROW(ARRAY[$1 /*, ... */], ARRAY[$2 /*, ... */])).*, $3                                              |     1
+ SELECT pg_stat_statements_reset() IS NOT NULL AS t                                                          |     1
+(8 rows)
+
 --
 -- cleanup
 --
@@ -818,3 +896,5 @@ DROP TABLE test_squash_numeric;
 DROP TABLE test_squash_bigint;
 DROP TABLE test_squash_cast CASCADE;
 DROP TABLE test_squash_jsonb;
+DROP TABLE test_composite;
+DROP FUNCTION composite_f;
diff --git a/contrib/pg_stat_statements/pg_stat_statements.c b/contrib/pg_stat_statements/pg_stat_statements.c
index f2187167c5c..1d22dc07da9 100644
--- a/contrib/pg_stat_statements/pg_stat_statements.c
+++ b/contrib/pg_stat_statements/pg_stat_statements.c
@@ -2955,8 +2955,7 @@ generate_normalized_query(JumbleState *jstate, const char *query,
  * a problem.
  *
  * Duplicate constant pointers are possible, and will have their lengths
- * marked as '-1', so that they are later ignored.  (Actually, we assume the
- * lengths were initialized as -1 to start with, and don't change them here.)
+ * marked as '-1', so that they are later ignored.
  *
  * If query_loc > 0, then "query" has been advanced by that much compared to
  * the original string start, so we need to translate the provided locations
@@ -3002,17 +3001,24 @@ fill_in_constant_lengths(JumbleState *jstate, const char *query,
 	{
 		int			loc = locs[i].location;
 		int			tok;
+		bool		squashed = locs[i].squashed;
 
 		/* Adjust recorded location if we're dealing with partial string */
 		loc -= query_loc;
 
 		Assert(loc >= 0);
 
-		if (locs[i].squashed)
-			continue;			/* squashable list, ignore */
-
 		if (loc <= last_loc)
+		{
+			locs[i].length = -1;
 			continue;			/* Duplicate constant, ignore */
+		}
+
+		/* We have a valid location, so let's save it */
+		last_loc = loc;
+
+		if (squashed)
+			continue;			/* squashable list, ignore */
 
 		/* Lex tokens until we find the desired constant */
 		for (;;)
@@ -3060,8 +3066,6 @@ fill_in_constant_lengths(JumbleState *jstate, const char *query,
 		/* If we hit end-of-string, give up, leaving remaining lengths -1 */
 		if (tok == 0)
 			break;
-
-		last_loc = loc;
 	}
 
 	scanner_finish(yyscanner);
diff --git a/contrib/pg_stat_statements/sql/squashing.sql b/contrib/pg_stat_statements/sql/squashing.sql
index 53138d125a9..03b0515f872 100644
--- a/contrib/pg_stat_statements/sql/squashing.sql
+++ b/contrib/pg_stat_statements/sql/squashing.sql
@@ -291,6 +291,30 @@ select where '1' IN ('1'::int::text, '2'::int::text);
 select where '1' = ANY (array['1'::int::text, '2'::int::text]);
 SELECT query, calls FROM pg_stat_statements ORDER BY query COLLATE "C";
 
+-- composite function with row expansion
+create table test_composite(x integer);
+CREATE FUNCTION composite_f(a integer[], out x integer, out y integer) returns
+record as $$            begin
+        x = a[1];
+        y = a[2];
+    end;
+$$ language plpgsql;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+SELECT ((composite_f(array[1, 2]))).* FROM test_composite;
+SELECT ((composite_f(array[1, 2, 3]))).* FROM test_composite;
+SELECT ((composite_f(array[1, 2, 3]))).*, 1, 2, 3, ((composite_f(array[1, 2, 3]))).*, 1, 2
+FROM test_composite
+WHERE x IN (1, 2, 3);
+SELECT ((composite_f(array[1, $1, 3]))).*, 1 FROM test_composite \bind 1
+;
+-- ROW() expression with row expansion
+SELECT (ROW(ARRAY[1,2])).*;
+SELECT (ROW(ARRAY[1, 2], ARRAY[1, 2, 3])).*;
+SELECT 1, 2, (ROW(ARRAY[1, 2], ARRAY[1, 2, 3])).*, 3, 4;
+SELECT (ROW(ARRAY[1, 2], ARRAY[1, $1, 3])).*, 1 \bind 1
+;
+SELECT query, calls FROM pg_stat_statements ORDER BY query COLLATE "C";
+
 --
 -- cleanup
 --
@@ -300,3 +324,5 @@ DROP TABLE test_squash_numeric;
 DROP TABLE test_squash_bigint;
 DROP TABLE test_squash_cast CASCADE;
 DROP TABLE test_squash_jsonb;
+DROP TABLE test_composite;
+DROP FUNCTION composite_f;
-- 
2.43.0

