feat: add OffsetDateTime, nesting limits and uniform Marshaler dispatch
Test / test (push) Successful in 2m18s

Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
2026-09-19 19:36:24 +02:00
parent b695b69768
commit a6e3e3fe31
12 changed files with 631 additions and 117 deletions
+19
View File
@@ -28,6 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
at most `threshold` bytes is written as an inline table instead of a header at most `threshold` bytes is written as an inline table instead of a header
section, which shortens a document of small tables. An array of tables keeps section, which shortens a document of small tables. An array of tables keeps
its header form, because its inline form would re-parse as a value array. its header form, because its inline form would re-parse as a value array.
- `OffsetDateTime`, the Go type of the offset date-time kind, so that all four
TOML date-time kinds have one of their own. `Parse` and `Unmarshal` hand it
back where they produced a bare `time.Time` before, and `Marshal` accepts it.
Unmarshalling into a struct field of type `time.Time` keeps working, because
the plain type takes an offset date-time as it always did; code that asserts
the tree's type, and `UnmarshalTOML` implementations that expect a
`time.Time`, need the new type.
- `Decoder.MaxDepth(depth)` and `Decoder.MaxInputSize(size)` bound the parse a
`Decode` performs, and every parse carries a nesting limit in any case
(10000 levels, which no hand-written document approaches): a document that
nests arrays or inline tables deeper used to run the stack out and is now
rejected with a `SyntaxError` naming the limit.
### Changed ### Changed
@@ -40,6 +52,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- An inline table that would pass the hundredth column is written across lines - An inline table that would pass the hundredth column is written across lines
with a trailing comma and one tab of indentation per nesting level, the shape with a trailing comma and one tab of indentation per nesting level, the shape
TOML 1.1 allows an inline table to take. TOML 1.1 allows an inline table to take.
- `MarshalTOML` reaches every array element and every field, whatever the Go
kind, and its result is normalised like any other value: an element rendering
itself as a table keeps the `[[header]]` form, one rendering itself as a
scalar turns the array into a value array, and the method runs once per
element. It is found on the addressable pointer as well, so a
pointer-receiver `MarshalTOML` is called for a field or an element, exactly
as `MarshalText` is.
- TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance - TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance
suite runs the 1.1 corpus alone, and the promise that every 1.0 document suite runs the 1.1 corpus alone, and the promise that every 1.0 document
parses exactly as before is withdrawn. Nothing that parses today stops parses exactly as before is withdrawn. Nothing that parses today stops
+7 -2
View File
@@ -285,8 +285,11 @@ func typeMatches(typ string, v any) bool {
_, ok := v.(bool) _, ok := v.(bool)
return ok return ok
case "datetime": case "datetime":
_, ok := v.(time.Time) switch v.(type) {
return ok case time.Time, interpres.OffsetDateTime:
return true
}
return false
case "datetime-local": case "datetime-local":
_, ok := v.(interpres.LocalDateTime) _, ok := v.(interpres.LocalDateTime)
return ok return ok
@@ -346,6 +349,8 @@ func tag(v any) (any, error) {
return tagged("float", formatFloat(x)), nil return tagged("float", formatFloat(x)), nil
case time.Time: case time.Time:
return tagged("datetime", x.Format(time.RFC3339Nano)), nil return tagged("datetime", x.Format(time.RFC3339Nano)), nil
case interpres.OffsetDateTime:
return tagged("datetime", x.Format(time.RFC3339Nano)), nil
case interpres.LocalDateTime: case interpres.LocalDateTime:
return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999")), nil return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999")), nil
case interpres.LocalDate: case interpres.LocalDate:
+1 -1
View File
@@ -373,7 +373,7 @@ func TestRunEncoderRejectsBadInput(t *testing.T) {
{"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"}, {"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"},
{"untagged-scalar", `{"x": 1}`, "unsupported JSON value"}, {"untagged-scalar", `{"x": 1}`, "unsupported JSON value"},
{"literal-mismatch", `{"x": {"type": "integer", "value": "1.5"}}`, "parsed as float64"}, {"literal-mismatch", `{"x": {"type": "integer", "value": "1.5"}}`, "parsed as float64"},
{"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as time.Time"}, {"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as interpres.OffsetDateTime"},
{"bad-literal", `{"x": {"type": "date-local", "value": "nope"}}`, "date-local"}, {"bad-literal", `{"x": {"type": "date-local", "value": "nope"}}`, "date-local"},
{"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"}, {"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"},
} }
+16 -4
View File
@@ -11,9 +11,15 @@ import (
"time" "time"
) )
// TOML distinguishes four date-time kinds. interpres decodes an offset // TOML distinguishes four date-time kinds, and each has its own Go type:
// date-time to a plain time.Time (it carries a zone), and uses the wrapper // OffsetDateTime for the offset kind, and the local wrappers below for the
// types below for the local variants so callers can tell them apart. // three that carry no offset. A plain time.Time is accepted wherever an
// offset date-time is, on both the encoding and the decoding side, so a
// timestamp field does not have to name the wrapper.
// OffsetDateTime is a TOML offset date-time, e.g. 1979-05-27T07:32:00-07:00.
// The embedded time.Time is the instant, with the offset the document wrote.
type OffsetDateTime struct{ time.Time }
// LocalDateTime is a TOML local date-time with no offset, e.g. // LocalDateTime is a TOML local date-time with no offset, e.g.
// 1979-05-27T07:32:00. The embedded time.Time is in UTC. // 1979-05-27T07:32:00. The embedded time.Time is in UTC.
@@ -27,6 +33,12 @@ type LocalDate struct{ time.Time }
// The embedded time.Time uses the zero date. // The embedded time.Time uses the zero date.
type LocalTime struct{ time.Time } type LocalTime struct{ time.Time }
// String returns the TOML-canonical rendering of the offset date-time, e.g.
// "1979-05-27T07:32Z" or "1979-05-27T07:32:00-07:00". The seconds appear only
// when the value carries them, a fractional second drops its trailing zeros,
// and an offset of zero is written "Z".
func (odt OffsetDateTime) String() string { return offsetString(odt.Time) }
// String returns the TOML-canonical rendering of the local date-time, e.g. // String returns the TOML-canonical rendering of the local date-time, e.g.
// "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a // "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a
// fractional second. TOML 1.1 makes the seconds optional, so they appear only // fractional second. TOML 1.1 makes the seconds optional, so they appear only
@@ -129,7 +141,7 @@ func parseDateTime(tok string) (any, bool) {
norm := strings.ToUpper(tok) norm := strings.ToUpper(tok)
for _, layout := range offsetDateTimeLayouts { for _, layout := range offsetDateTimeLayouts {
if t, err := time.Parse(layout, norm); err == nil { if t, err := time.Parse(layout, norm); err == nil {
return t, true return OffsetDateTime{t}, true
} }
} }
for _, layout := range localDateTimeLayouts { for _, layout := range localDateTimeLayouts {
+33 -5
View File
@@ -95,12 +95,10 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
return setInt(dst, v) return setInt(dst, v)
case float64: case float64:
return setFloat(dst, v) return setFloat(dst, v)
case OffsetDateTime:
return setOffsetDateTime(v, dst)
case time.Time: case time.Time:
if dst.Type() != timeType { return setDateTime(v, dst)
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
}
dst.Set(reflect.ValueOf(v))
return nil
default: default:
rv := reflect.ValueOf(data) rv := reflect.ValueOf(data)
if rv.IsValid() && dst.Type() == rv.Type() { if rv.IsValid() && dst.Type() == rv.Type() {
@@ -235,6 +233,36 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
// --- low-level setters ----------------------------------------------------- // --- low-level setters -----------------------------------------------------
// setOffsetDateTime stores an offset date-time: in a wrapper destination as it
// is, and in a plain time.Time, which takes the instant with the offset the
// document wrote, so a timestamp field does not have to name the wrapper.
func setOffsetDateTime(v OffsetDateTime, dst reflect.Value) error {
switch dst.Type() {
case offsetDateTimeType:
dst.Set(reflect.ValueOf(v))
case timeType:
dst.Set(reflect.ValueOf(v.Time))
default:
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
}
return nil
}
// setDateTime stores a time.Time that reached the tree directly, which is the
// shape a tree built by hand carries. Dates the parser produced arrive as
// OffsetDateTime instead.
func setDateTime(v time.Time, dst reflect.Value) error {
switch dst.Type() {
case timeType:
dst.Set(reflect.ValueOf(v))
case offsetDateTimeType:
dst.Set(reflect.ValueOf(OffsetDateTime{v}))
default:
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
}
return nil
}
func setBasic(dst, val reflect.Value, kind string) error { func setBasic(dst, val reflect.Value, kind string) error {
if dst.Kind() != val.Kind() { if dst.Kind() != val.Kind() {
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type()) return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
+127
View File
@@ -951,3 +951,130 @@ func TestQuotedStringNeverBecomesDateTime(t *testing.T) {
t.Fatal("expected a quoted string to be rejected for LocalDate") t.Fatal("expected a quoted string to be rejected for LocalDate")
} }
} }
func TestDecoderMaxDepth(t *testing.T) {
deep := func(n int) []byte {
return []byte("v = " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n")
}
var cfg struct {
V any `toml:"v"`
}
if err := NewDecoder().MaxDepth(4).Decode(deep(4), &cfg); err != nil {
t.Fatalf("at the limit: %v", err)
}
err := NewDecoder().MaxDepth(4).Decode(deep(5), &cfg)
if err == nil {
t.Fatal("expected a nesting error")
}
if !strings.Contains(err.Error(), "limit of 4") {
t.Errorf("err = %v, want it to name the limit", err)
}
}
func TestDecoderMaxInputSize(t *testing.T) {
doc := []byte("v = \"ab\"\n")
var cfg struct {
V string `toml:"v"`
}
if err := NewDecoder().MaxInputSize(len(doc)).Decode(doc, &cfg); err != nil {
t.Fatalf("at the limit: %v", err)
}
err := NewDecoder().MaxInputSize(len(doc)-1).Decode(doc, &cfg)
if err == nil {
t.Fatal("expected a size error")
}
if !strings.Contains(err.Error(), "over the limit of 8") {
t.Errorf("err = %v, want it to name the limit", err)
}
// Parse carries the nesting default and no size limit.
if _, err := Parse(doc); err != nil {
t.Fatalf("parse: %v", err)
}
}
// --- OffsetDateTime --------------------------------------------------------
func TestOffsetDateTimeIsTheParsedType(t *testing.T) {
// A document's offset date-time arrives as the wrapper, and a plain
// time.Time destination still takes it, so a timestamp field needs no
// change to keep working.
in := []byte("stamp = 2026-06-26T10:00:00-07:00\n")
want := time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600))
var plain struct {
Stamp time.Time `toml:"stamp"`
}
if err := Unmarshal(in, &plain); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !plain.Stamp.Equal(want) {
t.Errorf("time.Time destination = %v, want %v", plain.Stamp, want)
}
var wrapped struct {
Stamp OffsetDateTime `toml:"stamp"`
}
if err := Unmarshal(in, &wrapped); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !wrapped.Stamp.Time.Equal(want) {
t.Errorf("OffsetDateTime destination = %v, want %v", wrapped.Stamp.Time, want)
}
if got := wrapped.Stamp.String(); got != "2026-06-26T10:00-07:00" {
t.Errorf("String() = %q, want 2026-06-26T10:00-07:00", got)
}
}
func TestOffsetDateTimeFromHandBuiltTree(t *testing.T) {
// A tree built by hand may carry a plain time.Time, which is the other
// source of the offset kind; both date-time destinations take it.
tree := map[string]any{"stamp": time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)}
want := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)
var plain struct {
Stamp time.Time `toml:"stamp"`
}
if err := newDecoder().decode(tree, &plain); err != nil {
t.Fatalf("decode: %v", err)
}
if !plain.Stamp.Equal(want) {
t.Errorf("time.Time destination = %v, want %v", plain.Stamp, want)
}
var wrapped struct {
Stamp OffsetDateTime `toml:"stamp"`
}
if err := newDecoder().decode(tree, &wrapped); err != nil {
t.Fatalf("decode: %v", err)
}
if !wrapped.Stamp.Time.Equal(want) {
t.Errorf("OffsetDateTime destination = %v, want %v", wrapped.Stamp.Time, want)
}
}
// dateKindReceiver records the Go type UnmarshalTOML was handed.
type dateKindReceiver struct{ Kind string }
func (r *dateKindReceiver) UnmarshalTOML(data any) error {
r.Kind = fmt.Sprintf("%T", data)
return nil
}
func TestUnmarshalerReceivesOffsetDateTime(t *testing.T) {
// The interface sees the wrapper, which names the date-time kind on its
// own; the local kinds keep their own wrappers.
var cfg struct {
O dateKindReceiver `toml:"o"`
L dateKindReceiver `toml:"l"`
}
in := []byte("o = 2026-06-26T10:00:00Z\nl = 2026-06-26T10:00:00\n")
if err := Unmarshal(in, &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.O.Kind != "interpres.OffsetDateTime" {
t.Errorf("offset kind = %q, want interpres.OffsetDateTime", cfg.O.Kind)
}
if cfg.L.Kind != "interpres.LocalDateTime" {
t.Errorf("local kind = %q, want interpres.LocalDateTime", cfg.L.Kind)
}
}
+54 -26
View File
@@ -74,7 +74,7 @@ and every 64 fields during the reflection walk.
| integer | `int64` | | integer | `int64` |
| float | `float64` | | float | `float64` |
| boolean | `bool` | | boolean | `bool` |
| offset date-time | `time.Time` | | offset date-time | `OffsetDateTime` |
| local date-time | `LocalDateTime` | | local date-time | `LocalDateTime` |
| local date | `LocalDate` | | local date | `LocalDate` |
| local time | `LocalTime` | | local time | `LocalTime` |
@@ -133,22 +133,24 @@ The decoder converts to the destination type with explicit overflow checks:
| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | the value must be non-negative and must not overflow the destination's own width, `uint` on a 32-bit platform included; `uint64` accepts any non-negative `int64` | | `uint`, `uint8`, `uint16`, `uint32`, `uint64` | the value must be non-negative and must not overflow the destination's own width, `uint` on a 32-bit platform included; `uint64` accepts any non-negative `int64` |
| `float32`, `float64` | copied verbatim, except that a finite value beyond the `float32` range is an overflow error rather than a silent infinity; an integer also coerces, so TOML `5` decodes into `5.0` | | `float32`, `float64` | copied verbatim, except that a finite value beyond the `float32` range is an overflow error rather than a silent infinity; an integer also coerces, so TOML `5` decodes into `5.0` |
| `bool`, `string` | exact kind match only, no coercion across kinds | | `bool`, `string` | exact kind match only, no coercion across kinds |
| `time.Time` | offset date-times only; no implicit conversion to or from the local variants | | `time.Time`, `OffsetDateTime` | offset date-times only; no implicit conversion to or from the local variants |
A conversion that the rules do not allow produces an error wrapped with the A conversion that the rules do not allow produces an error wrapped with the
offending key or index, for example `p: interpres: integer 300 overflows uint8`. offending key or index, for example `p: interpres: integer 300 overflows uint8`.
### Date-time values ### Date-time values
Offset date-times decode into `time.Time` and keep their offset. The local Offset date-times decode into `OffsetDateTime`, whose embedded `time.Time` is the
variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose instant with the offset the document wrote; a destination of the plain
embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the `time.Time` takes the same value, so a timestamp field does not have to name the
zero date for a local time). Every kind may omit the seconds as of TOML 1.1 wrapper. The local variants decode into `LocalDateTime`, `LocalDate` and
(`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the `LocalTime`, whose embedded `time.Time` is normalised to UTC (midnight UTC for a
encoder writes the seconds only when the value carries them, so a document local date, the zero date for a local time). Every kind may omit the seconds as
written without seconds comes back without them. There is no implicit of TOML 1.1 (`07:32`, `1979-05-27T07:32`); such a value carries a zero second,
and the encoder writes the seconds only when the value carries them, so a
document written without seconds comes back without them. There is no implicit
conversion between the offset and local kinds; assigning one to the other is an conversion between the offset and local kinds; assigning one to the other is an
error. The four types take a bare timestamp and never a quoted string, so a error. The date-time types take a bare timestamp and never a quoted string, so a
document that writes a date-time with quotes does not decode into them, and document that writes a date-time with quotes does not decode into them, and
neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
@@ -170,7 +172,7 @@ type Unmarshaler interface {
``` ```
`data` is whatever the parser produced for that key: `string`, `bool`, `int64`, `data` is whatever the parser produced for that key: `string`, `bool`, `int64`,
`float64`, `time.Time`, `LocalDateTime`, `LocalDate`, `LocalTime`, `[]any`, or `float64`, `OffsetDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`, `[]any`, or
`map[string]any`. The method inspects the value and mutates its own receiver; `map[string]any`. The method inspects the value and mutates its own receiver;
the decoder keeps whatever state the receiver stored. the decoder keeps whatever state the receiver stored.
@@ -201,7 +203,7 @@ prefix `unmarshal text:`, for example `addr: unmarshal text: not an address`.
[`UnmarshalTOML`](#custom-decoding-unmarshaler) wins over `UnmarshalText` when [`UnmarshalTOML`](#custom-decoding-unmarshaler) wins over `UnmarshalText` when
a type implements both, and the four [date-time a type implements both, and the four [date-time
types](#date-time-values) are excluded: a quoted string stays a string and types](#date-time-values) are excluded: a quoted string stays a string and
never becomes a `time.Time` or one of the local wrappers. never becomes an `OffsetDateTime` or one of the local wrappers.
### Durations ### Durations
@@ -316,7 +318,7 @@ them.
By default every table is emitted with its entries grouped by kind: By default every table is emitted with its entries grouped by kind:
1. scalars (`string`, `int64`, `float64`, `bool`, `time.Time`, 1. scalars (`string`, `int64`, `float64`, `bool`, `time.Time`,
`LocalDateTime`, `LocalDate`, `LocalTime`) `OffsetDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`)
2. sub-tables (structs and `map[string]V` values) 2. sub-tables (structs and `map[string]V` values)
3. arrays of tables (`[]struct` and `[]map[string]V`) 3. arrays of tables (`[]struct` and `[]map[string]V`)
@@ -351,11 +353,20 @@ type Marshaler interface {
The returned value is encoded as if it had been passed in place of the The returned value is encoded as if it had been passed in place of the
receiver, so it may be a scalar, a slice, an array of tables, or another receiver, so it may be a scalar, a slice, an array of tables, or another
struct or map, including the `Marshaler` result of another type; the encoder struct or map, including the `Marshaler` result of another type; the encoder
recurses. An error returned from `MarshalTOML` fails the marshal wrapped with recurses, and the result is normalised like any other value, so a method may
the key path, for example `interpres: server.port: bad timestamp`. A result return a plain `int` or a `time.Duration`.
of `nil` with a nil error fails the same way with
`MarshalTOML returned a nil value`: nil has no TOML representation, so An error returned from `MarshalTOML` fails the marshal wrapped with the key
dropping the field silently is not an option. path, for example `interpres: server.port: bad timestamp`. A result of `nil` with
a nil error fails the same way with `MarshalTOML returned a nil value`: nil has
no TOML representation, so dropping the field silently is not an option.
The method is reached for every value the walk meets, array elements included:
an element that renders itself as a table keeps the `[[header]]` form, one that
renders itself as a scalar turns the array into a value array, and the method
runs once per element. It is looked up on the value and on its address, so a
pointer-receiver method is called for a field or an element, exactly as
`MarshalText` is.
```go ```go
type Port int type Port int
@@ -512,9 +523,11 @@ sequenceDiagram
### `type SyntaxError struct{ Line int; Msg string }` ### `type SyntaxError struct{ Line int; Msg string }`
Describes a malformed TOML document; `Line` is 1-based and `Error()` renders as Describes a document the parser rejected, with the 1-based `Line` at which it
`interpres: line N: msg`. Read the structured fields with a type assertion or gave up and `Error()` rendering as `interpres: line N: msg`. A malformed
`errors.AsType`: document is the usual cause; the nesting limit and an input that is not valid
UTF-8 report through the same type. Read the structured fields with a type
assertion or `errors.AsType`:
```go ```go
if se, ok := errors.AsType[*interpres.SyntaxError](err); ok { if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
@@ -550,6 +563,19 @@ with `DisallowUnknownFields`, then call `Decode` or `DecodeContext` any number
of times. A configured `Decoder` holds no per-call state and is safe for of times. A configured `Decoder` holds no per-call state and is safe for
concurrent use. concurrent use.
| Method | Default | Effect |
|---|---|---|
| `DisallowUnknownFields()` | off | a key with no matching struct field is an error |
| `MaxDepth(depth int)` | `10000` | bound how deeply arrays and inline tables may nest |
| `MaxInputSize(size int)` | no limit | bound the size of the document, in bytes |
The nesting limit protects the stack, because the parser is a recursive
descent: a deeper document is rejected with a `SyntaxError` naming the limit
rather than running the stack out. `Parse` and `ParseContext` carry that same
default but take no options. The size limit is off by default, because the
caller already holds the bytes and the size is therefore a policy, not a
protection the library can impose on its own.
### `type Encoder` ### `type Encoder`
Configurable emission policy, constructed with `NewEncoder`. The option state Configurable emission policy, constructed with `NewEncoder`. The option state
@@ -587,9 +613,10 @@ See [Custom decoding](#custom-decoding-unmarshaler).
### Date-time wrappers ### Date-time wrappers
```go ```go
type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00 type OffsetDateTime struct{ time.Time } // 1979-05-27T07:32:00Z
type LocalDate struct{ time.Time } // 1979-05-27 type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00
type LocalTime struct{ time.Time } // 07:32:00.999999 type LocalDate struct{ time.Time } // 1979-05-27
type LocalTime struct{ time.Time } // 07:32:00.999999
``` ```
Each carries a `String()` method returning the TOML-canonical rendering: the Each carries a `String()` method returning the TOML-canonical rendering: the
@@ -602,11 +629,12 @@ writes them through `String()`.
The entry points return: The entry points return:
- `*SyntaxError` for a malformed document, with the 1-based line - `*SyntaxError` for a malformed document, with the 1-based line; the nesting
limit reports through it as well
- `*DecodeError` for a decoding failure, with the key path in `Path` - `*DecodeError` for a decoding failure, with the key path in `Path`
- `*EncodeError` for an encoding failure, with the key path in `Path` - `*EncodeError` for an encoding failure, with the key path in `Path`
- a plain error for the rest: a non-pointer decode target, a cancelled - a plain error for the rest: a non-pointer decode target, a cancelled
context, a key that is not valid UTF-8 context, a key that is not valid UTF-8, an input over the size limit
Decode and encode failures carry the key path or element index in the typed Decode and encode failures carry the key path or element index in the typed
wrappers above, so `errors.Is` and `errors.AsType` see through them and the wrappers above, so `errors.Is` and `errors.AsType` see through them and the
+106 -59
View File
@@ -20,12 +20,13 @@ import (
) )
var ( var (
localDateTimeType = reflect.TypeFor[LocalDateTime]() localDateTimeType = reflect.TypeFor[LocalDateTime]()
localDateType = reflect.TypeFor[LocalDate]() localDateType = reflect.TypeFor[LocalDate]()
localTimeType = reflect.TypeFor[LocalTime]() localTimeType = reflect.TypeFor[LocalTime]()
timeGoType = reflect.TypeFor[time.Time]() offsetDateTimeType = reflect.TypeFor[OffsetDateTime]()
durationType = reflect.TypeFor[time.Duration]() timeGoType = reflect.TypeFor[time.Time]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
) )
// inlineLimit is the column past which an inline table is written across // inlineLimit is the column past which an inline table is written across
@@ -342,17 +343,15 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value") var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value")
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error { func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
if v.CanInterface() { if m, ok := marshalerOf(v); ok {
if m, ok := v.Interface().(Marshaler); ok { mv, err := m.MarshalTOML()
mv, err := m.MarshalTOML() if err != nil {
if err != nil { return &EncodeError{Path: joinKey(ctx, name), Err: err}
return &EncodeError{Path: joinKey(ctx, name), Err: err}
}
if mv == nil {
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
}
v = reflect.ValueOf(mv)
} }
if mv == nil {
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
}
v = reflect.ValueOf(mv)
} }
// A type that renders itself as text becomes a TOML string, whether it is // A type that renders itself as text becomes a TOML string, whether it is
// a scalar kind or a struct. // a scalar kind or a struct.
@@ -431,12 +430,29 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, []any{}, ctx) return doc.appendScalar(name, []any{}, ctx)
} }
// Every element is resolved through MarshalTOML first, so an element that
// renders itself as a scalar, a table or a value array is classified by
// what it produces rather than by its Go kind, and its method runs once.
elems := make([]reflect.Value, n)
for i := range n {
if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil {
return err
}
}
ev, err := resolveElement(v.Index(i), fmt.Sprintf("%s[%d]", joinKey(ctx, name), i))
if err != nil {
return err
}
elems[i] = ev
}
// An array keeps the [[header]] form only when every element is a table. // An array keeps the [[header]] form only when every element is a table.
// TOML lets one array mix tables with scalars, and that mix renders as a // TOML lets one array mix tables with scalars, and that mix renders as a
// value array with the table elements written inline. // value array with the table elements written inline.
allTables := true allTables := true
for i := range n { for _, ev := range elems {
if !isTableElementValue(v.Index(i)) { if !ev.IsValid() || !isTableElementValue(ev) {
allTables = false allTables = false
break break
} }
@@ -450,16 +466,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
} }
if allTables { if allTables {
subs := make([]*tomlDoc, n) subs := make([]*tomlDoc, n)
for i := range n { for i, ev := range elems {
if i%ctxCheckInterval == 0 { if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil { if err := doc.checkCtx(); err != nil {
return err return err
} }
} }
ev := followPtr(v.Index(i))
if !ev.IsValid() {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts} sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
switch ev.Kind() { switch ev.Kind() {
case reflect.Struct: case reflect.Struct:
@@ -485,29 +497,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
// Value array. Table elements normalise to map[string]any and the emitter // Value array. Table elements normalise to map[string]any and the emitter
// writes them as inline tables. // writes them as inline tables.
items := make([]any, n) items := make([]any, n)
for i := range n { for i, ev := range elems {
if i%ctxCheckInterval == 0 { if i%ctxCheckInterval == 0 {
if err := doc.checkCtx(); err != nil { if err := doc.checkCtx(); err != nil {
return err return err
} }
} }
ev := followPtr(v.Index(i))
if !ev.IsValid() {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
}
if ev.CanInterface() {
if m, ok := ev.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
}
if mv == nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errNilMarshalTOML}
}
ev = reflect.ValueOf(mv)
ev = followPtr(ev)
}
}
val, err := normaliseValue(ev) val, err := normaliseValue(ev)
if err != nil { if err != nil {
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err} return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
@@ -517,6 +512,50 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return doc.appendScalar(name, items, ctx) return doc.appendScalar(name, items, ctx)
} }
// marshalerOf finds the Marshaler a value carries: on the value itself, or on
// its address, so a pointer-receiver MarshalTOML is found on an addressable
// struct field or slice element, exactly as textMarshalerOf finds MarshalText.
func marshalerOf(v reflect.Value) (Marshaler, bool) {
if !v.CanInterface() {
return nil, false
}
if m, ok := v.Interface().(Marshaler); ok {
return m, true
}
if v.CanAddr() {
if m, ok := v.Addr().Interface().(Marshaler); ok {
return m, true
}
}
return nil, false
}
// resolveElement looks through pointers and runs MarshalTOML, so an array
// element is classified by what its method produces. path names the element,
// for the errors the method can raise.
func resolveElement(v reflect.Value, path string) (reflect.Value, error) {
ev := followPtr(v)
if !ev.IsValid() {
return ev, &EncodeError{Path: path, Err: errors.New("nil element")}
}
m, ok := marshalerOf(ev)
if !ok {
return ev, nil
}
mv, err := m.MarshalTOML()
if err != nil {
return reflect.Value{}, &EncodeError{Path: path, Err: err}
}
if mv == nil {
return reflect.Value{}, &EncodeError{Path: path, Err: errNilMarshalTOML}
}
ev = followPtr(reflect.ValueOf(mv))
if !ev.IsValid() {
return ev, &EncodeError{Path: path, Err: errors.New("nil element")}
}
return ev, nil
}
// normaliseValue converts a reflect.Value into one of the canonical scalar or // normaliseValue converts a reflect.Value into one of the canonical scalar or
// nested-array representations the emitter understands. Slices and arrays are // nested-array representations the emitter understands. Slices and arrays are
// recursively normalised so that nested arrays (e.g. [][]int) work. // recursively normalised so that nested arrays (e.g. [][]int) work.
@@ -528,20 +567,25 @@ func normaliseValue(v reflect.Value) (any, error) {
if v.Kind() == reflect.Interface { if v.Kind() == reflect.Interface {
return nil, fmt.Errorf("cannot encode nil value") return nil, fmt.Errorf("cannot encode nil value")
} }
if v.CanInterface() { if m, ok := marshalerOf(v); ok {
if m, ok := v.Interface().(Marshaler); ok { mv, err := m.MarshalTOML()
mv, err := m.MarshalTOML() if err != nil {
if err != nil { return nil, err
return nil, err
}
if mv == nil {
return nil, errNilMarshalTOML
}
return mv, nil
} }
if mv == nil {
return nil, errNilMarshalTOML
}
// The result is normalised like any other value, so a method may return
// a duration, a defined type or another Marshaler. A result of the
// receiver's own type is written as it is, because recursing into it
// would never end.
if rv := reflect.ValueOf(mv); rv.Type() != v.Type() {
return normaliseValue(rv)
}
return mv, nil
} }
// The datetime structs are TOML scalars; the emitter renders each of them. // The datetime structs are TOML scalars; the emitter renders each of them.
if t := v.Type(); t == timeGoType || isLocalDateType(t) { if isScalarStruct(v.Type()) {
return v.Interface(), nil return v.Interface(), nil
} }
// TOML has no duration type, so a duration goes out in its canonical Go // TOML has no duration type, so a duration goes out in its canonical Go
@@ -622,24 +666,24 @@ func followPtr(v reflect.Value) reflect.Value {
} }
// isScalarStruct reports whether t is a struct type that the encoder treats // isScalarStruct reports whether t is a struct type that the encoder treats
// as a TOML scalar (time.Time, LocalDateTime, LocalDate, LocalTime). // as a TOML scalar: time.Time and the four date-time wrappers.
func isScalarStruct(t reflect.Type) bool { func isScalarStruct(t reflect.Type) bool {
return t == timeGoType || isLocalDateType(t) return t == timeGoType || t == offsetDateTimeType || isLocalDateType(t)
} }
func isLocalDateType(t reflect.Type) bool { func isLocalDateType(t reflect.Type) bool {
return t == localDateTimeType || t == localDateType || t == localTimeType return t == localDateTimeType || t == localDateType || t == localTimeType
} }
// isDateTimeType reports whether t is one of the four TOML date-time types, // isDateTimeType reports whether t is one of the date-time types, which the
// which the encoder emits as bare atoms. Pointers are looked through. The types // encoder emits as bare atoms. Pointers are looked through. The types carry
// carry time.Time's text methods through an embedded field, and the atom form // time.Time's text methods through an embedded field, and the atom form takes
// takes precedence over them. // precedence over them.
func isDateTimeType(t reflect.Type) bool { func isDateTimeType(t reflect.Type) bool {
for t.Kind() == reflect.Pointer { for t.Kind() == reflect.Pointer {
t = t.Elem() t = t.Elem()
} }
return t == timeGoType || isLocalDateType(t) return isScalarStruct(t)
} }
// isTextMarshalerType reports whether t or *t implements // isTextMarshalerType reports whether t or *t implements
@@ -945,6 +989,9 @@ func (e *encoder) writeValue(val any) error {
case time.Time: case time.Time:
e.buf.WriteString(offsetString(v)) e.buf.WriteString(offsetString(v))
return nil return nil
case OffsetDateTime:
e.buf.WriteString(v.String())
return nil
case LocalDateTime: case LocalDateTime:
e.buf.WriteString(v.String()) e.buf.WriteString(v.String())
return nil return nil
+146
View File
@@ -1599,6 +1599,7 @@ func TestMarshalDateTimeRendering(t *testing.T) {
want string want string
}{ }{
{"offset-zero-seconds", base, "v = 2026-06-26T10:00Z\n"}, {"offset-zero-seconds", base, "v = 2026-06-26T10:00Z\n"},
{"offset-wrapper", OffsetDateTime{Time: base}, "v = 2026-06-26T10:00Z\n"},
{"offset-seconds", base.Add(30 * time.Second), "v = 2026-06-26T10:00:30Z\n"}, {"offset-seconds", base.Add(30 * time.Second), "v = 2026-06-26T10:00:30Z\n"},
{"offset-fraction", base.Add(500 * time.Millisecond), "v = 2026-06-26T10:00:00.5Z\n"}, {"offset-fraction", base.Add(500 * time.Millisecond), "v = 2026-06-26T10:00:00.5Z\n"},
{"offset-zone", time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600)), "v = 2026-06-26T10:00-07:00\n"}, {"offset-zone", time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600)), "v = 2026-06-26T10:00-07:00\n"},
@@ -1800,3 +1801,148 @@ func TestEncoderInlineTablesKeepsArraysOfTables(t *testing.T) {
t.Errorf("items = %#v, want []map[string]any", tree["items"]) t.Errorf("items = %#v, want []map[string]any", tree["items"])
} }
} }
// --- Marshaler on array elements -------------------------------------------
// countingMarshaler reports how often its method ran, so a test can check that
// the encoder resolves an element once.
type countingMarshaler struct{ calls *int }
func (c countingMarshaler) MarshalTOML() (any, error) {
*c.calls++
return map[string]any{"n": int64(*c.calls)}, nil
}
// ptrMarshaler carries MarshalTOML on the pointer receiver only.
type ptrMarshaler struct{ V string }
func (p *ptrMarshaler) MarshalTOML() (any, error) { return map[string]any{"v": p.V}, nil }
func TestMarshalerElementOfArrayOfTables(t *testing.T) {
// An element is classified by what MarshalTOML returns, so methods that
// render tables keep the [[header]] form the Go kind would have given them.
type Cfg struct {
Items []marshalerFunc `toml:"items"`
}
out, err := Marshal(Cfg{Items: []marshalerFunc{
func() (any, error) { return map[string]any{"k": "a"}, nil },
func() (any, error) { return map[string]any{"k": "b"}, nil },
}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[[items]]\nk = \"a\"\n\n[[items]]\nk = \"b\"\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerElementScalarResultMakesValueArray(t *testing.T) {
// One element rendering itself as a scalar turns the whole array into a
// value array, with the table elements written inline.
type Cfg struct {
Items []marshalerFunc `toml:"items"`
}
out, err := Marshal(Cfg{Items: []marshalerFunc{
func() (any, error) { return map[string]any{"k": "a"}, nil },
func() (any, error) { return "x", nil },
}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "items = [{k = \"a\"}, \"x\"]\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerElementRunsOnce(t *testing.T) {
// Classification and emission share one result, so the method runs exactly
// once per element even when it decides the array's form.
calls := 0
type Cfg struct {
Items []countingMarshaler `toml:"items"`
}
out, err := Marshal(Cfg{Items: []countingMarshaler{{calls: &calls}, {calls: &calls}, {calls: &calls}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if calls != 3 {
t.Errorf("MarshalTOML ran %d times, want 3", calls)
}
want := "[[items]]\nn = 1\n\n[[items]]\nn = 2\n\n[[items]]\nn = 3\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerPointerReceiverOnElement(t *testing.T) {
// A slice element is addressable, so a pointer-receiver MarshalTOML is
// found there, and the method's table keeps the [[header]] form.
type Cfg struct {
Items []ptrMarshaler `toml:"items"`
}
out, err := Marshal(&Cfg{Items: []ptrMarshaler{{V: "a"}, {V: "b"}}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "[[items]]\nv = \"a\"\n\n[[items]]\nv = \"b\"\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
// The same method is found on an addressable struct field, whose result is
// a table and so keeps a header section.
type Field struct {
F ptrMarshaler `toml:"f"`
}
out, err = Marshal(&Field{F: ptrMarshaler{V: "c"}})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if want := "[f]\nv = \"c\"\n"; string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
func TestMarshalerElementErrorCarriesPath(t *testing.T) {
type Cfg struct {
Items []any `toml:"items"`
}
_, err := Marshal(Cfg{Items: []any{map[string]any{"k": "a"}, failingMarshalerFunc{}}})
if err == nil {
t.Fatal("expected an error from MarshalTOML")
}
if !strings.Contains(err.Error(), "oops") {
t.Errorf("err = %v, want substring \"oops\"", err)
}
ee, ok := errors.AsType[*EncodeError](err)
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "items[1]" {
t.Fatalf("Path = %q, want %q", ee.Path, "items[1]")
}
}
func TestMarshalerResultIsNormalised(t *testing.T) {
// A result is normalised like any other value, so a method may return a
// plain int or a duration where the Go kind alone would not encode.
type Cfg struct {
Plain marshalerFunc `toml:"plain"`
Duration marshalerFunc `toml:"duration"`
Elements []any `toml:"elements"`
}
out, err := Marshal(Cfg{
Plain: func() (any, error) { return 7, nil },
Duration: func() (any, error) { return 90 * time.Minute, nil },
Elements: []any{marshalerFunc(func() (any, error) { return 8, nil })},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := "plain = 7\nduration = \"1h30m0s\"\nelements = [8]\n"
if string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
}
+59 -11
View File
@@ -104,15 +104,33 @@ func Parse(data []byte) (map[string]any, error) {
// ctx. The context is checked between top-level statements so cancellation is // ctx. The context is checked between top-level statements so cancellation is
// honoured before the parser has done substantial work. // honoured before the parser has done substantial work.
func ParseContext(ctx context.Context, data []byte) (map[string]any, error) { func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
return parseWithOptions(ctx, data, parseOptions{})
}
// parseOptions bound the work one parse may do. A zero field takes the
// default.
type parseOptions struct {
maxDepth int
maxInputSize int
}
func parseWithOptions(ctx context.Context, data []byte, opts parseOptions) (map[string]any, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
} }
if opts.maxInputSize > 0 && len(data) > opts.maxInputSize {
return nil, fmt.Errorf("interpres: input is %d bytes, over the limit of %d", len(data), opts.maxInputSize)
}
if !utf8.Valid(data) { if !utf8.Valid(data) {
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"} return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
} }
maxDepth := opts.maxDepth
if maxDepth <= 0 {
maxDepth = maxNestingDepth
}
// The parser scans data in place; it only reads the buffer, and every // The parser scans data in place; it only reads the buffer, and every
// string it stores in the tree is copied out of it. // string it stores in the tree is copied out of it.
p := &parser{src: data, line: 1, ctx: ctx} p := &parser{src: data, line: 1, ctx: ctx, maxDepth: maxDepth}
return p.parse() return p.parse()
} }
@@ -143,9 +161,11 @@ func UnmarshalContext(ctx context.Context, data []byte, v any) error {
} }
// A Decoder decodes a TOML document into a Go value with configurable // A Decoder decodes a TOML document into a Go value with configurable
// strictness. // strictness and configurable limits on the parse it performs.
type Decoder struct { type Decoder struct {
disallowUnknown bool disallowUnknown bool
maxDepth int
maxInputSize int
} }
// NewDecoder returns a Decoder. // NewDecoder returns a Decoder.
@@ -158,6 +178,28 @@ func (d *Decoder) DisallowUnknownFields() *Decoder {
return d return d
} }
// MaxDepth bounds how deeply arrays and inline tables may nest in a document
// this decoder accepts. The parser is a recursive descent, so a document that
// nests without bound would exhaust the stack; one that nests deeper than the
// limit is rejected with a SyntaxError naming it instead. Use 0 or any
// negative value for the default of 10000, which no hand-written document
// approaches.
func (d *Decoder) MaxDepth(depth int) *Decoder {
d.maxDepth = depth
return d
}
// MaxInputSize bounds the size of a document this decoder accepts, in bytes; a
// larger one is rejected before parsing starts. Use 0 or any negative value for
// no limit, which is the default: the caller already holds the bytes, so the
// size is a policy the caller sets rather than a protection the library
// imposes on its own. Parse and ParseContext take no limit beyond the nesting
// default.
func (d *Decoder) MaxInputSize(size int) *Decoder {
d.maxInputSize = size
return d
}
// Decode parses data and stores the result in the value pointed to by v, // Decode parses data and stores the result in the value pointed to by v,
// honouring the decoder's strictness settings. // honouring the decoder's strictness settings.
// //
@@ -168,7 +210,10 @@ func (d *Decoder) Decode(data []byte, v any) error {
// DecodeContext is the cancellable variant of Decode. // DecodeContext is the cancellable variant of Decode.
func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
tree, err := ParseContext(ctx, data) tree, err := parseWithOptions(ctx, data, parseOptions{
maxDepth: d.maxDepth,
maxInputSize: d.maxInputSize,
})
if err != nil { if err != nil {
return err return err
} }
@@ -193,12 +238,15 @@ type Marshaler interface {
// Unmarshaler is the inverse of Marshaler: a type that wants control over // Unmarshaler is the inverse of Marshaler: a type that wants control over
// how it is decoded from a TOML value may implement UnmarshalTOML. The data // how it is decoded from a TOML value may implement UnmarshalTOML. The data
// argument is whatever the parser produced for that key: one of string, // argument is whatever the parser produced for that key: one of string,
// bool, int64, float64, time.Time, LocalDateTime, LocalDate, LocalTime, // bool, int64, float64, OffsetDateTime, LocalDateTime, LocalDate, LocalTime,
// []any, or map[string]any. UnmarshalTOML may parse, inspect, or transform // []any, or map[string]any. A tree built by hand may carry a plain time.Time
// the value however it likes, then store the result by mutating its // where the parser would put an OffsetDateTime.
// receiver through the standard pointer-indirection rules of the reflect //
// package (i.e. via reflect.Value.Set or by reassigning fields through a // UnmarshalTOML may parse, inspect, or transform the value however it likes,
// pointer the receiver holds). // then store the result by mutating its receiver through the standard
// pointer-indirection rules of the reflect package (i.e. via
// reflect.Value.Set or by reassigning fields through a pointer the receiver
// holds).
// //
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the // UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
// destination type implements the interface. The decoder does not need to // destination type implements the interface. The decoder does not need to
@@ -231,8 +279,8 @@ type Unmarshaler interface {
// value array (for example an inline table in a mixed array) emits as an // value array (for example an inline table in a mixed array) emits as an
// inline table. // inline table.
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time // - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local // and OffsetDateTime (offset date-time), and LocalDateTime/LocalDate/
// variants). A date-time writes its seconds only when the value carries // LocalTime (local variants). A date-time writes its seconds only when the value carries
// them, and drops the trailing zeros of a fractional second. // them, and drops the trailing zeros of a fractional second.
// - A table element of a value array, and a sub-table inlined by // - A table element of a value array, and a sub-table inlined by
// Encoder.InlineTables, is written as an inline table, across lines when it // Encoder.InlineTables, is written as an inline table, across lines when it
+32 -9
View File
@@ -4,6 +4,7 @@
package interpres package interpres
import ( import (
"errors"
"math" "math"
"strings" "strings"
"testing" "testing"
@@ -192,7 +193,7 @@ clock = 07:32:00
if err != nil { if err != nil {
t.Fatalf("parse: %v", err) t.Fatalf("parse: %v", err)
} }
if off, ok := tree["offset"].(time.Time); !ok || off.Year() != 1979 || off.Hour() != 7 { if off, ok := tree["offset"].(OffsetDateTime); !ok || off.Year() != 1979 || off.Hour() != 7 {
t.Errorf("offset = %#v (%T)", tree["offset"], tree["offset"]) t.Errorf("offset = %#v (%T)", tree["offset"], tree["offset"])
} }
if ldt, ok := tree["local"].(LocalDateTime); !ok || ldt.Year() != 1979 || ldt.Hour() != 7 { if ldt, ok := tree["local"].(LocalDateTime); !ok || ldt.Year() != 1979 || ldt.Hour() != 7 {
@@ -211,11 +212,11 @@ func TestDateTimeFormats(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("parse: %v", err) t.Fatalf("parse: %v", err)
} }
if _, ok := tree["a"].(time.Time); !ok { if _, ok := tree["a"].(OffsetDateTime); !ok {
t.Errorf("a is %T, want time.Time", tree["a"]) t.Errorf("a is %T, want OffsetDateTime", tree["a"])
} }
if _, ok := tree["b"].(time.Time); !ok { if _, ok := tree["b"].(OffsetDateTime); !ok {
t.Errorf("b is %T, want time.Time", tree["b"]) t.Errorf("b is %T, want OffsetDateTime", tree["b"])
} }
if _, ok := tree["c"].(LocalDateTime); !ok { if _, ok := tree["c"].(LocalDateTime); !ok {
t.Errorf("c is %T, want LocalDateTime", tree["c"]) t.Errorf("c is %T, want LocalDateTime", tree["c"])
@@ -419,11 +420,11 @@ func TestParseAcceptsOffsetBounds(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("parse: %v", err) t.Fatalf("parse: %v", err)
} }
a := tree["a"].(time.Time) a := tree["a"].(OffsetDateTime)
if _, offset := a.Zone(); offset != 23*3600+59*60 { if _, offset := a.Zone(); offset != 23*3600+59*60 {
t.Fatalf("a offset = %d, want %d", offset, 23*3600+59*60) t.Fatalf("a offset = %d, want %d", offset, 23*3600+59*60)
} }
b := tree["b"].(time.Time) b := tree["b"].(OffsetDateTime)
if _, offset := b.Zone(); offset != -(23*3600 + 59*60) { if _, offset := b.Zone(); offset != -(23*3600 + 59*60) {
t.Fatalf("b offset = %d", offset) t.Fatalf("b offset = %d", offset)
} }
@@ -619,10 +620,10 @@ odt2 = 1979-05-27 07:32-07:00
if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" { if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" {
t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32") t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32")
} }
if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" { if got := tree["odt1"].(OffsetDateTime).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" {
t.Errorf("odt1 = %q", got) t.Errorf("odt1 = %q", got)
} }
if got := tree["odt2"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00-07:00" { if got := tree["odt2"].(OffsetDateTime).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00-07:00" {
t.Errorf("odt2 = %q", got) t.Errorf("odt2 = %q", got)
} }
// The fraction still requires the seconds it belongs to. // The fraction still requires the seconds it belongs to.
@@ -703,3 +704,25 @@ func TestParseAcceptsMultilineInlineTables(t *testing.T) {
} }
} }
} }
func TestParseNestingLimit(t *testing.T) {
// The parser is a recursive descent, so a document that nests without bound
// is rejected instead of exhausting the stack.
deep := func(n int) []byte {
return []byte("v = " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n")
}
if _, err := Parse(deep(100)); err != nil {
t.Fatalf("a document well inside the limit: %v", err)
}
_, err := Parse(deep(maxNestingDepth + 1))
if err == nil {
t.Fatal("expected a nesting error")
}
var se *SyntaxError
if !errors.As(err, &se) {
t.Fatalf("expected a *SyntaxError, got %T: %v", err, err)
}
if !strings.Contains(se.Msg, "nesting") {
t.Errorf("Msg = %q, want it to name the nesting limit", se.Msg)
}
}
+31
View File
@@ -30,6 +30,12 @@ type parser struct {
line int line int
ctx context.Context ctx context.Context
// maxDepth and depth bound the nesting the recursive descent may follow:
// arrays and inline tables nest through parseValue, and without a limit a
// hostile document would exhaust the stack.
maxDepth int
depth int
root map[string]any root map[string]any
current map[string]any current map[string]any
headers map[string]bool headers map[string]bool
@@ -40,6 +46,23 @@ type parser struct {
currentPath []string currentPath []string
} }
// maxNestingDepth bounds how deeply arrays and inline tables may nest when no
// limit is set. It matches the default encoding/json uses for the same reason,
// and sits far above any document a person writes.
const maxNestingDepth = 10000
// enterNesting counts one level of array or inline-table nesting and reports a
// document that nests deeper than the limit allows.
func (p *parser) enterNesting() error {
p.depth++
if p.depth > p.maxDepth {
return p.errf("nesting exceeds the limit of %d", p.maxDepth)
}
return nil
}
func (p *parser) leaveNesting() { p.depth-- }
func (p *parser) parse() (map[string]any, error) { func (p *parser) parse() (map[string]any, error) {
p.root = map[string]any{} p.root = map[string]any{}
p.current = p.root p.current = p.root
@@ -676,6 +699,10 @@ func (p *parser) readUnicode(n int) (rune, error) {
// --- arrays and inline tables --------------------------------------------- // --- arrays and inline tables ---------------------------------------------
func (p *parser) parseArray() (any, error) { func (p *parser) parseArray() (any, error) {
if err := p.enterNesting(); err != nil {
return nil, err
}
defer p.leaveNesting()
p.pos++ // '[' p.pos++ // '['
arr := []any{} arr := []any{}
for { for {
@@ -713,6 +740,10 @@ func (p *parser) parseArray() (any, error) {
} }
func (p *parser) parseInlineTable() (any, error) { func (p *parser) parseInlineTable() (any, error) {
if err := p.enterNesting(); err != nil {
return nil, err
}
defer p.leaveNesting()
p.pos++ // '{' p.pos++ // '{'
tbl := map[string]any{} tbl := map[string]any{}
assigned := map[string]bool{} assigned := map[string]bool{}