From 5e9dde0c13683f217c4f16a8fd70d1e22ce97bfa Mon Sep 17 00:00:00 2001 From: TristonianJones Date: Wed, 12 Aug 2026 07:49:27 -0700 Subject: [PATCH 1/2] Parsing helper for working with different types and formats of timestamp --- common/types/timestamp.go | 83 +++++++++++++++++++ common/types/timestamp_test.go | 146 +++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) diff --git a/common/types/timestamp.go b/common/types/timestamp.go index aee47f05..1f7e1d77 100644 --- a/common/types/timestamp.go +++ b/common/types/timestamp.go @@ -15,6 +15,8 @@ package types import ( + "encoding/json" + "errors" "fmt" "reflect" "regexp" @@ -258,6 +260,87 @@ func (t Timestamp) format(sb *strings.Builder) { fmt.Fprintf(sb, `timestamp("%s")`, t.Time.UTC().Format(time.RFC3339Nano)) } +// ParseTimestamp attempts to parse a timestamp from various supported types and representations: +// - time.Time, Timestamp, *timestamppb.Timestamp +// - RFC 3339 and RFC 3339Nano formatted strings (e.g. "2023-01-01T00:00:00Z") +// - Unix epoch integers (int, int32, int64) +// - Unix epoch floating-point seconds (float32, float64) +// - json.Number +// - String representations of integers or floating-point epoch seconds +// +// If the parsed timestamp falls outside the supported range [minUnixTime, maxUnixTime], an error is returned. +func ParseTimestamp(val any) (time.Time, error) { + if val == nil { + return time.Time{}, errors.New("invalid timestamp: nil value") + } + switch v := val.(type) { + case time.Time: + return validateTimestampRange(v.UTC()) + case Timestamp: + return validateTimestampRange(v.Time.UTC()) + case *tpb.Timestamp: + if v == nil { + return time.Time{}, nil + } + return validateTimestampRange(v.AsTime().UTC()) + case int: + return validateTimestampRange(time.Unix(int64(v), 0).UTC()) + case int32: + return validateTimestampRange(time.Unix(int64(v), 0).UTC()) + case int64: + return validateTimestampRange(time.Unix(v, 0).UTC()) + case float32: + return validateTimestampRange(unixTimeFromFloat(float64(v))) + case float64: + return validateTimestampRange(unixTimeFromFloat(v)) + case json.Number: + if i, err := v.Int64(); err == nil { + return validateTimestampRange(time.Unix(i, 0).UTC()) + } + if f, err := v.Float64(); err == nil { + return validateTimestampRange(unixTimeFromFloat(f)) + } + return ParseTimestamp(v.String()) + case string: + s := strings.TrimSpace(v) + if s == "" { + return time.Time{}, errors.New("invalid RFC 3339 timestamp: ''") + } + if isStrictRFC3339(s) { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, fmt.Errorf("invalid RFC 3339 timestamp %q", s) + } + return validateTimestampRange(t.UTC()) + } + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return validateTimestampRange(time.Unix(i, 0).UTC()) + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return validateTimestampRange(unixTimeFromFloat(f)) + } + return time.Time{}, fmt.Errorf("unsupported timestamp format: %q", s) + default: + return time.Time{}, fmt.Errorf("unsupported timestamp type: %T", val) + } +} + +func unixTimeFromFloat(f float64) time.Time { + sec := int64(f) + nsec := int64((f - float64(sec)) * 1e9) + return time.Unix(sec, nsec).UTC() +} + +func validateTimestampRange(t time.Time) (time.Time, error) { + if t.IsZero() { + return t, nil + } + if t.Unix() < minUnixTime || t.Unix() > maxUnixTime { + return time.Time{}, fmt.Errorf("timestamp overflow: %v", t) + } + return t, nil +} + var ( timestampValueType = reflect.TypeOf(&tpb.Timestamp{}) diff --git a/common/types/timestamp_test.go b/common/types/timestamp_test.go index c13b21fe..04799f1a 100644 --- a/common/types/timestamp_test.go +++ b/common/types/timestamp_test.go @@ -15,6 +15,7 @@ package types import ( + "encoding/json" "errors" "math" "reflect" @@ -551,3 +552,148 @@ func TestIsStrictRFC3339MatchesPattern(t *testing.T) { } } } + +func TestParseTimestamp(t *testing.T) { + now := time.Now().UTC() + epoch := int64(1700000000) + epochTime := time.Unix(epoch, 0).UTC() + epochFloatTime := time.Unix(epoch, 500000000).UTC() + var nilPbTs *tpb.Timestamp + + tests := []struct { + name string + val any + want time.Time + wantErr bool + }{ + { + name: "nil", + val: nil, + wantErr: true, + }, + { + name: "empty string", + val: "", + wantErr: true, + }, + { + name: "time.Time", + val: now, + want: now, + }, + { + name: "Timestamp struct", + val: Timestamp{Time: now}, + want: now, + }, + { + name: "*tpb.Timestamp", + val: tpb.New(now), + want: now, + }, + { + name: "nil *tpb.Timestamp", + val: nilPbTs, + want: time.Time{}, + }, + { + name: "int", + val: int(epoch), + want: epochTime, + }, + { + name: "int32", + val: int32(epoch), + want: epochTime, + }, + { + name: "int64", + val: int64(epoch), + want: epochTime, + }, + { + name: "float64", + val: float64(1700000000.5), + want: epochFloatTime, + }, + { + name: "float32", + val: float32(1700000000.5), + want: epochTime, + }, + { + name: "json.Number int", + val: json.Number("1700000000"), + want: epochTime, + }, + { + name: "json.Number float", + val: json.Number("1700000000.5"), + want: epochFloatTime, + }, + { + name: "json.Number invalid", + val: json.Number("invalid"), + wantErr: true, + }, + { + name: "string RFC3339", + val: "2026-08-10T12:00:00Z", + want: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC), + }, + { + name: "string RFC3339Nano", + val: "2026-08-10T12:00:00.500Z", + want: time.Date(2026, 8, 10, 12, 0, 0, 500000000, time.UTC), + }, + { + name: "string RFC3339 invalid", + val: "2026-99-99T99:99:99Z", + wantErr: true, + }, + { + name: "string epoch int", + val: "1700000000", + want: epochTime, + }, + { + name: "string epoch float", + val: "1700000000.5", + want: epochFloatTime, + }, + { + name: "string invalid", + val: "not-a-timestamp", + wantErr: true, + }, + { + name: "unsupported map type", + val: map[string]any{}, + wantErr: true, + }, + { + name: "overflow", + val: int64(999999999999999), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts, err := ParseTimestamp(tc.val) + if tc.wantErr { + if err == nil { + t.Errorf("ParseTimestamp(%v) succeeded, wanted error", tc.val) + } + return + } + if err != nil { + t.Errorf("ParseTimestamp(%v) unexpected error: %v", tc.val, err) + return + } + if !ts.Equal(tc.want) { + t.Errorf("ParseTimestamp(%v) = %v, wanted %v", tc.val, ts, tc.want) + } + }) + } +} From 838146aeb31cd0c343269510551f9ed9bc3f61b1 Mon Sep 17 00:00:00 2001 From: TristonianJones Date: Fri, 14 Aug 2026 13:54:28 -0700 Subject: [PATCH 2/2] Add overflow checks and negative tests --- common/types/timestamp.go | 17 ++++++++++------- common/types/timestamp_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/common/types/timestamp.go b/common/types/timestamp.go index 1f7e1d77..49b20010 100644 --- a/common/types/timestamp.go +++ b/common/types/timestamp.go @@ -290,15 +290,15 @@ func ParseTimestamp(val any) (time.Time, error) { case int64: return validateTimestampRange(time.Unix(v, 0).UTC()) case float32: - return validateTimestampRange(unixTimeFromFloat(float64(v))) + return unixTimeFromFloat(float64(v)) case float64: - return validateTimestampRange(unixTimeFromFloat(v)) + return unixTimeFromFloat(v) case json.Number: if i, err := v.Int64(); err == nil { return validateTimestampRange(time.Unix(i, 0).UTC()) } if f, err := v.Float64(); err == nil { - return validateTimestampRange(unixTimeFromFloat(f)) + return unixTimeFromFloat(f) } return ParseTimestamp(v.String()) case string: @@ -317,7 +317,7 @@ func ParseTimestamp(val any) (time.Time, error) { return validateTimestampRange(time.Unix(i, 0).UTC()) } if f, err := strconv.ParseFloat(s, 64); err == nil { - return validateTimestampRange(unixTimeFromFloat(f)) + return unixTimeFromFloat(f) } return time.Time{}, fmt.Errorf("unsupported timestamp format: %q", s) default: @@ -325,10 +325,13 @@ func ParseTimestamp(val any) (time.Time, error) { } } -func unixTimeFromFloat(f float64) time.Time { - sec := int64(f) +func unixTimeFromFloat(f float64) (time.Time, error) { + sec, err := doubleToInt64Checked(f) + if err != nil { + return time.Time{}, err + } nsec := int64((f - float64(sec)) * 1e9) - return time.Unix(sec, nsec).UTC() + return validateTimestampRange(time.Unix(sec, nsec).UTC()) } func validateTimestampRange(t time.Time) (time.Time, error) { diff --git a/common/types/timestamp_test.go b/common/types/timestamp_test.go index 04799f1a..47afeec3 100644 --- a/common/types/timestamp_test.go +++ b/common/types/timestamp_test.go @@ -616,11 +616,41 @@ func TestParseTimestamp(t *testing.T) { val: float64(1700000000.5), want: epochFloatTime, }, + { + name: "float64 negative", + val: float64(-1700000000.5), + want: time.Unix(-1700000000, -500000000).UTC(), + }, + { + name: "float64 MaxFloat64 overflow", + val: math.MaxFloat64, + wantErr: true, + }, + { + name: "float64 NaN overflow", + val: math.NaN(), + wantErr: true, + }, + { + name: "float64 Inf overflow", + val: math.Inf(1), + wantErr: true, + }, + { + name: "float64 -Inf overflow", + val: math.Inf(-1), + wantErr: true, + }, { name: "float32", val: float32(1700000000.5), want: epochTime, }, + { + name: "float32 negative", + val: float32(-1700000000.5), + want: time.Unix(-1700000000, 0).UTC(), + }, { name: "json.Number int", val: json.Number("1700000000"),