Skip to content

Commit b6460fd

Browse files
committed
types: add support RFC 110 expanded year format
This change adds support for the nine-digit expanded year datetime format specified in RFC 110, allowing exotic datetimes far in the past or future to be represented in Cedar policies and entities. Signed-Off-By: Patrick Jakubowski <patrick.jakubowski@strongdm.com>
1 parent 588e988 commit b6460fd

File tree

2 files changed

+284
-184
lines changed

2 files changed

+284
-184
lines changed

types/datetime.go

Lines changed: 154 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,23 @@ import (
1212

1313
var errDatetime = internal.ErrDatetime
1414

15+
// maxDatetime is the highest possible timestamp that will fit in 64 bits of millisecond-precision space.
16+
var maxDatetime = time.Date(292278994, 8, 17, 7, 12, 55, 807*1e6, time.UTC)
17+
18+
// minDatetime is the lowest possible timestamp that will fit in 64 bits of millisecond-precision space.
19+
var minDatetime = time.Date(-292275055, 5, 17, 16, 47, 04, 192*1e6, time.UTC)
20+
1521
// Datetime represents a Cedar datetime value
1622
type Datetime struct {
1723
// value is a timestamp in milliseconds
1824
value int64
1925
}
2026

21-
// NewDatetime returns a Cedar Datetime from a Go time.Time value
27+
// NewDatetime returns a Cedar Datetime from a Go time.Time value.
28+
//
29+
// The provided time.Time is truncated to millisecond precision. The result is
30+
// undefined if the Unix time in milliseconds cannot be represented by an int64
31+
// (a date more than 292 million years before or after 1970).
2232
func NewDatetime(t time.Time) Datetime {
2333
return Datetime{value: t.UnixMilli()}
2434
}
@@ -29,6 +39,41 @@ func NewDatetimeFromMillis(ms int64) Datetime {
2939
return Datetime{value: ms}
3040
}
3141

42+
func eatRune(s string, c uint8) (string, error) {
43+
if len(s) == 0 {
44+
return "", fmt.Errorf("%w: unexpected EOF", errDatetime)
45+
} else if s[0] != c {
46+
return "", fmt.Errorf("%w: unexpected character %c", errDatetime, s[0])
47+
}
48+
return s[1:], nil
49+
}
50+
51+
func parseUint(s string, chars int, maxValue uint, label string) (uint, string, error) {
52+
if len(s) < chars {
53+
return 0, "", fmt.Errorf("%w: unexpected EOF", errDatetime)
54+
}
55+
v, err := strconv.ParseUint(s[0:chars], 10, 0)
56+
if err != nil {
57+
return 0, "", fmt.Errorf("%w: invalid %v", errDatetime, label)
58+
} else if v > uint64(maxValue) {
59+
return 0, "", fmt.Errorf("%w: %v is greater than %v", errDatetime, label, maxValue)
60+
}
61+
return uint(v), s[chars:], nil
62+
}
63+
64+
// checkValidDay ensures that the given day is valid for the given month in the given year.
65+
func checkValidDay(year int, month, day uint) error {
66+
t := time.Date(year, time.Month(month), int(day), 0, 0, 0, 0, time.UTC)
67+
68+
// Don't allow wrapping: https://github.com/cedar-policy/rfcs/pull/94
69+
_, tmonth, tday := t.Date()
70+
if time.Month(month) != tmonth || int(day) != tday {
71+
return fmt.Errorf("%w: invalid date", errDatetime)
72+
}
73+
74+
return nil
75+
}
76+
3277
// ParseDatetime returns a Cedar datetime when the argument provided
3378
// represents a compatible datetime or an error
3479
//
@@ -39,186 +84,149 @@ func NewDatetimeFromMillis(ms int64) Datetime {
3984
// - "YYYY-MM-DDThh:mm:ss.SSSZ" (date and time with millisecond, UTC)
4085
// - "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (date and time, time zone offset)
4186
// - "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (date and time with millisecond, time zone offset)
87+
//
88+
// Cedar RFC 110 extends this with ISO 8601 expanded year format:
89+
//
90+
// - "(+/-)YYYYYYYYY-MM-DD" (9-digit year, date only)
91+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ssZ" (9-digit year, date and time, UTC)
92+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ss.SSSZ" (9-digit year with millisecond, UTC)
93+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ss(+/-)hhmm" (9-digit year with time zone offset)
94+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (9-digit year with millisecond and offset)
4295
func ParseDatetime(s string) (Datetime, error) {
4396
var (
44-
year, month, day, hour, minute, second, milli int
45-
offset time.Duration
97+
year int
98+
month, day, hour, minute, second, milli uint
99+
offset time.Duration
46100
)
47101

48-
length := len(s)
49-
if length < 10 {
50-
return Datetime{}, fmt.Errorf("%w: string too short", errDatetime)
102+
if len(s) == 0 {
103+
return Datetime{}, fmt.Errorf("%w: unexpected EOF", errDatetime)
51104
}
52105

53-
// Date: YYYY-MM-DD
54-
// YYYY is at offset 0
55-
// MM is at offset 5
56-
// DD is at offset 8
57-
// - is at 4 and 7
58-
// YYYY
59-
if !unicode.IsDigit(rune(s[0])) || !unicode.IsDigit(rune(s[1])) || !unicode.IsDigit(rune(s[2])) || !unicode.IsDigit(rune(s[3])) {
106+
// Check if this is an expanded year format (starts with + or -)
107+
yearSign := 1
108+
yearLength := 4
109+
yearMax := uint(9999)
110+
if s[0] == '+' || s[0] == '-' {
111+
yearLength = 9
112+
yearMax = 999999999
113+
if s[0] == '-' {
114+
yearSign = -1
115+
}
116+
s = s[1:]
117+
} else if !unicode.IsDigit(rune(s[0])) {
60118
return Datetime{}, fmt.Errorf("%w: invalid year", errDatetime)
61119
}
62-
year = 1000*int(rune(s[0])-'0') +
63-
100*int(rune(s[1])-'0') +
64-
10*int(rune(s[2])-'0') +
65-
int(rune(s[3])-'0')
66120

67-
if s[4] != '-' {
68-
return Datetime{}, fmt.Errorf("%w: unexpected character %s", errDatetime, strconv.QuoteRune(rune(s[4])))
121+
absYear, s, err := parseUint(s[0:], yearLength, yearMax, "year")
122+
if err != nil {
123+
return Datetime{}, err
69124
}
125+
year = int(absYear) * yearSign
70126

71-
// MM
72-
if !unicode.IsDigit(rune(s[5])) || !unicode.IsDigit(rune(s[6])) {
73-
return Datetime{}, fmt.Errorf("%w: invalid month", errDatetime)
74-
}
75-
month = 10*int(rune(s[5])-'0') + int(rune(s[6])-'0')
76-
if month > 12 {
77-
return Datetime{}, fmt.Errorf("%w: month is out of range", errDatetime)
127+
if s, err = eatRune(s, '-'); err != nil {
128+
return Datetime{}, err
78129
}
79130

80-
if s[7] != '-' {
81-
return Datetime{}, fmt.Errorf("%w: unexpected character %s", errDatetime, strconv.QuoteRune(rune(s[7])))
131+
if month, s, err = parseUint(s, 2, 12, "month"); err != nil {
132+
return Datetime{}, err
82133
}
83134

84-
// DD
85-
if !unicode.IsDigit(rune(s[8])) || !unicode.IsDigit(rune(s[9])) {
86-
return Datetime{}, fmt.Errorf("%w: invalid day", errDatetime)
87-
}
88-
day = 10*int(rune(s[8])-'0') + int(rune(s[9])-'0')
89-
if day > 31 {
90-
return Datetime{}, fmt.Errorf("%w: day is out of range", errDatetime)
135+
if s, err = eatRune(s, '-'); err != nil {
136+
return Datetime{}, err
91137
}
92138

93-
// If the length is 10, we only have a date and we're done.
94-
if length == 10 {
95-
t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
96-
return Datetime{value: t.UnixMilli()}, nil
139+
if day, s, err = parseUint(s, 2, 31, "day"); err != nil {
140+
return Datetime{}, err
97141
}
98142

99-
// If the length is less than 20, we can't have a valid time.
100-
if length < 20 {
101-
return Datetime{}, fmt.Errorf("%w: invalid time", errDatetime)
143+
if err = checkValidDay(year, month, day); err != nil {
144+
return Datetime{}, err
102145
}
103146

104-
// Time: Thh:mm:ss?
105-
// T is at 10
106-
// hh is at offset 11
107-
// mm is at offset 14
108-
// ss is at offset 17
109-
// : is at 13 and 16
110-
// ? is at 19, and... we'll skip to get back to that.
111-
112-
if s[10] != 'T' {
113-
return Datetime{}, fmt.Errorf("%w: unexpected character %s", errDatetime, strconv.QuoteRune(rune(s[10])))
147+
if len(s) == 0 {
148+
return Datetime{time.Date(year, time.Month(month), int(day), 0, 0, 0, 0, time.UTC).UnixMilli()}, nil
114149
}
115150

116-
if !unicode.IsDigit(rune(s[11])) || !unicode.IsDigit(rune(s[12])) {
117-
return Datetime{}, fmt.Errorf("%w: invalid hour", errDatetime)
118-
}
119-
hour = 10*int(rune(s[11])-'0') + int(rune(s[12])-'0')
120-
if hour > 23 {
121-
return Datetime{}, fmt.Errorf("%w: hour is out of range", errDatetime)
151+
if s, err = eatRune(s, 'T'); err != nil {
152+
return Datetime{}, err
122153
}
123154

124-
if s[13] != ':' {
125-
return Datetime{}, fmt.Errorf("%w: unexpected character %s", errDatetime, strconv.QuoteRune(rune(s[13])))
155+
if hour, s, err = parseUint(s, 2, 23, "hour"); err != nil {
156+
return Datetime{}, err
126157
}
127158

128-
if !unicode.IsDigit(rune(s[14])) || !unicode.IsDigit(rune(s[15])) {
129-
return Datetime{}, fmt.Errorf("%w: invalid minute", errDatetime)
130-
}
131-
minute = 10*int(rune(s[14])-'0') + int(rune(s[15])-'0')
132-
if minute > 59 {
133-
return Datetime{}, fmt.Errorf("%w: minute is out of range", errDatetime)
159+
if s, err = eatRune(s, ':'); err != nil {
160+
return Datetime{}, err
134161
}
135162

136-
if s[16] != ':' {
137-
return Datetime{}, fmt.Errorf("%w: unexpected character %s", errDatetime, strconv.QuoteRune(rune(s[16])))
163+
if minute, s, err = parseUint(s, 2, 59, "minute"); err != nil {
164+
return Datetime{}, err
138165
}
139166

140-
if !unicode.IsDigit(rune(s[17])) || !unicode.IsDigit(rune(s[18])) {
141-
return Datetime{}, fmt.Errorf("%w: invalid second", errDatetime)
167+
if s, err = eatRune(s, ':'); err != nil {
168+
return Datetime{}, err
142169
}
143-
second = 10*int(rune(s[17])-'0') + int(rune(s[18])-'0')
144-
if second > 59 {
145-
return Datetime{}, fmt.Errorf("%w: second is out of range", errDatetime)
170+
171+
if second, s, err = parseUint(s, 2, 59, "second"); err != nil {
172+
return Datetime{}, err
146173
}
147174

148-
// At this point, things are variable.
149-
// 19 can be ., in which case we have milliseconds. (SSS)
150-
// ... but we'll still need a Z, or offset. So, we'll introduce
151-
// trailerOffset to account for where this starts.
152-
trailerOffset := 19
153-
if s[19] == '.' {
154-
if length < 23 {
155-
return Datetime{}, fmt.Errorf("%w: invalid millisecond", errDatetime)
156-
}
175+
if len(s) == 0 {
176+
return Datetime{}, fmt.Errorf("%w: unexpected EOF", errDatetime)
177+
}
157178

158-
if !unicode.IsDigit(rune(s[20])) || !unicode.IsDigit(rune(s[21])) || !unicode.IsDigit(rune(s[22])) {
159-
return Datetime{}, fmt.Errorf("%w: invalid millisecond", errDatetime)
179+
// Parse optional milliseconds
180+
if s[0] == '.' {
181+
milli, s, err = parseUint(s[1:], 3, 999, "millisecond")
182+
if err != nil {
183+
return Datetime{}, err
160184
}
161-
162-
milli = 100*int(rune(s[20])-'0') + 10*int(rune(s[21])-'0') + int(rune(s[22])-'0')
163-
trailerOffset = 23
164185
}
165186

166-
if length == trailerOffset {
167-
return Datetime{}, fmt.Errorf("%w: expected time zone designator", errDatetime)
187+
if len(s) == 0 {
188+
return Datetime{}, fmt.Errorf("%w: unexpected EOF", errDatetime)
168189
}
169190

170-
// At this point, we can only have 2 possible lengths. Anything else is an error.
171-
switch s[trailerOffset] {
191+
switch s[0] {
172192
case 'Z':
173-
if length > trailerOffset+1 {
174-
// If something comes after the Z, it's an error
175-
return Datetime{}, fmt.Errorf("%w: unexpected trailer after time zone designator", errDatetime)
176-
}
193+
s = s[1:]
177194
case '+', '-':
178195
sign := 1
179-
if s[trailerOffset] == '-' {
196+
if s[0] == '-' {
180197
sign = -1
181198
}
199+
s = s[1:]
182200

183-
if length > trailerOffset+5 {
184-
return Datetime{}, fmt.Errorf("%w: unexpected trailer after time zone designator", errDatetime)
185-
} else if length != trailerOffset+5 {
186-
return Datetime{}, fmt.Errorf("%w: invalid time zone offset", errDatetime)
187-
}
188-
189-
// get the time zone offset hhmm.
190-
if !unicode.IsDigit(rune(s[trailerOffset+1])) || !unicode.IsDigit(rune(s[trailerOffset+2])) || !unicode.IsDigit(rune(s[trailerOffset+3])) || !unicode.IsDigit(rune(s[trailerOffset+4])) {
191-
return Datetime{}, fmt.Errorf("%w: invalid time zone offset", errDatetime)
201+
var hh uint
202+
if hh, s, err = parseUint(s, 2, 23, "offset hours"); err != nil {
203+
return Datetime{}, err
192204
}
193205

194-
hh := time.Duration(10*int64(rune(s[trailerOffset+1])-'0') + int64(rune(s[trailerOffset+2])-'0'))
195-
mm := time.Duration(10*int64(rune(s[trailerOffset+3])-'0') + int64(rune(s[trailerOffset+4])-'0'))
196-
197-
if hh > 23 {
198-
return Datetime{}, fmt.Errorf("%w: time zone offset hours are out of range", errDatetime)
199-
}
200-
if mm > 59 {
201-
return Datetime{}, fmt.Errorf("%w: time zone offset minutes are out of range", errDatetime)
206+
var mm uint
207+
if mm, s, err = parseUint(s, 2, 59, "offset minutes"); err != nil {
208+
return Datetime{}, err
202209
}
203210

204-
offset = time.Duration(sign) * ((hh * time.Hour) + (mm * time.Minute))
205-
211+
offset = time.Duration(sign) * ((time.Duration(hh) * time.Hour) + (time.Duration(mm) * time.Minute))
206212
default:
207213
return Datetime{}, fmt.Errorf("%w: invalid time zone designator", errDatetime)
208214
}
209215

210-
t := time.Date(year, time.Month(month), day,
211-
hour, minute, second,
212-
int(time.Duration(milli)*time.Millisecond), time.UTC)
216+
if len(s) > 0 {
217+
return Datetime{}, fmt.Errorf("%w: unexpected additional characters", errDatetime)
218+
}
213219

214-
// Don't allow wrapping: https://github.com/cedar-policy/rfcs/pull/94, which can occur
215-
// because not all months have 31 days, which is our validation range
216-
_, tmonth, tday := t.Date()
217-
if time.Month(month) != tmonth || day != tday {
218-
return Datetime{}, fmt.Errorf("%w: invalid date", errDatetime)
220+
t := time.Date(year, time.Month(month), int(day),
221+
int(hour), int(minute), int(second),
222+
int(time.Duration(milli)*time.Millisecond), time.UTC).Add(-offset)
223+
224+
// Check for boundary conditions before calling UnixMilli(), which has undefined behavior outside of these
225+
// boundaries
226+
if t.Before(minDatetime) || t.After(maxDatetime) {
227+
return Datetime{}, fmt.Errorf("%w: timestamp out of range", errDatetime)
219228
}
220229

221-
t = t.Add(-offset)
222230
return Datetime{value: t.UnixMilli()}, nil
223231
}
224232

@@ -239,7 +247,7 @@ func (d Datetime) LessThan(bi Value) (bool, error) {
239247
return d.value < b.value, nil
240248
}
241249

242-
// LessThan returns true if value is less than or equal to the
250+
// LessThanOrEqual returns true if value is less than or equal to the
243251
// argument and they are both Datetime values, or an error indicating
244252
// they aren't comparable otherwise
245253
func (d Datetime) LessThanOrEqual(bi Value) (bool, error) {
@@ -256,9 +264,28 @@ func (d Datetime) MarshalCedar() []byte {
256264
return []byte(`datetime("` + d.String() + `")`)
257265
}
258266

259-
// String returns an ISO 8601 millisecond precision timestamp
267+
// String returns an ISO 8601 millisecond precision timestamp.
268+
// For years in [0000, 9999], returns RFC 3339 format: "YYYY-MM-DDThh:mm:ss.SSSZ"
269+
// For years outside that range, returns expanded year format: "(+/-)YYYYYYYYY-MM-DDThh:mm:ss.SSSZ"
260270
func (d Datetime) String() string {
261-
return time.UnixMilli(d.value).UTC().Format("2006-01-02T15:04:05.000Z")
271+
t := time.UnixMilli(d.value).UTC()
272+
year := t.Year()
273+
274+
// Use RFC 3339 format for years in standard range
275+
if year >= 0 && year <= 9999 {
276+
return t.Format("2006-01-02T15:04:05.000Z")
277+
}
278+
279+
// Use ISO 8601 expanded year format for years outside standard range
280+
sign := '+'
281+
if year < 0 {
282+
sign = '-'
283+
year = -year
284+
}
285+
286+
return fmt.Sprintf("%c%09d-%02d-%02dT%02d:%02d:%02d.%03dZ",
287+
sign, year, t.Month(), t.Day(),
288+
t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6)
262289
}
263290

264291
// UnmarshalJSON implements encoding/json.Unmarshaler for Datetime

0 commit comments

Comments
 (0)