diff --git a/pgtype/timestamp.go b/pgtype/timestamp.go index dc685af1d..d2b0c54ef 100644 --- a/pgtype/timestamp.go +++ b/pgtype/timestamp.go @@ -11,8 +11,10 @@ import ( ) const ( - pgTimestampFormat = "2006-01-02 15:04:05.999999999" - jsonISO8601 = "2006-01-02T15:04:05.999999999" + pgTimestampFormat = "2006-01-02 15:04:05.999999999" + jsonISO8601 = "2006-01-02T15:04:05.999999999" + maxTimestampYear = 294276 + maxTimestampBCYear = 4714 ) type TimestampScanner interface { @@ -311,14 +313,16 @@ func (plan *scanPlanTextTimestampToTimestampScanner) Scan(src []byte, dst any) e sbuf = sbuf[:len(sbuf)-3] bc = true } - tim, err := time.Parse(pgTimestampFormat, sbuf) + maxYear := int64(maxTimestampYear) + if bc { + maxYear = maxTimestampBCYear + } + tim, err := parseTimestampWithVariableYear(pgTimestampFormat, sbuf, false, maxYear, bc) if err != nil { return err } - - if bc { - year := -tim.Year() + 1 - tim = time.Date(year, tim.Month(), tim.Day(), tim.Hour(), tim.Minute(), tim.Second(), tim.Nanosecond(), tim.Location()) + if timestampOutOfRange(tim) { + return fmt.Errorf("timestamp out of range") } if plan.location != nil { @@ -331,6 +335,137 @@ func (plan *scanPlanTextTimestampToTimestampScanner) Scan(src []byte, dst any) e return scanner.ScanTimestamp(ts) } +func parseTimestampWithVariableYear(layout, s string, preserveOffset bool, maxYear int64, bc bool) (time.Time, error) { + yearEnd, err := timestampYearEnd(s) + if err != nil { + return time.Time{}, err + } + if yearEnd == 4 && !bc { + if _, err := parseTimestampYear(s[:yearEnd], maxYear); err != nil { + return time.Time{}, err + } + tim, err := time.Parse(layout, s) + if err != nil { + return time.Time{}, err + } + if preserveOffset { + _, offset := tim.Zone() + if offset <= -16*60*60 || offset >= 16*60*60 { + return time.Time{}, fmt.Errorf("time zone displacement out of range") + } + } + return tim, nil + } + + normalized, year, err := normalizeTimestampYear(s, maxYear, bc) + if err != nil { + return time.Time{}, err + } + + tim, err := time.Parse(layout, normalized) + if err != nil { + return time.Time{}, err + } + + loc := tim.Location() + if preserveOffset { + _, offset := tim.Zone() + if offset <= -16*60*60 || offset >= 16*60*60 { + return time.Time{}, fmt.Errorf("time zone displacement out of range") + } + loc = time.FixedZone("", offset) + } + + if bc { + year = 1 - year + } + + return time.Date(year, tim.Month(), tim.Day(), tim.Hour(), tim.Minute(), tim.Second(), tim.Nanosecond(), loc), nil +} + +func timestampOutOfRange(t time.Time) bool { + return t.Before(minTimestampTime().Add(-500*time.Nanosecond)) || !t.Before(maxTimestampTime().Add(500*time.Nanosecond)) +} + +func minTimestampTime() time.Time { + return time.Date(-4713, 11, 24, 0, 0, 0, 0, time.UTC) +} + +func maxTimestampTime() time.Time { + return time.Date(maxTimestampYear, 12, 31, 23, 59, 59, 999999000, time.UTC) +} + +func normalizeTimestampYear(s string, maxYear int64, bc bool) (string, int, error) { + yearEnd, err := timestampYearEnd(s) + if err != nil { + return "", 0, err + } + + year64, err := parseTimestampYear(s[:yearEnd], maxYear) + if err != nil { + return "", 0, err + } + year := int(year64) + + normalizedYear := "2001" + if isTimestampLeapYear(year, bc) { + normalizedYear = "2000" + } + + return normalizedYear + s[yearEnd:], year, nil +} + +func timestampYearEnd(s string) (int, error) { + yearEnd := -1 + for i := 4; i < len(s); i++ { + if s[i] == '-' { + yearEnd = i + break + } + if s[i] < '0' || s[i] > '9' { + return 0, fmt.Errorf("invalid timestamp format") + } + } + if yearEnd == -1 { + return 0, fmt.Errorf("invalid timestamp format") + } + return yearEnd, nil +} + +func parseTimestampYear(s string, maxYear int64) (int64, error) { + if len(s) == 0 { + return 0, fmt.Errorf("invalid timestamp format") + } + + var n int64 + for _, c := range []byte(s) { + if c < '0' || c > '9' { + return 0, fmt.Errorf("invalid timestamp format") + } + digit := int64(c - '0') + if n > (maxYear-digit)/10 { + return 0, fmt.Errorf("timestamp year out of range") + } + n = n*10 + digit + } + if n < 1 || n > maxYear { + return 0, fmt.Errorf("timestamp year out of range") + } + + return n, nil +} + +func isLeapYear(year int) bool { + return year%4 == 0 && (year%100 != 0 || year%400 == 0) +} + +func isTimestampLeapYear(year int, bc bool) bool { + if bc { + year = 1 - year + } + return isLeapYear(year) +} + func (c *TimestampCodec) DecodeDatabaseSQLValue(m *Map, oid uint32, format int16, src []byte) (driver.Value, error) { if src == nil { return nil, nil diff --git a/pgtype/timestamp_test.go b/pgtype/timestamp_test.go index 73cc8bd8e..392936037 100644 --- a/pgtype/timestamp_test.go +++ b/pgtype/timestamp_test.go @@ -92,13 +92,53 @@ func TestTimestampTranscodeBigTimeBinary(t *testing.T) { }) } +func TestTimestampCodecDecodeTextBigTime(t *testing.T) { + c := &pgtype.TimestampCodec{} + + for _, tt := range []struct { + src string + want time.Time + }{ + {src: `10000-01-02 03:04:05.123456`, want: time.Date(10000, 1, 2, 3, 4, 5, 123456000, time.UTC)}, + {src: `00000000000010000-01-02 03:04:05.123456`, want: time.Date(10000, 1, 2, 3, 4, 5, 123456000, time.UTC)}, + {src: `294276-12-31 23:59:59.999999`, want: time.Date(294276, 12, 31, 23, 59, 59, 999999000, time.UTC)}, + {src: `294276-12-31 23:59:59.999999499`, want: time.Date(294276, 12, 31, 23, 59, 59, 999999499, time.UTC)}, + {src: `4713-02-29 00:00:00 BC`, want: time.Date(-4712, 2, 29, 0, 0, 0, 0, time.UTC)}, + {src: `4714-11-24 00:00:00 BC`, want: time.Date(-4713, 11, 24, 0, 0, 0, 0, time.UTC)}, + {src: `4714-11-23 23:59:59.999999500 BC`, want: time.Date(-4713, 11, 23, 23, 59, 59, 999999500, time.UTC)}, + } { + var ts pgtype.Timestamp + plan := c.PlanScan(nil, pgtype.TimestampOID, pgtype.TextFormatCode, &ts) + + err := plan.Scan([]byte(tt.src), &ts) + require.NoError(t, err) + require.True(t, ts.Valid) + require.Equal(t, tt.want, ts.Time) + } +} + // https://github.com/jackc/pgtype/issues/74 func TestTimestampCodecDecodeTextInvalid(t *testing.T) { c := &pgtype.TimestampCodec{} - var ts pgtype.Timestamp - plan := c.PlanScan(nil, pgtype.TimestampOID, pgtype.TextFormatCode, &ts) - err := plan.Scan([]byte(`eeeee`), &ts) - require.Error(t, err) + + for _, src := range []string{ + `eeeee`, + `0000-01-01 00:00:00`, + `10000-02-30 00:00:00`, + `10001-02-29 00:00:00`, + `294277-01-01 00:00:00`, + `294276-12-31 23:59:59.999999500`, + `4714-01-01 00:00:00 BC`, + `4714-11-23 23:59:59.999999499 BC`, + `4712-02-29 00:00:00 BC`, + `10000-01-01 00:00:00 BC`, + `9223372036854775808-01-01 00:00:00`, + } { + var ts pgtype.Timestamp + plan := c.PlanScan(nil, pgtype.TimestampOID, pgtype.TextFormatCode, &ts) + err := plan.Scan([]byte(src), &ts) + require.Error(t, err) + } } func TestTimestampMarshalJSON(t *testing.T) { diff --git a/pgtype/timestamptz.go b/pgtype/timestamptz.go index 2d764e440..de1ccb3d2 100644 --- a/pgtype/timestamptz.go +++ b/pgtype/timestamptz.go @@ -313,14 +313,19 @@ func (plan *scanPlanTextTimestamptzToTimestamptzScanner) Scan(src []byte, dst an format = pgTimestamptzHourFormat } - tim, err := time.Parse(format, sbuf) + maxYear := int64(maxTimestampYear) + if bc { + maxYear = maxTimestampBCYear + } else { + maxYear++ + } + tim, err := parseTimestampWithVariableYear(format, sbuf, true, maxYear, bc) if err != nil { return err } - if bc { - year := -tim.Year() + 1 - tim = time.Date(year, tim.Month(), tim.Day(), tim.Hour(), tim.Minute(), tim.Second(), tim.Nanosecond(), tim.Location()) + if timestampOutOfRange(tim) { + return fmt.Errorf("timestamp out of range") } if plan.location != nil { diff --git a/pgtype/timestamptz_test.go b/pgtype/timestamptz_test.go index 00cace5c2..c91eda575 100644 --- a/pgtype/timestamptz_test.go +++ b/pgtype/timestamptz_test.go @@ -88,13 +88,87 @@ func TestTimestamptzTranscodeBigTimeBinary(t *testing.T) { }) } +func TestTimestamptzCodecDecodeTextBigTime(t *testing.T) { + c := &pgtype.TimestamptzCodec{ScanLocation: time.UTC} + + for _, tt := range []struct { + src string + want time.Time + }{ + {src: `10000-01-02 03:04:05.123456+00`, want: time.Date(10000, 1, 2, 3, 4, 5, 123456000, time.UTC)}, + {src: `00000000000010000-01-02 03:04:05.123456+00`, want: time.Date(10000, 1, 2, 3, 4, 5, 123456000, time.UTC)}, + {src: `294276-12-31 23:59:59.999999+00`, want: time.Date(294276, 12, 31, 23, 59, 59, 999999000, time.UTC)}, + {src: `294276-12-31 23:59:59.999999499+00`, want: time.Date(294276, 12, 31, 23, 59, 59, 999999499, time.UTC)}, + {src: `294276-12-31 23:59:59.999999+14`, want: time.Date(294276, 12, 31, 9, 59, 59, 999999000, time.UTC)}, + {src: `294277-01-01 00:00:00+14`, want: time.Date(294276, 12, 31, 10, 0, 0, 0, time.UTC)}, + {src: `294277-01-01 15:58:59.999999+15:59`, want: time.Date(294276, 12, 31, 23, 59, 59, 999999000, time.UTC)}, + {src: `4713-02-29 00:00:00+00 BC`, want: time.Date(-4712, 2, 29, 0, 0, 0, 0, time.UTC)}, + {src: `4714-11-24 00:00:00+00 BC`, want: time.Date(-4713, 11, 24, 0, 0, 0, 0, time.UTC)}, + {src: `4714-11-23 10:00:00-14 BC`, want: time.Date(-4713, 11, 24, 0, 0, 0, 0, time.UTC)}, + {src: `4714-11-23 09:59:59.999999500-14 BC`, want: time.Date(-4713, 11, 23, 23, 59, 59, 999999500, time.UTC)}, + } { + var tstz pgtype.Timestamptz + plan := c.PlanScan(nil, pgtype.TimestamptzOID, pgtype.TextFormatCode, &tstz) + + err := plan.Scan([]byte(tt.src), &tstz) + require.NoError(t, err) + require.True(t, tstz.Valid) + require.Equal(t, tt.want, tstz.Time) + } +} + +func TestTimestamptzCodecDecodeTextBigTimePreservesOffset(t *testing.T) { + c := &pgtype.TimestamptzCodec{} + + for _, tt := range []struct { + src string + want time.Time + }{ + {src: `10000-01-02 03:04:05.123456+09`, want: time.Date(10000, 1, 2, 3, 4, 5, 123456000, time.FixedZone("", 9*60*60))}, + } { + var tstz pgtype.Timestamptz + plan := c.PlanScan(nil, pgtype.TimestamptzOID, pgtype.TextFormatCode, &tstz) + + err := plan.Scan([]byte(tt.src), &tstz) + require.NoError(t, err) + require.True(t, tstz.Valid) + + _, offset := tstz.Time.Zone() + require.Equal(t, 9*60*60, offset) + require.Equal(t, tt.want, tstz.Time) + } +} + // https://github.com/jackc/pgtype/issues/74 func TestTimestamptzDecodeTextInvalid(t *testing.T) { c := &pgtype.TimestamptzCodec{} - var tstz pgtype.Timestamptz - plan := c.PlanScan(nil, pgtype.TimestamptzOID, pgtype.TextFormatCode, &tstz) - err := plan.Scan([]byte(`eeeee`), &tstz) - require.Error(t, err) + + for _, src := range []string{ + `eeeee`, + `0000-01-01 00:00:00+00`, + `10000-02-30 00:00:00+00`, + `10001-02-29 00:00:00+00`, + `2024-01-01 00:00:00+16`, + `2024-01-01 00:00:00-16`, + `294277-01-01 00:00:00+00`, + `294277-01-01 15:59:00+15:59`, + `294276-12-31 23:59:59.999999500+00`, + `4714-01-01 00:00:00+00 BC`, + `4714-11-23 23:59:59.999999499+00 BC`, + `4714-11-23 09:59:59.999999499-14 BC`, + `4714-11-24 00:00:00+14 BC`, + `4712-02-29 00:00:00+00 BC`, + `10000-01-01 00:00:00+00 BC`, + `9223372036854775808-01-01 00:00:00+00`, + `10000-01-02 03:04:05.123456+16`, + `10000-01-02 03:04:05.123456-16`, + `294276-12-31 23:59:59.999999-14`, + } { + var tstz pgtype.Timestamptz + plan := c.PlanScan(nil, pgtype.TimestamptzOID, pgtype.TextFormatCode, &tstz) + err := plan.Scan([]byte(src), &tstz) + require.Error(t, err) + } } func TestTimestamptzMarshalJSON(t *testing.T) {