feat: bound the encoder walk and add UnmarshalWithOptions
Test / test (push) Successful in 1m34s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 00:21:46 +02:00
parent eaa69dc6f6
commit 10391a090f
5 changed files with 219 additions and 18 deletions
+6
View File
@@ -52,6 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
order on decode and sorts on encode. It works as a decode target on its
own, in a struct field, and as the element of an array of tables; its
values are untyped, so a nested table stays a `map[string]any`.
- `UnmarshalWithOptions(data, v, opts)` decodes with a `DecodeOptions` struct
in one call, the options a `Decoder` sets without building one: unknown
keys, `Number` literals, and the parse limits.
- `Marshal` carries a nesting limit of 10000 levels, the parser's own figure:
cyclic data, which used to run the stack out, is now rejected with an error
that names the limit and the path it was met at.
- The `toml` tag gained the `required` option: a field tagged
`toml:"host,required"` makes the decode fail with
`missing required key "host"` when the document carries no key that
+16 -2
View File
@@ -73,6 +73,13 @@ if err := interpres.Valid(data); err != nil {
}
```
### `func UnmarshalWithOptions(data []byte, v any, opts DecodeOptions) error`
The one-shot form of a configured `Decoder`: the same options as
`NewDecoder` sets, in a `DecodeOptions` struct, applied to a single call.
The zero value takes the defaults: unknown keys ignored, numbers evaluated
as `int64` and `float64`, no size limit and the 10000-level nesting default.
## Documents
`Parse` returns a `Document`: the value tree together with what a map cannot
@@ -443,6 +450,11 @@ different top-level value fails:
| a nil `any` | `interpres: cannot marshal nil value` |
| a nil pointer | `interpres: cannot marshal nil pointer` |
The encoding walk carries a nesting limit of 10000 levels, the parser's own
figure: a value that nests deeper, which cyclic data always does, is rejected
with an error that names the limit and suggests the cycle, instead of running
the stack out.
### Field matching
Struct fields become TOML keys as follows:
@@ -739,9 +751,11 @@ same way.
### `type Decoder`
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
with `DisallowUnknownFields`, then call `Decode` or `DecodeContext` any number
with the chainable methods, then call `Decode` or `DecodeContext` any number
of times. A configured `Decoder` holds no per-call state and is safe for
concurrent use.
concurrent use. For a single document, `UnmarshalWithOptions(data, v,
DecodeOptions{...})` sets the same options without the Decoder; its zero
value takes the defaults.
| Method | Default | Effect |
|---|---|---|
+63 -14
View File
@@ -247,7 +247,10 @@ func buildOrderedDoc(om *OrderedMap, doc *tomlDoc, path encPath) error {
val := om.values[key]
switch x := val.(type) {
case OrderedMap:
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(key).String(), Err: errDepthLimit()}
}
if err := buildOrderedDoc(&x, sub, path.key(key)); err != nil {
return err
}
@@ -257,7 +260,10 @@ func buildOrderedDoc(om *OrderedMap, doc *tomlDoc, path encPath) error {
if x == nil {
continue
}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(key).String(), Err: errDepthLimit()}
}
if err := buildOrderedDoc(x, sub, path.key(key)); err != nil {
return err
}
@@ -304,11 +310,30 @@ type entry struct {
emitted bool
}
// tomlDoc holds the entries of one TOML table in declaration order.
// tomlDoc holds the entries of one TOML table in declaration order. depth is
// the nesting level the table sits at, which bounds the walk: cyclic data
// would recurse forever, and hits the limit instead of the stack.
type tomlDoc struct {
entries []entry
ctx context.Context // inherited from encoder; nil-safe
opts Encoder // inherited from encoder; options drive emit-time behaviour
depth int
}
// maxEncodeDepth bounds the encoding walk. It matches the parser's nesting
// limit: a document that deep cannot be written by this encoder either, and
// a value that nests that far without being an array or a table is cyclic.
const maxEncodeDepth = maxNestingDepth
// atDepthLimit reports whether a table nested depth levels is past the walk's
// limit.
func atDepthLimit(depth int) bool {
return depth > maxEncodeDepth
}
// errDepthLimit is the failure a cyclic value walks into.
func errDepthLimit() error {
return fmt.Errorf("value nests deeper than the limit of %d levels; the value may be cyclic", maxEncodeDepth)
}
func (d *tomlDoc) checkCtx() error {
@@ -385,7 +410,7 @@ func (p encPath) String() string {
// --- reflection walk: struct ---------------------------------------------
func buildStructDoc(v reflect.Value, doc *tomlDoc, path encPath) error {
return walkStructDoc(v, doc, path, nil, cachedStructSchema(v.Type()))
return walkStructDoc(v, doc, path, nil, cachedStructSchema(v.Type()), doc.depth)
}
// walkStructDoc emits the fields of v into doc. prefix is v's index path from
@@ -393,8 +418,13 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, path encPath) error {
// with the outer schema and a longer prefix, so every leaf competes under the
// decoder's rule: the shallower field wins, the later declaration at equal
// depth. A field another field shadows is skipped, because emitting both
// would duplicate the key and the output would not re-parse.
func walkStructDoc(v reflect.Value, doc *tomlDoc, path encPath, prefix []int, schema structSchema) error {
// would duplicate the key and the output would not re-parse. depth is the
// nesting the walk has reached, which an embedded struct raises; a cycle
// through embedded pointers ends at the limit instead of the stack.
func walkStructDoc(v reflect.Value, doc *tomlDoc, path encPath, prefix []int, schema structSchema, depth int) error {
if atDepthLimit(depth) {
return fmt.Errorf("interpres: %s: %w", path.String(), errDepthLimit())
}
t := v.Type()
if cap(doc.entries) == 0 {
doc.entries = make([]entry, 0, t.NumField())
@@ -430,7 +460,7 @@ func walkStructDoc(v reflect.Value, doc *tomlDoc, path encPath, prefix []int, sc
doc.addScalar(name, fv.Interface())
continue
}
if err := walkStructDoc(fv, doc, path, fpath, schema); err != nil {
if err := walkStructDoc(fv, doc, path, fpath, schema, depth+1); err != nil {
return err
}
continue
@@ -520,6 +550,9 @@ func fieldName(f reflect.StructField) string {
// --- reflection walk: map ------------------------------------------------
func buildMapDoc(v reflect.Value, doc *tomlDoc, path encPath) error {
if atDepthLimit(doc.depth) {
return fmt.Errorf("interpres: %s: %w", path.String(), errDepthLimit())
}
if v.Type().Key().Kind() != reflect.String {
return fmt.Errorf("interpres: map key must be string, got %s", v.Type().Key())
}
@@ -584,7 +617,10 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath) error {
}
if v.Type() == orderedMapType {
om := v.Interface().(OrderedMap)
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(name).String(), Err: errDepthLimit()}
}
if err := buildOrderedDoc(&om, sub, path.key(name)); err != nil {
return err
}
@@ -613,7 +649,10 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath) error {
}
func addSubTable(doc *tomlDoc, name string, v reflect.Value, path encPath) error {
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(name).String(), Err: errDepthLimit()}
}
switch v.Kind() {
case reflect.Struct:
if err := buildStructDoc(v, sub, path.key(name)); err != nil {
@@ -692,7 +731,10 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath) err
return err
}
}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) {
return &EncodeError{Path: apath.elem(i).String(), Err: errDepthLimit()}
}
switch {
case ev.Type() == orderedMapType:
om := ev.Interface().(OrderedMap)
@@ -795,6 +837,13 @@ func resolveElement(v reflect.Value, path encPath) (reflect.Value, error) {
// nested-array representations the emitter understands. Slices and arrays are
// recursively normalised so that nested arrays (e.g. [][]int) work.
func normaliseValue(v reflect.Value) (any, error) {
return normaliseValueAt(v, 0)
}
func normaliseValueAt(v reflect.Value, depth int) (any, error) {
if atDepthLimit(depth) {
return nil, errDepthLimit()
}
if !v.IsValid() {
return nil, fmt.Errorf("cannot encode nil value")
}
@@ -818,7 +867,7 @@ func normaliseValue(v reflect.Value) (any, error) {
// 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 normaliseValueAt(rv, depth+1)
}
return mv, nil
}
@@ -842,7 +891,7 @@ func normaliseValue(v reflect.Value) (any, error) {
om := v.Interface().(OrderedMap)
out := make(map[string]any, om.Len())
for _, k := range om.Keys() {
val, err := normaliseValue(reflect.ValueOf(om.values[k]))
val, err := normaliseValueAt(reflect.ValueOf(om.values[k]), depth+1)
if err != nil {
return nil, fmt.Errorf("[%s]: %w", k, err)
}
@@ -882,7 +931,7 @@ func normaliseValue(v reflect.Value) (any, error) {
}
out := make(map[string]any, v.Len())
for _, k := range v.MapKeys() {
val, err := normaliseValue(v.MapIndex(k))
val, err := normaliseValueAt(v.MapIndex(k), depth+1)
if err != nil {
return nil, fmt.Errorf("[%s]: %w", k.String(), err)
}
@@ -892,7 +941,7 @@ func normaliseValue(v reflect.Value) (any, error) {
case reflect.Slice, reflect.Array:
items := make([]any, v.Len())
for i := range v.Len() {
val, err := normaliseValue(v.Index(i))
val, err := normaliseValueAt(v.Index(i), depth+1)
if err != nil {
return nil, fmt.Errorf("[%d]: %w", i, err)
}
+102
View File
@@ -2024,3 +2024,105 @@ func TestMarshalAppend(t *testing.T) {
t.Errorf("MarshalAppend with an unencodable value = %q, want an error", out3)
}
}
func TestMarshalCyclicData(t *testing.T) {
t.Run("a cyclic struct is an error, not a crash", func(t *testing.T) {
type Node struct {
Name string `toml:"name"`
Next *Node `toml:"next"`
}
a := &Node{Name: "a"}
b := &Node{Name: "b"}
a.Next = b
b.Next = a
_, err := Marshal(a)
if err == nil {
t.Fatal("Marshal(cyclic) succeeded, want an error")
}
if !strings.Contains(err.Error(), "may be cyclic") {
t.Errorf("err = %v, want it to name the cycle", err)
}
})
t.Run("a cyclic map is an error", func(t *testing.T) {
m := map[string]any{}
m["self"] = m
if _, err := Marshal(m); err == nil {
t.Fatal("Marshal(cyclic map) succeeded, want an error")
}
})
t.Run("a cyclic value array is an error", func(t *testing.T) {
m := map[string]any{}
m["items"] = []any{int64(1), m}
if _, err := Marshal(map[string]any{"outer": m}); err == nil {
t.Fatal("Marshal(cyclic array) succeeded, want an error")
}
})
t.Run("a deeply nested but finite value encodes", func(t *testing.T) {
type Node struct {
Next *Node `toml:"next"`
}
root := &Node{}
cur := root
for range 5000 {
cur.Next = &Node{}
cur = cur.Next
}
if _, err := Marshal(root); err != nil {
t.Errorf("Marshal(deep) = %v, want nil", err)
}
})
}
func TestUnmarshalWithOptions(t *testing.T) {
data := []byte("host = \"db\"\nextra = 1\n")
type Config struct {
Host string `toml:"host,required"`
}
t.Run("the zero value takes the defaults", func(t *testing.T) {
var cfg struct {
Host string `toml:"host"`
Extra int `toml:"extra"`
}
if err := UnmarshalWithOptions(data, &cfg, DecodeOptions{}); err != nil {
t.Fatal(err)
}
if cfg.Host != "db" || cfg.Extra != 1 {
t.Errorf("decoded %+v", cfg)
}
})
t.Run("strict and required work in one call", func(t *testing.T) {
err := UnmarshalWithOptions(data, &Config{}, DecodeOptions{DisallowUnknownFields: true})
want := `interpres: unknown field "extra" for interpres.Config`
if err == nil || err.Error() != want {
t.Errorf("err = %v, want %q", err, want)
}
})
t.Run("UseNumber keeps the literal", func(t *testing.T) {
var tree map[string]any
in := []byte("n = 1_000\n")
if err := UnmarshalWithOptions(in, &tree, DecodeOptions{UseNumber: true}); err != nil {
t.Fatal(err)
}
if got, ok := tree["n"].(Number); !ok || got != "1_000" {
t.Errorf("n = %#v, want Number(\"1_000\")", tree["n"])
}
})
t.Run("the limits apply", func(t *testing.T) {
var nested strings.Builder
nested.WriteString("x = ")
for range 20 {
nested.WriteString("[")
}
nested.WriteString("1")
for range 20 {
nested.WriteString("]")
}
var tree map[string]any
if err := UnmarshalWithOptions([]byte(nested.String()), &tree, DecodeOptions{MaxDepth: 10}); err == nil {
t.Error("a document over MaxDepth decoded, want an error")
}
if err := UnmarshalWithOptions([]byte("a = 1\n"), &tree, DecodeOptions{MaxInputSize: 2}); err == nil {
t.Error("a document over MaxInputSize decoded, want an error")
}
})
}
+32 -2
View File
@@ -327,6 +327,35 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
return dec.decode(tree, v)
}
// DecodeOptions gathers the options a one-shot decode call can set, the
// struct-shaped alternative to building a Decoder for a single document. The
// zero value decodes with the defaults: unknown keys ignored, numbers
// evaluated, and no limit beyond the nesting default.
type DecodeOptions struct {
// DisallowUnknownFields rejects a key with no matching struct field.
DisallowUnknownFields bool
// UseNumber keeps the numbers of the document as Number literals.
UseNumber bool
// MaxDepth bounds how deeply arrays and inline tables may nest; 0 takes
// the default of 10000.
MaxDepth int
// MaxInputSize bounds the document size in bytes; 0 takes no limit.
MaxInputSize int
}
// UnmarshalWithOptions decodes data into v with the options set, the one-shot
// form of building a Decoder. See DecodeOptions for the fields and their
// defaults.
func UnmarshalWithOptions(data []byte, v any, opts DecodeOptions) error {
dec := &Decoder{
disallowUnknown: opts.DisallowUnknownFields,
useNumber: opts.UseNumber,
maxDepth: opts.MaxDepth,
maxInputSize: opts.MaxInputSize,
}
return dec.DecodeContext(context.Background(), data, v)
}
// Marshaler is the interface implemented by types that can produce a custom
// TOML representation of themselves. MarshalTOML returns a value that Marshal
// then encodes as if the returned value had been passed in its place, which
@@ -407,8 +436,9 @@ type UnmarshalerContext interface {
// returns. time.Duration is written in its canonical Go form, `1h30m0s`.
// - nil pointer fields are omitted.
//
// Marshal cannot encode cyclic data structures; passing one will loop until
// the stack overflows. The output is not guaranteed to be byte-identical to
// Marshal rejects a value that nests deeper than 10000 levels with an error
// naming the limit, so cyclic data is reported instead of running the stack
// out. The output is not guaranteed to be byte-identical to
// the input that produced v: comments, whitespace, key order (for maps),
// string quoting style, and the choice between `[table]` headers and inline
// tables are not preserved.