feat: add OffsetDateTime, nesting limits and uniform Marshaler dispatch
Test / test (push) Successful in 2m18s
Test / test (push) Successful in 2m18s
Assisted-by: DeepSeek V4.1 Flash
This commit is contained in:
@@ -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
|
||||
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.
|
||||
- `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
|
||||
|
||||
@@ -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
|
||||
with a trailing comma and one tab of indentation per nesting level, the shape
|
||||
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
|
||||
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
|
||||
|
||||
@@ -285,8 +285,11 @@ func typeMatches(typ string, v any) bool {
|
||||
_, ok := v.(bool)
|
||||
return ok
|
||||
case "datetime":
|
||||
_, ok := v.(time.Time)
|
||||
return ok
|
||||
switch v.(type) {
|
||||
case time.Time, interpres.OffsetDateTime:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "datetime-local":
|
||||
_, ok := v.(interpres.LocalDateTime)
|
||||
return ok
|
||||
@@ -346,6 +349,8 @@ func tag(v any) (any, error) {
|
||||
return tagged("float", formatFloat(x)), nil
|
||||
case time.Time:
|
||||
return tagged("datetime", x.Format(time.RFC3339Nano)), nil
|
||||
case interpres.OffsetDateTime:
|
||||
return tagged("datetime", x.Format(time.RFC3339Nano)), nil
|
||||
case interpres.LocalDateTime:
|
||||
return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999")), nil
|
||||
case interpres.LocalDate:
|
||||
|
||||
@@ -373,7 +373,7 @@ func TestRunEncoderRejectsBadInput(t *testing.T) {
|
||||
{"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"},
|
||||
{"untagged-scalar", `{"x": 1}`, "unsupported JSON value"},
|
||||
{"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"},
|
||||
{"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"},
|
||||
}
|
||||
|
||||
+16
-4
@@ -11,9 +11,15 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TOML distinguishes four date-time kinds. interpres decodes an offset
|
||||
// date-time to a plain time.Time (it carries a zone), and uses the wrapper
|
||||
// types below for the local variants so callers can tell them apart.
|
||||
// TOML distinguishes four date-time kinds, and each has its own Go type:
|
||||
// OffsetDateTime for the offset kind, and the local wrappers below for the
|
||||
// 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.
|
||||
// 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.
|
||||
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.
|
||||
// "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
|
||||
@@ -129,7 +141,7 @@ func parseDateTime(tok string) (any, bool) {
|
||||
norm := strings.ToUpper(tok)
|
||||
for _, layout := range offsetDateTimeLayouts {
|
||||
if t, err := time.Parse(layout, norm); err == nil {
|
||||
return t, true
|
||||
return OffsetDateTime{t}, true
|
||||
}
|
||||
}
|
||||
for _, layout := range localDateTimeLayouts {
|
||||
|
||||
@@ -95,12 +95,10 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
||||
return setInt(dst, v)
|
||||
case float64:
|
||||
return setFloat(dst, v)
|
||||
case OffsetDateTime:
|
||||
return setOffsetDateTime(v, dst)
|
||||
case time.Time:
|
||||
if dst.Type() != timeType {
|
||||
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
|
||||
}
|
||||
dst.Set(reflect.ValueOf(v))
|
||||
return nil
|
||||
return setDateTime(v, dst)
|
||||
default:
|
||||
rv := reflect.ValueOf(data)
|
||||
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 -----------------------------------------------------
|
||||
|
||||
// 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 {
|
||||
if dst.Kind() != val.Kind() {
|
||||
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
|
||||
|
||||
+127
@@ -951,3 +951,130 @@ func TestQuotedStringNeverBecomesDateTime(t *testing.T) {
|
||||
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
@@ -74,7 +74,7 @@ and every 64 fields during the reflection walk.
|
||||
| integer | `int64` |
|
||||
| float | `float64` |
|
||||
| boolean | `bool` |
|
||||
| offset date-time | `time.Time` |
|
||||
| offset date-time | `OffsetDateTime` |
|
||||
| local date-time | `LocalDateTime` |
|
||||
| local date | `LocalDate` |
|
||||
| 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` |
|
||||
| `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 |
|
||||
| `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
|
||||
offending key or index, for example `p: interpres: integer 300 overflows uint8`.
|
||||
|
||||
### Date-time values
|
||||
|
||||
Offset date-times decode into `time.Time` and keep their offset. The local
|
||||
variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose
|
||||
embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the
|
||||
zero date for a local time). Every kind may omit the seconds as 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
|
||||
Offset date-times decode into `OffsetDateTime`, whose embedded `time.Time` is the
|
||||
instant with the offset the document wrote; a destination of the plain
|
||||
`time.Time` takes the same value, so a timestamp field does not have to name the
|
||||
wrapper. The local variants decode into `LocalDateTime`, `LocalDate` and
|
||||
`LocalTime`, whose embedded `time.Time` is normalised to UTC (midnight UTC for a
|
||||
local date, the zero date for a local time). Every kind may omit the seconds as
|
||||
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
|
||||
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
|
||||
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`,
|
||||
`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;
|
||||
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
|
||||
a type implements both, and the four [date-time
|
||||
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
|
||||
|
||||
@@ -316,7 +318,7 @@ them.
|
||||
By default every table is emitted with its entries grouped by kind:
|
||||
|
||||
1. scalars (`string`, `int64`, `float64`, `bool`, `time.Time`,
|
||||
`LocalDateTime`, `LocalDate`, `LocalTime`)
|
||||
`OffsetDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`)
|
||||
2. sub-tables (structs and `map[string]V` values)
|
||||
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
|
||||
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
|
||||
recurses. An error returned from `MarshalTOML` fails the marshal wrapped with
|
||||
the key 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.
|
||||
recurses, and the result is normalised like any other value, so a method may
|
||||
return a plain `int` or a `time.Duration`.
|
||||
|
||||
An error returned from `MarshalTOML` fails the marshal wrapped with the key
|
||||
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
|
||||
type Port int
|
||||
@@ -512,9 +523,11 @@ sequenceDiagram
|
||||
|
||||
### `type SyntaxError struct{ Line int; Msg string }`
|
||||
|
||||
Describes a malformed TOML document; `Line` is 1-based and `Error()` renders as
|
||||
`interpres: line N: msg`. Read the structured fields with a type assertion or
|
||||
`errors.AsType`:
|
||||
Describes a document the parser rejected, with the 1-based `Line` at which it
|
||||
gave up and `Error()` rendering as `interpres: line N: msg`. A malformed
|
||||
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
|
||||
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
|
||||
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`
|
||||
|
||||
Configurable emission policy, constructed with `NewEncoder`. The option state
|
||||
@@ -587,9 +613,10 @@ See [Custom decoding](#custom-decoding-unmarshaler).
|
||||
### Date-time wrappers
|
||||
|
||||
```go
|
||||
type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00
|
||||
type LocalDate struct{ time.Time } // 1979-05-27
|
||||
type LocalTime struct{ time.Time } // 07:32:00.999999
|
||||
type OffsetDateTime struct{ time.Time } // 1979-05-27T07:32:00Z
|
||||
type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00
|
||||
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
|
||||
@@ -602,11 +629,12 @@ writes them through `String()`.
|
||||
|
||||
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`
|
||||
- `*EncodeError` for an encoding failure, with the key path in `Path`
|
||||
- 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
|
||||
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
|
||||
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
localDateTimeType = reflect.TypeFor[LocalDateTime]()
|
||||
localDateType = reflect.TypeFor[LocalDate]()
|
||||
localTimeType = reflect.TypeFor[LocalTime]()
|
||||
timeGoType = reflect.TypeFor[time.Time]()
|
||||
durationType = reflect.TypeFor[time.Duration]()
|
||||
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||
localDateTimeType = reflect.TypeFor[LocalDateTime]()
|
||||
localDateType = reflect.TypeFor[LocalDate]()
|
||||
localTimeType = reflect.TypeFor[LocalTime]()
|
||||
offsetDateTimeType = reflect.TypeFor[OffsetDateTime]()
|
||||
timeGoType = reflect.TypeFor[time.Time]()
|
||||
durationType = reflect.TypeFor[time.Duration]()
|
||||
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||
if v.CanInterface() {
|
||||
if m, ok := v.Interface().(Marshaler); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
if err != nil {
|
||||
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||
}
|
||||
if mv == nil {
|
||||
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
|
||||
}
|
||||
v = reflect.ValueOf(mv)
|
||||
if m, ok := marshalerOf(v); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
if err != nil {
|
||||
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TOML lets one array mix tables with scalars, and that mix renders as a
|
||||
// value array with the table elements written inline.
|
||||
allTables := true
|
||||
for i := range n {
|
||||
if !isTableElementValue(v.Index(i)) {
|
||||
for _, ev := range elems {
|
||||
if !ev.IsValid() || !isTableElementValue(ev) {
|
||||
allTables = false
|
||||
break
|
||||
}
|
||||
@@ -450,16 +466,12 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
}
|
||||
if allTables {
|
||||
subs := make([]*tomlDoc, n)
|
||||
for i := range n {
|
||||
for i, ev := range elems {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := doc.checkCtx(); err != nil {
|
||||
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}
|
||||
switch ev.Kind() {
|
||||
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
|
||||
// writes them as inline tables.
|
||||
items := make([]any, n)
|
||||
for i := range n {
|
||||
for i, ev := range elems {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := doc.checkCtx(); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// nested-array representations the emitter understands. Slices and arrays are
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("cannot encode nil value")
|
||||
}
|
||||
if v.CanInterface() {
|
||||
if m, ok := v.Interface().(Marshaler); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mv == nil {
|
||||
return nil, errNilMarshalTOML
|
||||
}
|
||||
return mv, nil
|
||||
if m, ok := marshalerOf(v); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
|
||||
if isScalarStruct(v.Type()) {
|
||||
return v.Interface(), nil
|
||||
}
|
||||
// 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
|
||||
// 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 {
|
||||
return t == timeGoType || isLocalDateType(t)
|
||||
return t == timeGoType || t == offsetDateTimeType || isLocalDateType(t)
|
||||
}
|
||||
|
||||
func isLocalDateType(t reflect.Type) bool {
|
||||
return t == localDateTimeType || t == localDateType || t == localTimeType
|
||||
}
|
||||
|
||||
// isDateTimeType reports whether t is one of the four TOML date-time types,
|
||||
// which the encoder emits as bare atoms. Pointers are looked through. The types
|
||||
// carry time.Time's text methods through an embedded field, and the atom form
|
||||
// takes precedence over them.
|
||||
// isDateTimeType reports whether t is one of the date-time types, which the
|
||||
// encoder emits as bare atoms. Pointers are looked through. The types carry
|
||||
// time.Time's text methods through an embedded field, and the atom form takes
|
||||
// precedence over them.
|
||||
func isDateTimeType(t reflect.Type) bool {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t == timeGoType || isLocalDateType(t)
|
||||
return isScalarStruct(t)
|
||||
}
|
||||
|
||||
// isTextMarshalerType reports whether t or *t implements
|
||||
@@ -945,6 +989,9 @@ func (e *encoder) writeValue(val any) error {
|
||||
case time.Time:
|
||||
e.buf.WriteString(offsetString(v))
|
||||
return nil
|
||||
case OffsetDateTime:
|
||||
e.buf.WriteString(v.String())
|
||||
return nil
|
||||
case LocalDateTime:
|
||||
e.buf.WriteString(v.String())
|
||||
return nil
|
||||
|
||||
+146
@@ -1599,6 +1599,7 @@ func TestMarshalDateTimeRendering(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"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-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"},
|
||||
@@ -1800,3 +1801,148 @@ func TestEncoderInlineTablesKeepsArraysOfTables(t *testing.T) {
|
||||
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
@@ -104,15 +104,33 @@ func Parse(data []byte) (map[string]any, error) {
|
||||
// ctx. The context is checked between top-level statements so cancellation is
|
||||
// honoured before the parser has done substantial work.
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// strictness.
|
||||
// strictness and configurable limits on the parse it performs.
|
||||
type Decoder struct {
|
||||
disallowUnknown bool
|
||||
maxDepth int
|
||||
maxInputSize int
|
||||
}
|
||||
|
||||
// NewDecoder returns a Decoder.
|
||||
@@ -158,6 +178,28 @@ func (d *Decoder) DisallowUnknownFields() *Decoder {
|
||||
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,
|
||||
// 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.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -193,12 +238,15 @@ type Marshaler interface {
|
||||
// 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
|
||||
// argument is whatever the parser produced for that key: one of string,
|
||||
// bool, int64, float64, time.Time, LocalDateTime, LocalDate, LocalTime,
|
||||
// []any, or map[string]any. UnmarshalTOML may parse, inspect, or transform
|
||||
// the value however it likes, 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).
|
||||
// bool, int64, float64, OffsetDateTime, LocalDateTime, LocalDate, LocalTime,
|
||||
// []any, or map[string]any. A tree built by hand may carry a plain time.Time
|
||||
// where the parser would put an OffsetDateTime.
|
||||
//
|
||||
// UnmarshalTOML may parse, inspect, or transform the value however it likes,
|
||||
// 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
|
||||
// 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
|
||||
// inline table.
|
||||
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
|
||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||
// variants). A date-time writes its seconds only when the value carries
|
||||
// and OffsetDateTime (offset date-time), and LocalDateTime/LocalDate/
|
||||
// LocalTime (local variants). A date-time writes its seconds only when the value carries
|
||||
// them, and drops the trailing zeros of a fractional second.
|
||||
// - 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
|
||||
|
||||
+32
-9
@@ -4,6 +4,7 @@
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -192,7 +193,7 @@ clock = 07:32:00
|
||||
if err != nil {
|
||||
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"])
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, ok := tree["a"].(time.Time); !ok {
|
||||
t.Errorf("a is %T, want time.Time", tree["a"])
|
||||
if _, ok := tree["a"].(OffsetDateTime); !ok {
|
||||
t.Errorf("a is %T, want OffsetDateTime", tree["a"])
|
||||
}
|
||||
if _, ok := tree["b"].(time.Time); !ok {
|
||||
t.Errorf("b is %T, want time.Time", tree["b"])
|
||||
if _, ok := tree["b"].(OffsetDateTime); !ok {
|
||||
t.Errorf("b is %T, want OffsetDateTime", tree["b"])
|
||||
}
|
||||
if _, ok := tree["c"].(LocalDateTime); !ok {
|
||||
t.Errorf("c is %T, want LocalDateTime", tree["c"])
|
||||
@@ -419,11 +420,11 @@ func TestParseAcceptsOffsetBounds(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
a := tree["a"].(time.Time)
|
||||
a := tree["a"].(OffsetDateTime)
|
||||
if _, offset := a.Zone(); 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) {
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ type parser struct {
|
||||
line int
|
||||
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
|
||||
current map[string]any
|
||||
headers map[string]bool
|
||||
@@ -40,6 +46,23 @@ type parser struct {
|
||||
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) {
|
||||
p.root = map[string]any{}
|
||||
p.current = p.root
|
||||
@@ -676,6 +699,10 @@ func (p *parser) readUnicode(n int) (rune, error) {
|
||||
// --- arrays and inline tables ---------------------------------------------
|
||||
|
||||
func (p *parser) parseArray() (any, error) {
|
||||
if err := p.enterNesting(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.leaveNesting()
|
||||
p.pos++ // '['
|
||||
arr := []any{}
|
||||
for {
|
||||
@@ -713,6 +740,10 @@ func (p *parser) parseArray() (any, error) {
|
||||
}
|
||||
|
||||
func (p *parser) parseInlineTable() (any, error) {
|
||||
if err := p.enterNesting(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.leaveNesting()
|
||||
p.pos++ // '{'
|
||||
tbl := map[string]any{}
|
||||
assigned := map[string]bool{}
|
||||
|
||||
Reference in New Issue
Block a user