@@ -12,13 +12,23 @@ import (
1212
1313var 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
1622type 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).
2232func 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)
4295func 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
245253func (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"
260270func (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