Fix portability issues in datetime parsing.
authorTom Lane <tgl@sss.pgh.pa.us>
Sun, 3 Apr 2022 21:04:21 +0000 (17:04 -0400)
committerTom Lane <tgl@sss.pgh.pa.us>
Sun, 3 Apr 2022 21:04:33 +0000 (17:04 -0400)
datetime.c's parsing logic has assumed that strtod() will accept
a string that looks like ".", which it does in glibc, but not on
some less-common platforms such as AIX.  The result of this was
that datetime fields like "123." would be accepted on some platforms
but not others; which is a sufficiently odd case that it's not that
surprising we've heard no field complaints.  But commit e39f99046
extended that assumption to new places, and happened to add a test
case that exposed the platform dependency.  Remove this dependency
by special-casing situations without any digits after the decimal
point.

(Again, this is in part a pre-existing bug but I don't feel a
compulsion to back-patch.)

Also, rearrange e39f99046's changes in formatting.c to avoid a
Coverity complaint that we were copying an uninitialized field.

Discussion: https://postgr.es/m/1592893.1648969747@sss.pgh.pa.us

src/backend/utils/adt/datetime.c
src/backend/utils/adt/formatting.c
src/test/regress/expected/interval.out
src/test/regress/sql/interval.sql

index 462f2ed7a8ac043d7c34e9b1620c4dbaed00ee22..4c12c4d66302d21a1a143ef28822402792f47612 100644 (file)
@@ -668,19 +668,50 @@ AdjustYears(int64 val, int scale,
 }
 
 
-/* Fetch a fractional-second value with suitable error checking */
+/*
+ * Parse the fractional part of a number (decimal point and optional digits,
+ * followed by end of string).  Returns the fractional value into *frac.
+ *
+ * Returns 0 if successful, DTERR code if bogus input detected.
+ */
+static int
+ParseFraction(char *cp, double *frac)
+{
+   /* Caller should always pass the start of the fraction part */
+   Assert(*cp == '.');
+
+   /*
+    * We want to allow just "." with no digits, but some versions of strtod
+    * will report EINVAL for that, so special-case it.
+    */
+   if (cp[1] == '\0')
+   {
+       *frac = 0;
+   }
+   else
+   {
+       errno = 0;
+       *frac = strtod(cp, &cp);
+       /* check for parse failure */
+       if (*cp != '\0' || errno != 0)
+           return DTERR_BAD_FORMAT;
+   }
+   return 0;
+}
+
+/*
+ * Fetch a fractional-second value with suitable error checking.
+ * Same as ParseFraction except we convert the result to integer microseconds.
+ */
 static int
 ParseFractionalSecond(char *cp, fsec_t *fsec)
 {
    double      frac;
+   int         dterr;
 
-   /* Caller should always pass the start of the fraction part */
-   Assert(*cp == '.');
-   errno = 0;
-   frac = strtod(cp, &cp);
-   /* check for parse failure */
-   if (*cp != '\0' || errno != 0)
-       return DTERR_BAD_FORMAT;
+   dterr = ParseFraction(cp, &frac);
+   if (dterr)
+       return dterr;
    *fsec = rint(frac * 1000000);
    return 0;
 }
@@ -1248,10 +1279,9 @@ DecodeDateTime(char **field, int *ftype, int nf,
                            {
                                double      time;
 
-                               errno = 0;
-                               time = strtod(cp, &cp);
-                               if (*cp != '\0' || errno != 0)
-                                   return DTERR_BAD_FORMAT;
+                               dterr = ParseFraction(cp, &time);
+                               if (dterr)
+                                   return dterr;
                                time *= USECS_PER_DAY;
                                dt2time(time,
                                        &tm->tm_hour, &tm->tm_min,
@@ -2146,10 +2176,9 @@ DecodeTimeOnly(char **field, int *ftype, int nf,
                            {
                                double      time;
 
-                               errno = 0;
-                               time = strtod(cp, &cp);
-                               if (*cp != '\0' || errno != 0)
-                                   return DTERR_BAD_FORMAT;
+                               dterr = ParseFraction(cp, &time);
+                               if (dterr)
+                                   return dterr;
                                time *= USECS_PER_DAY;
                                dt2time(time,
                                        &tm->tm_hour, &tm->tm_min,
@@ -3035,13 +3064,21 @@ DecodeNumberField(int len, char *str, int fmask,
         * Can we use ParseFractionalSecond here?  Not clear whether trailing
         * junk should be rejected ...
         */
-       double      frac;
+       if (cp[1] == '\0')
+       {
+           /* avoid assuming that strtod will accept "." */
+           *fsec = 0;
+       }
+       else
+       {
+           double      frac;
 
-       errno = 0;
-       frac = strtod(cp, NULL);
-       if (errno != 0)
-           return DTERR_BAD_FORMAT;
-       *fsec = rint(frac * 1000000);
+           errno = 0;
+           frac = strtod(cp, NULL);
+           if (errno != 0)
+               return DTERR_BAD_FORMAT;
+           *fsec = rint(frac * 1000000);
+       }
        /* Now truncate off the fraction for further processing */
        *cp = '\0';
        len = strlen(str);
@@ -3467,11 +3504,9 @@ DecodeInterval(char **field, int *ftype, int nf, int range,
                }
                else if (*cp == '.')
                {
-                   errno = 0;
-                   fval = strtod(cp, &cp);
-                   if (*cp != '\0' || errno != 0)
-                       return DTERR_BAD_FORMAT;
-
+                   dterr = ParseFraction(cp, &fval);
+                   if (dterr)
+                       return dterr;
                    if (*field[i] == '-')
                        fval = -fval;
                }
@@ -3650,6 +3685,7 @@ DecodeInterval(char **field, int *ftype, int nf, int range,
  * Helper functions to avoid duplicated code in DecodeISO8601Interval.
  *
  * Parse a decimal value and break it into integer and fractional parts.
+ * Set *endptr to end+1 of the parsed substring.
  * Returns 0 or DTERR code.
  */
 static int
@@ -3676,7 +3712,20 @@ ParseISO8601Number(char *str, char **endptr, int64 *ipart, double *fpart)
 
    /* Parse fractional part if there is any */
    if (**endptr == '.')
-       *fpart = strtod(*endptr, endptr) * sign;
+   {
+       /*
+        * As in ParseFraction, some versions of strtod insist on seeing some
+        * digits after '.', but some don't.  We want to allow zero digits
+        * after '.' as long as there were some before it.
+        */
+       if (isdigit((unsigned char) *(*endptr + 1)))
+           *fpart = strtod(*endptr, endptr) * sign;
+       else
+       {
+           (*endptr)++;        /* advance over '.' */
+           str++;              /* so next test will fail if no digits */
+       }
+   }
 
    /* did we not see anything that looks like a number? */
    if (*endptr == str || errno != 0)
index 843b07d7d24c918301f9cfd867269d4c5b5a499f..97a4544ffc630e1234dd387c9ebb0bcd58ec586b 100644 (file)
@@ -4134,11 +4134,13 @@ timestamp_to_char(PG_FUNCTION_ARGS)
        ereport(ERROR,
                (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
                 errmsg("timestamp out of range")));
-   COPY_tm(tm, &tt);
 
-   thisdate = date2j(tm->tm_year, tm->tm_mon, tm->tm_mday);
-   tm->tm_wday = (thisdate + 1) % 7;
-   tm->tm_yday = thisdate - date2j(tm->tm_year, 1, 1) + 1;
+   /* calculate wday and yday, because timestamp2tm doesn't */
+   thisdate = date2j(tt.tm_year, tt.tm_mon, tt.tm_mday);
+   tt.tm_wday = (thisdate + 1) % 7;
+   tt.tm_yday = thisdate - date2j(tt.tm_year, 1, 1) + 1;
+
+   COPY_tm(tm, &tt);
 
    if (!(res = datetime_to_char_body(&tmtc, fmt, false, PG_GET_COLLATION())))
        PG_RETURN_NULL();
@@ -4168,11 +4170,13 @@ timestamptz_to_char(PG_FUNCTION_ARGS)
        ereport(ERROR,
                (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
                 errmsg("timestamp out of range")));
-   COPY_tm(tm, &tt);
 
-   thisdate = date2j(tm->tm_year, tm->tm_mon, tm->tm_mday);
-   tm->tm_wday = (thisdate + 1) % 7;
-   tm->tm_yday = thisdate - date2j(tm->tm_year, 1, 1) + 1;
+   /* calculate wday and yday, because timestamp2tm doesn't */
+   thisdate = date2j(tt.tm_year, tt.tm_mon, tt.tm_mday);
+   tt.tm_wday = (thisdate + 1) % 7;
+   tt.tm_yday = thisdate - date2j(tt.tm_year, 1, 1) + 1;
+
+   COPY_tm(tm, &tt);
 
    if (!(res = datetime_to_char_body(&tmtc, fmt, false, PG_GET_COLLATION())))
        PG_RETURN_NULL();
index 86c8d4bc990573d31b66e69071cd77916e1cd58a..03f77c01dcf7f6d6d0a19bef9267d88065ae521a 100644 (file)
@@ -908,6 +908,41 @@ select  interval 'P0002'                  AS "year only",
  2 years   | 2 years 10 mons | 2 years 10 mons 15 days | 2 years 00:00:01    | 2 years 10 mons 00:00:01 | 2 years 10 mons 15 days 00:00:01 | 10:00:00  | 10:30:00
 (1 row)
 
+-- Check handling of fractional fields in ISO8601 format.
+select interval 'P1Y0M3DT4H5M6S';
+        interval        
+------------------------
+ 1 year 3 days 04:05:06
+(1 row)
+
+select interval 'P1.0Y0M3DT4H5M6S';
+        interval        
+------------------------
+ 1 year 3 days 04:05:06
+(1 row)
+
+select interval 'P1.1Y0M3DT4H5M6S';
+           interval           
+------------------------------
+ 1 year 1 mon 3 days 04:05:06
+(1 row)
+
+select interval 'P1.Y0M3DT4H5M6S';
+        interval        
+------------------------
+ 1 year 3 days 04:05:06
+(1 row)
+
+select interval 'P.1Y0M3DT4H5M6S';
+       interval        
+-----------------------
+ 1 mon 3 days 04:05:06
+(1 row)
+
+select interval 'P.Y0M3DT4H5M6S';  -- error
+ERROR:  invalid input syntax for type interval: "P.Y0M3DT4H5M6S"
+LINE 1: select interval 'P.Y0M3DT4H5M6S';
+                        ^
 -- test a couple rounding cases that changed since 8.3 w/ HAVE_INT64_TIMESTAMP.
 SET IntervalStyle to postgres_verbose;
 select interval '-10 mons -3 days +03:55:06.70';
index f05055e03a98b1ac8af64ace85080562cf96b6f7..97d33a13236a662a42139cd2955fdcf35e9847ed 100644 (file)
@@ -312,6 +312,14 @@ select  interval 'P0002'                  AS "year only",
         interval 'PT10'                   AS "hour only",
         interval 'PT10:30'                AS "hour minute";
 
+-- Check handling of fractional fields in ISO8601 format.
+select interval 'P1Y0M3DT4H5M6S';
+select interval 'P1.0Y0M3DT4H5M6S';
+select interval 'P1.1Y0M3DT4H5M6S';
+select interval 'P1.Y0M3DT4H5M6S';
+select interval 'P.1Y0M3DT4H5M6S';
+select interval 'P.Y0M3DT4H5M6S';  -- error
+
 -- test a couple rounding cases that changed since 8.3 w/ HAVE_INT64_TIMESTAMP.
 SET IntervalStyle to postgres_verbose;
 select interval '-10 mons -3 days +03:55:06.70';