Skip to content
Merged
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
75 changes: 75 additions & 0 deletions common/types/timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ package types
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"unicode"

"github.com/google/cel-go/common/overloads"
"github.com/google/cel-go/common/types/ref"
Expand Down Expand Up @@ -52,6 +54,79 @@ 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. Month and day are held
// to the grammar ranges 01-12 and 01-31; the remaining calendar validation
// (day-of-month vs. month, leap years) 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}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[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
}
// full-date "T" partial-time
if !isYear(s[0:4]) || !isChar(s[4], '-') || !isMonth(s[5:7]) || !isChar(s[7], '-') || !isDay(s[8:10]) ||
!isChar(s[10], 't') ||
!isHour(s[11:13]) || !isChar(s[13], ':') || !isMinute(s[14:16]) || !isChar(s[16], ':') || !isSecond(s[17:19]) {
return false
}
rest := s[19:]
// optional fractional seconds: "." 1*DIGIT
if rest[0] == '.' {
rest = rest[1:]
n := 0
for n < len(rest) && isDigit(rest[n]) {
n++
}
if n == 0 {
return false
}
rest = rest[n:]
}
// time-offset: "Z" or ("+" / "-") time-hour ":" time-minute
if len(rest) == 1 {
return isChar(rest[0], 'z')
}
if len(rest) == 6 && (rest[0] == '+' || rest[0] == '-') {
return isHour(rest[1:3]) && isChar(rest[3], ':') && isMinute(rest[4:6])
}
return false
}

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

// isChar reports whether got is want, case-insensitively; want must be lower case.
func isChar(got, want byte) bool {
g, w := rune(got), rune(want)
return g == w || unicode.ToLower(g) == w
}

// inRange reports whether s is all decimal digits and its value lies in [lo, hi].
func inRange(s string, lo, hi uint64) bool {
u, err := strconv.ParseUint(s, 10, 64)
return err == nil && u >= lo && u <= hi
}

func isYear(s string) bool { return inRange(s, 0, 9999) }
func isMonth(s string) bool { return inRange(s, 1, 12) }
func isDay(s string) bool { return inRange(s, 1, 31) }
func isHour(s string) bool { return inRange(s, 0, 23) }
func isMinute(s string) bool { return inRange(s, 0, 59) }

// isSecond permits 60 for a leap second.
func isSecond(s string) bool { return inRange(s, 0, 60) }

// Add implements traits.Adder.Add.
func (t Timestamp) Add(other ref.Val) ref.Val {
switch other.Type() {
Expand Down
46 changes: 46 additions & 0 deletions common/types/timestamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,49 @@ 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",
"2025-12-31T12:34:56Z",
// rejected forms
"2025-00-01T12:34:56Z",
"2025-13-01T12:34:56Z",
"2025-01-00T12:34:56Z",
"2025-01-32T12:34:56Z",
"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)
}
}
}