@@ -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).
2226func 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)
4289func 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
245245func (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"
260262func (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