Skip to content

Commit f699039

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 f699039

File tree

2 files changed

+276
-184
lines changed

2 files changed

+276
-184
lines changed

types/datetime.go

Lines changed: 146 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ type Datetime struct {
1818
value int64
1919
}
2020

21-
// NewDatetime returns a Cedar Datetime from a Go time.Time value
21+
// NewDatetime returns a Cedar Datetime from a Go time.Time value.
22+
//
23+
// The provided time.Time is truncated to millisecond precision. The result is
24+
// undefined if the Unix time in milliseconds cannot be represented by an int64
25+
// (a date more than 292 million years before or after 1970).
2226
func NewDatetime(t time.Time) Datetime {
2327
return Datetime{value: t.UnixMilli()}
2428
}
@@ -29,6 +33,41 @@ func NewDatetimeFromMillis(ms int64) Datetime {
2933
return Datetime{value: ms}
3034
}
3135

36+
func eatRune(s string, c uint8) (string, error) {
37+
if len(s) == 0 {
38+
return "", fmt.Errorf("%w: unexpected EOF", errDatetime)
39+
} else if s[0] != c {
40+
return "", fmt.Errorf("%w: unexpected character %c", errDatetime, s[0])
41+
}
42+
return s[1:], nil
43+
}
44+
45+
func parseUint(s string, chars int, maxValue uint, label string) (uint, string, error) {
46+
if len(s) < chars {
47+
return 0, "", fmt.Errorf("%w: unexpected EOF", errDatetime)
48+
}
49+
v, err := strconv.ParseUint(s[0:chars], 10, 0)
50+
if err != nil {
51+
return 0, "", fmt.Errorf("%w: invalid %v", errDatetime, label)
52+
} else if v > uint64(maxValue) {
53+
return 0, "", fmt.Errorf("%w: %v is greater than %v", errDatetime, label, maxValue)
54+
}
55+
return uint(v), s[chars:], nil
56+
}
57+
58+
// checkValidDay ensures that the given day is valid for the given month in the given year.
59+
func checkValidDay(year int, month, day uint) error {
60+
t := time.Date(year, time.Month(month), int(day), 0, 0, 0, 0, time.UTC)
61+
62+
// Don't allow wrapping: https://github.com/cedar-policy/rfcs/pull/94
63+
_, tmonth, tday := t.Date()
64+
if time.Month(month) != tmonth || int(day) != tday {
65+
return fmt.Errorf("%w: invalid date", errDatetime)
66+
}
67+
68+
return nil
69+
}
70+
3271
// ParseDatetime returns a Cedar datetime when the argument provided
3372
// represents a compatible datetime or an error
3473
//
@@ -39,186 +78,147 @@ func NewDatetimeFromMillis(ms int64) Datetime {
3978
// - "YYYY-MM-DDThh:mm:ss.SSSZ" (date and time with millisecond, UTC)
4079
// - "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (date and time, time zone offset)
4180
// - "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (date and time with millisecond, time zone offset)
81+
//
82+
// Cedar RFC 110 extends this with ISO 8601 expanded year format:
83+
//
84+
// - "(+/-)YYYYYYYYY-MM-DD" (9-digit year, date only)
85+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ssZ" (9-digit year, date and time, UTC)
86+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ss.SSSZ" (9-digit year with millisecond, UTC)
87+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ss(+/-)hhmm" (9-digit year with time zone offset)
88+
// - "(+/-)YYYYYYYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (9-digit year with millisecond and offset)
4289
func ParseDatetime(s string) (Datetime, error) {
4390
var (
44-
year, month, day, hour, minute, second, milli int
45-
offset time.Duration
91+
year int
92+
month, day, hour, minute, second, milli uint
93+
offset time.Duration
4694
)
4795

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

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])) {
100+
// Check if this is an expanded year format (starts with + or -)
101+
yearSign := 1
102+
yearLength := 4
103+
yearMax := uint(9999)
104+
if s[0] == '+' || s[0] == '-' {
105+
yearLength = 9
106+
yearMax = 999999999
107+
if s[0] == '-' {
108+
yearSign = -1
109+
}
110+
s = s[1:]
111+
} else if !unicode.IsDigit(rune(s[0])) {
60112
return Datetime{}, fmt.Errorf("%w: invalid year", errDatetime)
61113
}
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')
66114

67-
if s[4] != '-' {
68-
return Datetime{}, fmt.Errorf("%w: unexpected character %s", errDatetime, strconv.QuoteRune(rune(s[4])))
115+
absYear, s, err := parseUint(s[0:], yearLength, yearMax, "year")
116+
if err != nil {
117+
return Datetime{}, err
69118
}
119+
year = int(absYear) * yearSign
70120

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)
121+
if s, err = eatRune(s, '-'); err != nil {
122+
return Datetime{}, err
78123
}
79124

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

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)
129+
if s, err = eatRune(s, '-'); err != nil {
130+
return Datetime{}, err
91131
}
92132

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
133+
if day, s, err = parseUint(s, 2, 31, "day"); err != nil {
134+
return Datetime{}, err
97135
}
98136

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)
137+
if err = checkValidDay(year, month, day); err != nil {
138+
return Datetime{}, err
102139
}
103140

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])))
141+
if len(s) == 0 {
142+
return Datetime{time.Date(year, time.Month(month), int(day), 0, 0, 0, 0, time.UTC).UnixMilli()}, nil
114143
}
115144

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)
145+
if s, err = eatRune(s, 'T'); err != nil {
146+
return Datetime{}, err
122147
}
123148

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

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)
153+
if s, err = eatRune(s, ':'); err != nil {
154+
return Datetime{}, err
134155
}
135156

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

140-
if !unicode.IsDigit(rune(s[17])) || !unicode.IsDigit(rune(s[18])) {
141-
return Datetime{}, fmt.Errorf("%w: invalid second", errDatetime)
161+
if s, err = eatRune(s, ':'); err != nil {
162+
return Datetime{}, err
142163
}
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)
164+
165+
if second, s, err = parseUint(s, 2, 59, "second"); err != nil {
166+
return Datetime{}, err
146167
}
147168

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-
}
169+
if len(s) == 0 {
170+
return Datetime{}, fmt.Errorf("%w: unexpected EOF", errDatetime)
171+
}
157172

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)
173+
// Parse optional milliseconds
174+
if s[0] == '.' {
175+
milli, s, err = parseUint(s[1:], 3, 999, "millisecond")
176+
if err != nil {
177+
return Datetime{}, err
160178
}
161-
162-
milli = 100*int(rune(s[20])-'0') + 10*int(rune(s[21])-'0') + int(rune(s[22])-'0')
163-
trailerOffset = 23
164179
}
165180

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

170-
// At this point, we can only have 2 possible lengths. Anything else is an error.
171-
switch s[trailerOffset] {
185+
switch s[0] {
172186
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-
}
187+
s = s[1:]
177188
case '+', '-':
178189
sign := 1
179-
if s[trailerOffset] == '-' {
190+
if s[0] == '-' {
180191
sign = -1
181192
}
193+
s = s[1:]
182194

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)
195+
var hh uint
196+
if hh, s, err = parseUint(s, 2, 23, "offset hours"); err != nil {
197+
return Datetime{}, err
187198
}
188199

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)
200+
var mm uint
201+
if mm, s, err = parseUint(s, 2, 59, "offset minutes"); err != nil {
202+
return Datetime{}, err
192203
}
193204

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)
202-
}
203-
204-
offset = time.Duration(sign) * ((hh * time.Hour) + (mm * time.Minute))
205-
205+
offset = time.Duration(sign) * ((time.Duration(hh) * time.Hour) + (time.Duration(mm) * time.Minute))
206206
default:
207207
return Datetime{}, fmt.Errorf("%w: invalid time zone designator", errDatetime)
208208
}
209209

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

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)
214+
t := time.Date(year, time.Month(month), int(day),
215+
int(hour), int(minute), int(second),
216+
int(time.Duration(milli)*time.Millisecond), time.UTC).Add(-offset)
217+
218+
if !time.UnixMilli(t.UnixMilli()).Equal(t) {
219+
return Datetime{}, fmt.Errorf("%w: timestamp out of range", errDatetime)
219220
}
220221

221-
t = t.Add(-offset)
222222
return Datetime{value: t.UnixMilli()}, nil
223223
}
224224

@@ -239,7 +239,7 @@ func (d Datetime) LessThan(bi Value) (bool, error) {
239239
return d.value < b.value, nil
240240
}
241241

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

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

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

0 commit comments

Comments
 (0)