Fix mis-rounding and overflow hazards in date_bin().
authorTom Lane
Wed, 28 Feb 2024 19:00:30 +0000 (14:00 -0500)
committerTom Lane
Wed, 28 Feb 2024 19:00:30 +0000 (14:00 -0500)
In the case where the target timestamp is before the origin timestamp
and their difference is already an exact multiple of the stride, the
code incorrectly subtracted the stride anyway.

Also detect several integer-overflow cases that previously produced
bogus results.  (The submitted patch tried to avoid overflow, but
I'm not convinced it's right, and problematic cases are so far out of
the plausibly-useful range that they don't seem worth sweating over.
Let's just use overflow-detecting arithmetic and throw errors.)

timestamp_bin() and timestamptz_bin() are basically identical and
so had identical bugs.  Fix both.

Report and patch by Moaaz Assali, adjusted some by me.  Back-patch
to v14 where date_bin() was introduced.

Discussion: https://postgr.es/m/CALkF+nvtuas-2kydG-WfofbRSJpyODAJWun==W-yO5j2R4meqA@mail.gmail.com

src/backend/utils/adt/timestamp.c
src/test/regress/expected/timestamp.out
src/test/regress/expected/timestamptz.out
src/test/regress/sql/timestamp.sql
src/test/regress/sql/timestamptz.sql

index 238a40d388a295d0ccc01471d09832cc4b44cd10..fe100d5615b415725e42112501a37f22deecdd4a 100644 (file)
@@ -3989,8 +3989,9 @@ timestamp_bin(PG_FUNCTION_ARGS)
    Timestamp   timestamp = PG_GETARG_TIMESTAMP(1);
    Timestamp   origin = PG_GETARG_TIMESTAMP(2);
    Timestamp   result,
-               tm_diff,
                stride_usecs,
+               tm_diff,
+               tm_modulo,
                tm_delta;
 
    if (TIMESTAMP_NOT_FINITE(timestamp))
@@ -4006,24 +4007,40 @@ timestamp_bin(PG_FUNCTION_ARGS)
                (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
                 errmsg("timestamps cannot be binned into intervals containing months or years")));
 
-   stride_usecs = stride->day * USECS_PER_DAY + stride->time;
+   if (unlikely(pg_mul_s64_overflow(stride->day, USECS_PER_DAY, &stride_usecs)) ||
+       unlikely(pg_add_s64_overflow(stride_usecs, stride->time, &stride_usecs)))
+       ereport(ERROR,
+               (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+                errmsg("interval out of range")));
 
    if (stride_usecs <= 0)
        ereport(ERROR,
                (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
                 errmsg("stride must be greater than zero")));
 
-   tm_diff = timestamp - origin;
-   tm_delta = tm_diff - tm_diff % stride_usecs;
+   if (unlikely(pg_sub_s64_overflow(timestamp, origin, &tm_diff)))
+       ereport(ERROR,
+               (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+                errmsg("interval out of range")));
+
+   /* These calculations cannot overflow */
+   tm_modulo = tm_diff % stride_usecs;
+   tm_delta = tm_diff - tm_modulo;
+   result = origin + tm_delta;
 
    /*
-    * Make sure the returned timestamp is at the start of the bin, even if
-    * the origin is in the future.
+    * We want to round towards -infinity, not 0, when tm_diff is negative and
+    * not a multiple of stride_usecs.  This adjustment *can* cause overflow,
+    * since the result might now be out of the range origin .. timestamp.
     */
-   if (origin > timestamp && stride_usecs > 1)
-       tm_delta -= stride_usecs;
-
-   result = origin + tm_delta;
+   if (tm_modulo < 0)
+   {
+       if (unlikely(pg_sub_s64_overflow(result, stride_usecs, &result)) ||
+           !IS_VALID_TIMESTAMP(result))
+           ereport(ERROR,
+                   (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+                    errmsg("timestamp out of range")));
+   }
 
    PG_RETURN_TIMESTAMP(result);
 }
@@ -4174,6 +4191,7 @@ timestamptz_bin(PG_FUNCTION_ARGS)
    TimestampTz result,
                stride_usecs,
                tm_diff,
+               tm_modulo,
                tm_delta;
 
    if (TIMESTAMP_NOT_FINITE(timestamp))
@@ -4189,24 +4207,40 @@ timestamptz_bin(PG_FUNCTION_ARGS)
                (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
                 errmsg("timestamps cannot be binned into intervals containing months or years")));
 
-   stride_usecs = stride->day * USECS_PER_DAY + stride->time;
+   if (unlikely(pg_mul_s64_overflow(stride->day, USECS_PER_DAY, &stride_usecs)) ||
+       unlikely(pg_add_s64_overflow(stride_usecs, stride->time, &stride_usecs)))
+       ereport(ERROR,
+               (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+                errmsg("interval out of range")));
 
    if (stride_usecs <= 0)
        ereport(ERROR,
                (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
                 errmsg("stride must be greater than zero")));
 
-   tm_diff = timestamp - origin;
-   tm_delta = tm_diff - tm_diff % stride_usecs;
+   if (unlikely(pg_sub_s64_overflow(timestamp, origin, &tm_diff)))
+       ereport(ERROR,
+               (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+                errmsg("interval out of range")));
+
+   /* These calculations cannot overflow */
+   tm_modulo = tm_diff % stride_usecs;
+   tm_delta = tm_diff - tm_modulo;
+   result = origin + tm_delta;
 
    /*
-    * Make sure the returned timestamp is at the start of the bin, even if
-    * the origin is in the future.
+    * We want to round towards -infinity, not 0, when tm_diff is negative and
+    * not a multiple of stride_usecs.  This adjustment *can* cause overflow,
+    * since the result might now be out of the range origin .. timestamp.
     */
-   if (origin > timestamp && stride_usecs > 1)
-       tm_delta -= stride_usecs;
-
-   result = origin + tm_delta;
+   if (tm_modulo < 0)
+   {
+       if (unlikely(pg_sub_s64_overflow(result, stride_usecs, &result)) ||
+           !IS_VALID_TIMESTAMP(result))
+           ereport(ERROR,
+                   (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+                    errmsg("timestamp out of range")));
+   }
 
    PG_RETURN_TIMESTAMPTZ(result);
 }
index c64bcb7c127fc3b0e3d23f9283345ed5126db578..ef45d2843a0835d4722f531ae605282736d7a31d 100644 (file)
@@ -736,6 +736,13 @@ SELECT date_bin('5 min'::interval, timestamp '2020-02-01 01:01:01', timestamp '2
  Sat Feb 01 00:57:30 2020
 (1 row)
 
+-- test roundoff edge case when source < origin
+SELECT date_bin('30 minutes'::interval, timestamp '2024-02-01 15:00:00', timestamp '2024-02-01 17:00:00');
+         date_bin         
+--------------------------
+ Thu Feb 01 15:00:00 2024
+(1 row)
+
 -- disallow intervals with months or years
 SELECT date_bin('5 months'::interval, timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
 ERROR:  timestamps cannot be binned into intervals containing months or years
@@ -747,6 +754,13 @@ ERROR:  stride must be greater than zero
 -- disallow negative intervals
 SELECT date_bin('-2 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp '1970-01-01 00:00:00');
 ERROR:  stride must be greater than zero
+-- test overflow cases
+select date_bin('15 minutes'::interval, timestamp '294276-12-30', timestamp '4000-12-20 BC');
+ERROR:  interval out of range
+select date_bin('200000000 days'::interval, '2024-02-01'::timestamp, '2024-01-01'::timestamp);
+ERROR:  interval out of range
+select date_bin('365000 days'::interval, '4400-01-01 BC'::timestamp, '4000-01-01 BC'::timestamp);
+ERROR:  timestamp out of range
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
index 0dd2fe2c82dc62bf92f5e5843165915a7eb94f0c..db56fcfb0e7a52a6982ff4797d6f3b8589deaa70 100644 (file)
@@ -780,6 +780,13 @@ SELECT date_bin('5 min'::interval, timestamptz '2020-02-01 01:01:01+00', timesta
  Fri Jan 31 16:57:30 2020 PST
 (1 row)
 
+-- test roundoff edge case when source < origin
+SELECT date_bin('30 minutes'::interval, timestamptz '2024-02-01 15:00:00', timestamptz '2024-02-01 17:00:00');
+           date_bin           
+------------------------------
+ Thu Feb 01 15:00:00 2024 PST
+(1 row)
+
 -- disallow intervals with months or years
 SELECT date_bin('5 months'::interval, timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
 ERROR:  timestamps cannot be binned into intervals containing months or years
@@ -791,6 +798,13 @@ ERROR:  stride must be greater than zero
 -- disallow negative intervals
 SELECT date_bin('-2 days'::interval, timestamp with time zone '1970-01-01 01:00:00+00' , timestamp with time zone '1970-01-01 00:00:00+00');
 ERROR:  stride must be greater than zero
+-- test overflow cases
+select date_bin('15 minutes'::interval, timestamptz '294276-12-30', timestamptz '4000-12-20 BC');
+ERROR:  interval out of range
+select date_bin('200000000 days'::interval, '2024-02-01'::timestamptz, '2024-01-01'::timestamptz);
+ERROR:  interval out of range
+select date_bin('365000 days'::interval, '4400-01-01 BC'::timestamptz, '4000-01-01 BC'::timestamptz);
+ERROR:  timestamp out of range
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
index b9bcce9cfe35de0383dd5f2c74dc366d14fbe8d8..b3ebed86a5bb8c235513bc7dbb5053080f6f2b56 100644 (file)
@@ -268,6 +268,9 @@ FROM (
 -- shift bins using the origin parameter:
 SELECT date_bin('5 min'::interval, timestamp '2020-02-01 01:01:01', timestamp '2020-02-01 00:02:30');
 
+-- test roundoff edge case when source < origin
+SELECT date_bin('30 minutes'::interval, timestamp '2024-02-01 15:00:00', timestamp '2024-02-01 17:00:00');
+
 -- disallow intervals with months or years
 SELECT date_bin('5 months'::interval, timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
 SELECT date_bin('5 years'::interval,  timestamp '2020-02-01 01:01:01', timestamp '2001-01-01');
@@ -278,6 +281,11 @@ SELECT date_bin('0 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp
 -- disallow negative intervals
 SELECT date_bin('-2 days'::interval, timestamp '1970-01-01 01:00:00' , timestamp '1970-01-01 00:00:00');
 
+-- test overflow cases
+select date_bin('15 minutes'::interval, timestamp '294276-12-30', timestamp '4000-12-20 BC');
+select date_bin('200000000 days'::interval, '2024-02-01'::timestamp, '2024-01-01'::timestamp);
+select date_bin('365000 days'::interval, '4400-01-01 BC'::timestamp, '4000-01-01 BC'::timestamp);
+
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
index 69b36d04202e24b1423bd43f94f967c9aa82a114..60cd84172c5b3c0d503fcf515837595ab22ce8b2 100644 (file)
@@ -243,6 +243,9 @@ FROM (
 -- shift bins using the origin parameter:
 SELECT date_bin('5 min'::interval, timestamptz '2020-02-01 01:01:01+00', timestamptz '2020-02-01 00:02:30+00');
 
+-- test roundoff edge case when source < origin
+SELECT date_bin('30 minutes'::interval, timestamptz '2024-02-01 15:00:00', timestamptz '2024-02-01 17:00:00');
+
 -- disallow intervals with months or years
 SELECT date_bin('5 months'::interval, timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
 SELECT date_bin('5 years'::interval,  timestamp with time zone '2020-02-01 01:01:01+00', timestamp with time zone '2001-01-01+00');
@@ -253,6 +256,11 @@ SELECT date_bin('0 days'::interval, timestamp with time zone '1970-01-01 01:00:0
 -- disallow negative intervals
 SELECT date_bin('-2 days'::interval, timestamp with time zone '1970-01-01 01:00:00+00' , timestamp with time zone '1970-01-01 00:00:00+00');
 
+-- test overflow cases
+select date_bin('15 minutes'::interval, timestamptz '294276-12-30', timestamptz '4000-12-20 BC');
+select date_bin('200000000 days'::interval, '2024-02-01'::timestamptz, '2024-01-01'::timestamptz);
+select date_bin('365000 days'::interval, '4400-01-01 BC'::timestamptz, '4000-01-01 BC'::timestamptz);
+
 -- Test casting within a BETWEEN qualifier
 SELECT d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL