From 10391a090fa56e540a5ca845fccf5efb1d71b10a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 00:21:46 +0200 Subject: [PATCH] feat: bound the encoder walk and add UnmarshalWithOptions Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 6 +++ docs/API.md | 18 ++++++++- encode.go | 77 ++++++++++++++++++++++++++++++------- encode_test.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ interpres.go | 34 ++++++++++++++++- 5 files changed, 219 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0646c..e418fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 5ee78ad..fad99e6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 | |---|---|---| diff --git a/encode.go b/encode.go index fa6e82d..7b13d07 100644 --- a/encode.go +++ b/encode.go @@ -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) } diff --git a/encode_test.go b/encode_test.go index c29a424..8e09535 100644 --- a/encode_test.go +++ b/encode_test.go @@ -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") + } + }) +} diff --git a/interpres.go b/interpres.go index def72b9..5db82f1 100644 --- a/interpres.go +++ b/interpres.go @@ -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.