Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion common/types/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ func (s String) ConvertToType(typeVal ref.Type) ref.Val {
return durationOf(d)
}
case TimestampType:
if t, err := time.Parse(time.RFC3339, s.Value().(string)); err == nil {
str := s.Value().(string)
if !isStrictRFC3339(str) {
return NewErr("invalid RFC 3339 timestamp %q", str)
}
if t, err := time.Parse(time.RFC3339, str); err == nil {
if t.Unix() < minUnixTime || t.Unix() > maxUnixTime {
return celErrTimestampOverflow
}
Expand Down
45 changes: 45 additions & 0 deletions common/types/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package types

import (
"fmt"
"reflect"
"testing"
"time"
Expand Down Expand Up @@ -163,6 +164,50 @@ func TestStringConvertToType(t *testing.T) {
}
}

func TestStringConvertToTimestampStrict(t *testing.T) {
valid := []string{
"2025-01-17T01:00:00.001Z",
"2025-01-01T12:34:56Z",
"2025-01-01T12:34:56.123456789Z",
"2025-01-01T12:34:56+05:30",
"2025-01-01T12:34:56-08:00",
"2025-01-01T12:34:56+14:00",
}
for _, s := range valid {
if IsError(String(s).ConvertToType(TimestampType)) {
t.Errorf("String(%q).ConvertToType(TimestampType) errored, wanted a timestamp", s)
}
}
// RFC 3339 violations that time.Parse accepts loosely.
invalid := []string{
"2025-01-17T01:00:00,001Z", // ',' fractional separator
"2025-01-17T1:00:00Z", // single-digit hour
"2025-01-17T01:5:00Z", // single-digit minute
"2025-01-18T01:01:01.001+24:01", // offset hour out of range
"2025-01-17T01:01:01.001+00:60", // offset minute out of range
}
for _, s := range invalid {
out := String(s).ConvertToType(TimestampType)
if !IsError(out) {
t.Errorf("String(%q).ConvertToType(TimestampType) succeeded, wanted an error", s)
continue
}
want := fmt.Sprintf("invalid RFC 3339 timestamp %q", s)
if got := out.(*Err).String(); got != want {
t.Errorf("String(%q).ConvertToType(TimestampType) errored with %q, wanted %q", s, got, want)
}
}
}

func BenchmarkStringConvertToTimestamp(b *testing.B) {
s := String("2025-01-01T12:34:56.123456789Z")
for i := 0; i < b.N; i++ {
if IsError(s.ConvertToType(TimestampType)) {
b.Fatal("ConvertToType(TimestampType) errored, wanted a timestamp")
}
}
}

func TestStringEqual(t *testing.T) {
if !String("hello").Equal(String("hello")).(Bool) {
t.Error("Two equivalent strings were not equal")
Expand Down
84 changes: 84 additions & 0 deletions common/types/timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package types
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -52,6 +53,89 @@ const (
maxUnixTime int64 = 253402300799
)

// strictRFC3339Pattern gates the strings accepted by the `timestamp()` overload.
// time.Parse accepts inputs that RFC 3339 forbids: a ',' fractional-second
// separator, single-digit time fields, and numeric offsets whose hours exceed
// 23 or minutes exceed 59. Those slip past unnoticed and shift the parsed
// instant, so they are rejected before time.Parse runs. The remaining calendar
// validation (month, day, leap year) is left to time.Parse.
//
// isStrictRFC3339 is the implementation used on the conversion path; the pattern
// is retained as the reference the scan is conformance tested against.
var strictRFC3339Pattern = regexp.MustCompile(
`^\d{4}-\d{2}-\d{2}[Tt]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$`)

// isStrictRFC3339 reports whether s matches strictRFC3339Pattern, hand-rolled to
// keep the conversion path off the regexp engine and its per-call cost.
func isStrictRFC3339(s string) bool {
// Shortest accepted form is "2006-01-02T15:04:05Z" (20 bytes): a 19-byte
// fixed-width date-time followed by at least a 'Z'/'z' zone.
if len(s) < 20 {
return false
}
// date: \d{4}-\d{2}-\d{2}
if !isDigit(s[0]) || !isDigit(s[1]) || !isDigit(s[2]) || !isDigit(s[3]) || s[4] != '-' ||
!isDigit(s[5]) || !isDigit(s[6]) || s[7] != '-' ||
!isDigit(s[8]) || !isDigit(s[9]) {
return false
}
// date/time separator [Tt]
if s[10] != 'T' && s[10] != 't' {
return false
}
// time: ([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)
if !isHour(s[11], s[12]) || s[13] != ':' || !isMinute(s[14], s[15]) || s[16] != ':' || !isSecond(s[17], s[18]) {
return false
}
rest := s[19:]
// optional fractional seconds (\.\d+)
if rest[0] == '.' {
rest = rest[1:]
n := 0
for n < len(rest) && isDigit(rest[n]) {
n++
}
if n == 0 {
return false
}
rest = rest[n:]
}
// zone: [Zz] | [+-]([01]\d|2[0-3]):[0-5]\d
if len(rest) == 1 {
return rest[0] == 'Z' || rest[0] == 'z'
}
if len(rest) == 6 && (rest[0] == '+' || rest[0] == '-') {
return isHour(rest[1], rest[2]) && rest[3] == ':' && isMinute(rest[4], rest[5])
}
return false
}

func isDigit(c byte) bool { return c >= '0' && c <= '9' }

// isHour reports whether the two bytes form 00-23.
func isHour(hi, lo byte) bool {
switch hi {
case '0', '1':
return isDigit(lo)
case '2':
return lo >= '0' && lo <= '3'
}
return false
}

// isMinute reports whether the two bytes form 00-59.
func isMinute(hi, lo byte) bool {
return hi >= '0' && hi <= '5' && isDigit(lo)
}

// isSecond reports whether the two bytes form 00-60 (60 permits a leap second).
func isSecond(hi, lo byte) bool {
if hi == '6' {
return lo == '0'
}
return isMinute(hi, lo)
}

// Add implements traits.Adder.Add.
func (t Timestamp) Add(other ref.Val) ref.Val {
switch other.Type() {
Expand Down
41 changes: 41 additions & 0 deletions common/types/timestamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,44 @@ func TestTimestampGetMilliseconds(t *testing.T) {
t.Errorf("ts.getMilliseconds('America/Phoenix') got %v, wanted 1 ms", msTz)
}
}

func TestIsStrictRFC3339MatchesPattern(t *testing.T) {
// Exercise the hand-rolled scan against strictRFC3339Pattern, including the
// boundary cases for each field and a few well-formed timestamps. The two
// must agree on every input.
cases := []string{
"2025-01-01T12:34:56Z",
"2025-01-01T12:34:56z",
"2025-01-01t12:34:56Z",
"2025-01-01T12:34:56.123456789Z",
"2025-01-01T12:34:56.1Z",
"2025-01-01T00:00:00+00:00",
"2025-01-01T23:59:60-08:00",
"2025-01-01T12:34:56+14:00",
"2025-01-01T12:34:56+05:30",
"2025-01-01T20:00:00Z",
"2025-01-01T23:59:59Z",
// rejected forms
"2025-01-01T12:34:56,123Z",
"2025-01-01T1:34:56Z",
"2025-01-01T12:3:56Z",
"2025-01-01T12:34:5Z",
"2025-01-01T24:00:00Z",
"2025-01-01T12:60:00Z",
"2025-01-01T12:34:61Z",
"2025-01-01T12:34:56.Z",
"2025-01-01T12:34:56+24:00",
"2025-01-01T12:34:56+00:60",
"2025-01-01T12:34:56+0530",
"2025-01-01T12:34:56",
"2025-01-01 12:34:56Z",
"2025-1-01T12:34:56Z",
"",
"not-a-timestamp",
}
for _, s := range cases {
if got, want := isStrictRFC3339(s), strictRFC3339Pattern.MatchString(s); got != want {
t.Errorf("isStrictRFC3339(%q) = %v, strictRFC3339Pattern.MatchString = %v", s, got, want)
}
}
}