fix(parse): reject the lenient grammar edges and name out-of-range date-times

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-22 21:15:00 +02:00
parent 8f2b26bd33
commit 2efdb2d059
3 changed files with 212 additions and 36 deletions
+32 -15
View File
@@ -79,7 +79,9 @@ func clockString(t time.Time) string {
// offsetString renders an offset date-time, the fourth TOML kind, in the same
// shape: no zero seconds, no trailing zeros in the fraction, and the offset
// written as "Z" when it is zero.
// written as "Z" when it is zero. A zone offset that is not a whole number of
// minutes loses its seconds to this rendering, which is why Marshal refuses
// such a value rather than writing it.
func offsetString(t time.Time) string {
buf := t.AppendFormat(make([]byte, 0, 32), "2006-01-02T")
buf = appendClock(buf, t)
@@ -235,17 +237,21 @@ func normaliseDateTimeToken(tok string, kind dateTimeKind) string {
// parseDateTime classifies and parses a bare token as a TOML date-time value.
// It returns the decoded value (OffsetDateTime, LocalDateTime, LocalDate or
// LocalTime) and whether the token was a date-time at all.
func parseDateTime(tok string) (any, bool) {
// LocalTime), whether the token was a date-time at all, and an error for a
// token whose shape is a date-time a component of which lies outside its
// range: an hour of 24, a day the month does not hold. Such a token is a
// broken date-time, not some other value, so the error names it instead of
// leaving it to the number decoder's complaint.
func parseDateTime(tok string) (any, bool, error) {
if tok == "" || tok[0] < '0' || tok[0] > '9' {
return nil, false
return nil, false, nil
}
if !strings.ContainsAny(tok, "-:") {
return nil, false
return nil, false, nil
}
kind, seconds := scanDateTimeShape(tok)
if kind == dateTimeNone {
return nil, false
return nil, false, nil
}
norm := normaliseDateTimeToken(tok, kind)
switch kind {
@@ -256,7 +262,7 @@ func parseDateTime(tok string) (any, bool) {
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
return nil, false, fmt.Errorf("invalid date-time %q", tok)
}
// A zero offset carries its own anonymous location from time.Parse,
// while the written form is "Z" either way; normalising to UTC keeps
@@ -264,7 +270,7 @@ func parseDateTime(tok string) (any, bool) {
if _, off := t.Zone(); off == 0 {
t = t.In(time.UTC)
}
return OffsetDateTime{t}, true
return OffsetDateTime{t}, true, nil
case dateTimeLocal:
layout := localClockLayout
if seconds {
@@ -272,15 +278,15 @@ func parseDateTime(tok string) (any, bool) {
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
return nil, false, fmt.Errorf("invalid date-time %q", tok)
}
return LocalDateTime{t}, true
return LocalDateTime{t}, true, nil
case dateTimeDate:
t, err := time.Parse(localDateOnlyLayout, norm)
if err != nil {
return nil, false
return nil, false, fmt.Errorf("invalid date-time %q", tok)
}
return LocalDate{t}, true
return LocalDate{t}, true, nil
case dateTimeClock:
layout := localTimeClockLayout
if seconds {
@@ -288,11 +294,22 @@ func parseDateTime(tok string) (any, bool) {
}
t, err := time.Parse(layout, norm)
if err != nil {
return nil, false
return nil, false, fmt.Errorf("invalid date-time %q", tok)
}
return LocalTime{t}, true
return LocalTime{t}, true, nil
}
return nil, false
return nil, false, nil
}
// wholeMinuteOffset reports an error when the zone offset carries seconds, a
// shape no TOML offset can hold: writing only the minutes would silently
// shift the instant on the way back, so the encoder refuses the value rather
// than corrupting it.
func wholeMinuteOffset(t time.Time) error {
if _, off := t.Zone(); off%60 != 0 {
return fmt.Errorf("interpres: date-time offset of %d seconds is not a whole number of minutes, which TOML cannot write", off)
}
return nil
}
// isDateToken reports whether s is exactly a YYYY-MM-DD date, used to detect a