Skip to content

Commit d0a1128

Browse files
dsnetromaindoumenc
authored andcommitted
time: implement strict RFC 3339 during marshal and unmarshal
We add strict checking to marshal and unmarshal methods, rather than Parse to maintain compatibility in Parse behavior. Also, the Time.Format method has no ability to report errors. The Time.Marshal{Text,JSON} and Time.Unmarshal{Time,JSON} methods are already documented as complying with RFC 3339, but have edge cases on both marshal and unmarshal where it is incorrect. The Marshal methods already have at least one check to comply with RFC 3339, so it seems sensible to expand this to cover all known violations of the specification. This commit fixes all known edge cases for full compliance. Two optimizations are folded into this change: 1. parseRFC3339 is made generic so that it can operate directly on a []byte as well as string. This avoids allocating or redundant copying when converting from string to []byte. 2. When marshaling, we verify for correctness based on the serialized output, rather than calling attribute methods on the Time type. For example, it is faster to check that the 5th byte is '-' rather than check that Time.Year is within [0,9999], since Year is a relatively expensive operation. Performance: name old time/op new time/op delta MarshalJSON 109ns ± 2% 99ns ± 1% -9.43% (p=0.000 n=10+10) UnmarshalText 158ns ± 4% 143ns ± 1% -9.17% (p=0.000 n=9+9) Updates golang#54580 Updates golang#54568 Updates golang#54571 Change-Id: I1222e45a7625d1ffd0629be5738670a84188d301 Reviewed-on: https://go-review.googlesource.com/c/go/+/444277 Reviewed-by: David Chase <[email protected]> Reviewed-by: Ian Lance Taylor <[email protected]> Run-TryBot: Joseph Tsai <[email protected]> TryBot-Result: Gopher Robot <[email protected]>
1 parent 4730a9d commit d0a1128

File tree

5 files changed

+147
-52
lines changed

5 files changed

+147
-52
lines changed

src/time/export_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,4 @@ var Quote = quote
136136
var AppendFormatAny = Time.appendFormat
137137
var AppendFormatRFC3339 = Time.appendFormatRFC3339
138138
var ParseAny = parse
139-
var ParseRFC3339 = parseRFC3339
139+
var ParseRFC3339 = parseRFC3339[string]

src/time/format.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -427,15 +427,15 @@ func appendInt(b []byte, x int, width int) []byte {
427427
var atoiError = errors.New("time: invalid number")
428428

429429
// Duplicates functionality in strconv, but avoids dependency.
430-
func atoi(s string) (x int, err error) {
430+
func atoi[bytes []byte | string](s bytes) (x int, err error) {
431431
neg := false
432-
if s != "" && (s[0] == '-' || s[0] == '+') {
432+
if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
433433
neg = s[0] == '-'
434434
s = s[1:]
435435
}
436436
q, rem, err := leadingInt(s)
437437
x = int(q)
438-
if err != nil || rem != "" {
438+
if err != nil || len(rem) > 0 {
439439
return 0, atoiError
440440
}
441441
if neg {
@@ -880,7 +880,7 @@ func (e *ParseError) Error() string {
880880
}
881881

882882
// isDigit reports whether s[i] is in range and is a decimal digit.
883-
func isDigit(s string, i int) bool {
883+
func isDigit[bytes []byte | string](s bytes, i int) bool {
884884
if len(s) <= i {
885885
return false
886886
}
@@ -1474,7 +1474,7 @@ func commaOrPeriod(b byte) bool {
14741474
return b == '.' || b == ','
14751475
}
14761476

1477-
func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string, err error) {
1477+
func parseNanoseconds[bytes []byte | string](value bytes, nbytes int) (ns int, rangeErrString string, err error) {
14781478
if !commaOrPeriod(value[0]) {
14791479
err = errBad
14801480
return
@@ -1502,7 +1502,7 @@ func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string,
15021502
var errLeadingInt = errors.New("time: bad [0-9]*") // never printed
15031503

15041504
// leadingInt consumes the leading [0-9]* from s.
1505-
func leadingInt(s string) (x uint64, rem string, err error) {
1505+
func leadingInt[bytes []byte | string](s bytes) (x uint64, rem bytes, err error) {
15061506
i := 0
15071507
for ; i < len(s); i++ {
15081508
c := s[i]
@@ -1511,12 +1511,12 @@ func leadingInt(s string) (x uint64, rem string, err error) {
15111511
}
15121512
if x > 1<<63/10 {
15131513
// overflow
1514-
return 0, "", errLeadingInt
1514+
return 0, rem, errLeadingInt
15151515
}
15161516
x = x*10 + uint64(c) - '0'
15171517
if x > 1<<63 {
15181518
// overflow
1519-
return 0, "", errLeadingInt
1519+
return 0, rem, errLeadingInt
15201520
}
15211521
}
15221522
return x, s[i:], nil

src/time/format_rfc3339.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
package time
66

7+
import "errors"
8+
79
// RFC 3339 is the most commonly used format.
810
//
911
// It is implicitly used by the Time.(Marshal|Unmarshal)(Text|JSON) methods.
@@ -57,13 +59,33 @@ func (t Time) appendFormatRFC3339(b []byte, nanos bool) []byte {
5759
return b
5860
}
5961

60-
func parseRFC3339(s string, local *Location) (Time, bool) {
62+
func (t Time) appendStrictRFC3339(b []byte) ([]byte, error) {
63+
n0 := len(b)
64+
b = t.appendFormatRFC3339(b, true)
65+
66+
// Not all valid Go timestamps can be serialized as valid RFC 3339.
67+
// Explicitly check for these edge cases.
68+
// See https://go.dev/issue/4556 and https://go.dev/issue/54580.
69+
num2 := func(b []byte) byte { return 10*(b[0]-'0') + (b[1] - '0') }
70+
switch {
71+
case b[n0+len("9999")] != '-': // year must be exactly 4 digits wide
72+
return b, errors.New("year outside of range [0,9999]")
73+
case b[len(b)-1] != 'Z':
74+
c := b[len(b)-len("Z07:00")]
75+
if ('0' <= c && c <= '9') || num2(b[len(b)-len("07:00"):]) >= 24 {
76+
return b, errors.New("timezone hour outside of range [0,23]")
77+
}
78+
}
79+
return b, nil
80+
}
81+
82+
func parseRFC3339[bytes []byte | string](s bytes, local *Location) (Time, bool) {
6183
// parseUint parses s as an unsigned decimal integer and
6284
// verifies that it is within some range.
6385
// If it is invalid or out-of-range,
6486
// it sets ok to false and returns the min value.
6587
ok := true
66-
parseUint := func(s string, min, max int) (x int) {
88+
parseUint := func(s bytes, min, max int) (x int) {
6789
for _, c := range []byte(s) {
6890
if c < '0' || '9' < c {
6991
ok = false
@@ -105,7 +127,7 @@ func parseRFC3339(s string, local *Location) (Time, bool) {
105127

106128
// Parse the time zone.
107129
t := Date(year, Month(month), day, hour, min, sec, nsec, UTC)
108-
if s != "Z" {
130+
if len(s) != 1 || s[0] != 'Z' {
109131
if len(s) != len("-07:00") {
110132
return Time{}, false
111133
}
@@ -129,3 +151,33 @@ func parseRFC3339(s string, local *Location) (Time, bool) {
129151
}
130152
return t, true
131153
}
154+
155+
func parseStrictRFC3339(b []byte) (Time, error) {
156+
t, ok := parseRFC3339(b, Local)
157+
if !ok {
158+
if _, err := Parse(RFC3339, string(b)); err != nil {
159+
return Time{}, err
160+
}
161+
162+
// The parse template syntax cannot correctly validate RFC 3339.
163+
// Explicitly check for cases that Parse is unable to validate for.
164+
// See https://go.dev/issue/54580.
165+
num2 := func(b []byte) byte { return 10*(b[0]-'0') + (b[1] - '0') }
166+
switch {
167+
case b[len("2006-01-02T")+1] == ':': // hour must be two digits
168+
return Time{}, &ParseError{RFC3339, string(b), "15", string(b[len("2006-01-02T"):][:1]), ""}
169+
case b[len("2006-01-02T15:04:05")] == ',': // sub-second separator must be a period
170+
return Time{}, &ParseError{RFC3339, string(b), ".", ",", ""}
171+
case b[len(b)-1] != 'Z':
172+
switch {
173+
case num2(b[len(b)-len("07:00"):]) >= 24: // timezone hour must be in range
174+
return Time{}, &ParseError{RFC3339, string(b), "Z07:00", string(b[len(b)-len("Z07:00"):]), ": timezone hour out of range"}
175+
case num2(b[len(b)-len("00"):]) >= 60: // timezone minute must be in range
176+
return Time{}, &ParseError{RFC3339, string(b), "Z07:00", string(b[len(b)-len("Z07:00"):]), ": timezone minute out of range"}
177+
}
178+
default: // unknown error; should not occur
179+
return Time{}, &ParseError{RFC3339, string(b), RFC3339, string(b), ""}
180+
}
181+
}
182+
return t, nil
183+
}

src/time/time.go

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,51 +1337,54 @@ func (t *Time) GobDecode(data []byte) error {
13371337
}
13381338

13391339
// MarshalJSON implements the json.Marshaler interface.
1340-
// The time is a quoted string in RFC 3339 format, with sub-second precision added if present.
1340+
// The time is a quoted string in the RFC 3339 format with sub-second precision.
1341+
// If the timestamp cannot be represented as valid RFC 3339
1342+
// (e.g., the year is out of range), then an error is reported.
13411343
func (t Time) MarshalJSON() ([]byte, error) {
1342-
if y := t.Year(); y < 0 || y >= 10000 {
1343-
// RFC 3339 is clear that years are 4 digits exactly.
1344-
// See golang.org/issue/4556#c15 for more discussion.
1345-
return nil, errors.New("Time.MarshalJSON: year outside of range [0,9999]")
1346-
}
1347-
1348-
b := make([]byte, 0, len(RFC3339Nano)+2)
1344+
b := make([]byte, 0, len(RFC3339Nano)+len(`""`))
13491345
b = append(b, '"')
1350-
b = t.AppendFormat(b, RFC3339Nano)
1346+
b, err := t.appendStrictRFC3339(b)
13511347
b = append(b, '"')
1348+
if err != nil {
1349+
return nil, errors.New("Time.MarshalJSON: " + err.Error())
1350+
}
13521351
return b, nil
13531352
}
13541353

13551354
// UnmarshalJSON implements the json.Unmarshaler interface.
1356-
// The time is expected to be a quoted string in RFC 3339 format.
1355+
// The time must be a quoted string in the RFC 3339 format.
13571356
func (t *Time) UnmarshalJSON(data []byte) error {
1358-
// Ignore null, like in the main JSON package.
13591357
if string(data) == "null" {
13601358
return nil
13611359
}
1362-
// Fractional seconds are handled implicitly by Parse.
1360+
// TODO(https://go.dev/issue/47353): Properly unescape a JSON string.
1361+
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
1362+
return errors.New("Time.UnmarshalJSON: input is not a JSON string")
1363+
}
1364+
data = data[len(`"`) : len(data)-len(`"`)]
13631365
var err error
1364-
*t, err = Parse(`"`+RFC3339+`"`, string(data))
1366+
*t, err = parseStrictRFC3339(data)
13651367
return err
13661368
}
13671369

13681370
// MarshalText implements the encoding.TextMarshaler interface.
1369-
// The time is formatted in RFC 3339 format, with sub-second precision added if present.
1371+
// The time is formatted in RFC 3339 format with sub-second precision.
1372+
// If the timestamp cannot be represented as valid RFC 3339
1373+
// (e.g., the year is out of range), then an error is reported.
13701374
func (t Time) MarshalText() ([]byte, error) {
1371-
if y := t.Year(); y < 0 || y >= 10000 {
1372-
return nil, errors.New("Time.MarshalText: year outside of range [0,9999]")
1373-
}
1374-
13751375
b := make([]byte, 0, len(RFC3339Nano))
1376-
return t.AppendFormat(b, RFC3339Nano), nil
1376+
b, err := t.appendStrictRFC3339(b)
1377+
if err != nil {
1378+
return nil, errors.New("Time.MarshalText: " + err.Error())
1379+
}
1380+
return b, nil
13771381
}
13781382

13791383
// UnmarshalText implements the encoding.TextUnmarshaler interface.
1380-
// The time is expected to be in RFC 3339 format.
1384+
// The time must be in the RFC 3339 format.
13811385
func (t *Time) UnmarshalText(data []byte) error {
1382-
// Fractional seconds are handled implicitly by Parse.
13831386
var err error
1384-
*t, err = Parse(RFC3339, string(data))
1387+
*t, err = parseStrictRFC3339(data)
13851388
return err
13861389
}
13871390

src/time/time_test.go

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,7 @@ var jsonTests = []struct {
804804
{Date(9999, 4, 12, 23, 20, 50, 520*1e6, UTC), `"9999-04-12T23:20:50.52Z"`},
805805
{Date(1996, 12, 19, 16, 39, 57, 0, Local), `"1996-12-19T16:39:57-08:00"`},
806806
{Date(0, 1, 1, 0, 0, 0, 1, FixedZone("", 1*60)), `"0000-01-01T00:00:00.000000001+00:01"`},
807+
{Date(2020, 1, 1, 0, 0, 0, 0, FixedZone("", 23*60*60+59*60)), `"2020-01-01T00:00:00+23:59"`},
807808
}
808809

809810
func TestTimeJSON(t *testing.T) {
@@ -822,28 +823,67 @@ func TestTimeJSON(t *testing.T) {
822823
}
823824
}
824825

825-
func TestInvalidTimeJSON(t *testing.T) {
826-
var tt Time
827-
err := json.Unmarshal([]byte(`{"now is the time":"buddy"}`), &tt)
828-
_, isParseErr := err.(*ParseError)
829-
if !isParseErr {
830-
t.Errorf("expected *time.ParseError unmarshaling JSON, got %v", err)
826+
func TestUnmarshalInvalidTimes(t *testing.T) {
827+
tests := []struct {
828+
in string
829+
want string
830+
}{
831+
{`{}`, "Time.UnmarshalJSON: input is not a JSON string"},
832+
{`[]`, "Time.UnmarshalJSON: input is not a JSON string"},
833+
{`"2000-01-01T1:12:34Z"`, `parsing time "2000-01-01T1:12:34Z" as "2006-01-02T15:04:05Z07:00": cannot parse "1" as "15"`},
834+
{`"2000-01-01T00:00:00,000Z"`, `parsing time "2000-01-01T00:00:00,000Z" as "2006-01-02T15:04:05Z07:00": cannot parse "," as "."`},
835+
{`"2000-01-01T00:00:00+24:00"`, `parsing time "2000-01-01T00:00:00+24:00": timezone hour out of range`},
836+
{`"2000-01-01T00:00:00+00:60"`, `parsing time "2000-01-01T00:00:00+00:60": timezone minute out of range`},
837+
{`"2000-01-01T00:00:00+123:45"`, `parsing time "2000-01-01T00:00:00+123:45" as "2006-01-02T15:04:05Z07:00": cannot parse "+123:45" as "Z07:00"`},
838+
}
839+
840+
for _, tt := range tests {
841+
var ts Time
842+
843+
want := tt.want
844+
err := json.Unmarshal([]byte(tt.in), &ts)
845+
if err == nil || err.Error() != want {
846+
t.Errorf("Time.UnmarshalJSON(%s) = %v, want %v", tt.in, err, want)
847+
}
848+
849+
if strings.HasPrefix(tt.in, `"`) && strings.HasSuffix(tt.in, `"`) {
850+
err = ts.UnmarshalText([]byte(strings.Trim(tt.in, `"`)))
851+
if err == nil || err.Error() != want {
852+
t.Errorf("Time.UnmarshalText(%s) = %v, want %v", tt.in, err, want)
853+
}
854+
}
831855
}
832856
}
833857

834-
var notJSONEncodableTimes = []struct {
835-
time Time
836-
want string
837-
}{
838-
{Date(10000, 1, 1, 0, 0, 0, 0, UTC), "Time.MarshalJSON: year outside of range [0,9999]"},
839-
{Date(-1, 1, 1, 0, 0, 0, 0, UTC), "Time.MarshalJSON: year outside of range [0,9999]"},
840-
}
858+
func TestMarshalInvalidTimes(t *testing.T) {
859+
tests := []struct {
860+
time Time
861+
want string
862+
}{
863+
{Date(10000, 1, 1, 0, 0, 0, 0, UTC), "Time.MarshalJSON: year outside of range [0,9999]"},
864+
{Date(-998, 1, 1, 0, 0, 0, 0, UTC).Add(-Second), "Time.MarshalJSON: year outside of range [0,9999]"},
865+
{Date(0, 1, 1, 0, 0, 0, 0, UTC).Add(-Nanosecond), "Time.MarshalJSON: year outside of range [0,9999]"},
866+
{Date(2020, 1, 1, 0, 0, 0, 0, FixedZone("", 24*60*60)), "Time.MarshalJSON: timezone hour outside of range [0,23]"},
867+
{Date(2020, 1, 1, 0, 0, 0, 0, FixedZone("", 123*60*60)), "Time.MarshalJSON: timezone hour outside of range [0,23]"},
868+
}
869+
870+
for _, tt := range tests {
871+
want := tt.want
872+
b, err := tt.time.MarshalJSON()
873+
switch {
874+
case b != nil:
875+
t.Errorf("(%v).MarshalText() = %q, want nil", tt.time, b)
876+
case err == nil || err.Error() != want:
877+
t.Errorf("(%v).MarshalJSON() error = %v, want %v", tt.time, err, want)
878+
}
841879

842-
func TestNotJSONEncodableTime(t *testing.T) {
843-
for _, tt := range notJSONEncodableTimes {
844-
_, err := tt.time.MarshalJSON()
845-
if err == nil || err.Error() != tt.want {
846-
t.Errorf("%v MarshalJSON error = %v, want %v", tt.time, err, tt.want)
880+
want = strings.ReplaceAll(tt.want, "JSON", "Text")
881+
b, err = tt.time.MarshalText()
882+
switch {
883+
case b != nil:
884+
t.Errorf("(%v).MarshalText() = %q, want nil", tt.time, b)
885+
case err == nil || err.Error() != want:
886+
t.Errorf("(%v).MarshalText() error = %v, want %v", tt.time, err, want)
847887
}
848888
}
849889
}

0 commit comments

Comments
 (0)