From 3c8ac859c05a6d74e01541d665990a667d3e072a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Thu, 17 Sep 2026 23:11:38 +0200 Subject: [PATCH] fix(decode): report the smallest unknown field deterministically Assisted-by: GLM 5.3 --- CHANGELOG.md | 3 +++ decode.go | 19 ++++++++++++++++--- docs/API.md | 3 ++- interpres_test.go | 21 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f866942..e4c0e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Strict decoding reports the smallest unknown key. Several unknown keys in + one table made the message depend on Go's random map iteration order, so + the same document reported different keys across runs. - A `MarshalTOML` result of `nil` with a nil error fails the marshal with `MarshalTOML returned a nil value`. The field silently vanished before, and inside a value array the nil result reached reflection as a zero value and diff --git a/decode.go b/decode.go index 16341e9..39ee1fe 100644 --- a/decode.go +++ b/decode.go @@ -107,12 +107,25 @@ func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error { func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error { schema := cachedStructSchema(dst.Type()) + if d.disallowUnknown { + // Map iteration order is random, so pick the unknown key to report + // deterministically: the smallest one. + unknown := "" + for key := range tbl { + if _, ok := schema.byName[strings.ToLower(key)]; ok { + continue + } + if unknown == "" || key < unknown { + unknown = key + } + } + if unknown != "" { + return fmt.Errorf("interpres: unknown field %q for %s", unknown, dst.Type()) + } + } for key, val := range tbl { field, ok := schema.byName[strings.ToLower(key)] if !ok { - if d.disallowUnknown { - return fmt.Errorf("interpres: unknown field %q for %s", key, dst.Type()) - } if schema.embedMaps != nil { // Leftover keys land in an untagged embedded map, the inverse // of the encoder inlining that map's entries. diff --git a/docs/API.md b/docs/API.md index 717fce3..bfa859c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -186,7 +186,8 @@ A typo such as `database_urls` then fails with `interpres: unknown field "database_urls" for main.Config` instead of a silent default-zero run. Strictness applies to every struct the decode reaches, at any depth, including struct elements inside slices; map destinations accept every -key by nature. +key by nature. When several keys are unknown, the message names the smallest +one, so it does not depend on map iteration order. ### Cancellation diff --git a/interpres_test.go b/interpres_test.go index f33970a..c7373fe 100644 --- a/interpres_test.go +++ b/interpres_test.go @@ -5,6 +5,7 @@ package interpres import ( "math" + "strings" "testing" "time" ) @@ -316,6 +317,26 @@ func TestDisallowUnknownFields(t *testing.T) { } } +func TestDisallowUnknownFieldsReportsSmallestKey(t *testing.T) { + // Map iteration order is random, so the reported key must be chosen + // deterministically: the smallest unknown key, whichever order the map + // iterates in. + type C struct { + Known string `toml:"known"` + } + data := []byte("known = \"x\"\nzeta = 1\nalpha = 2\nmu = 3\n") + for range 20 { + var c C + err := NewDecoder().DisallowUnknownFields().Decode(data, &c) + if err == nil { + t.Fatal("expected error for unknown fields") + } + if !strings.Contains(err.Error(), `unknown field "alpha"`) { + t.Fatalf("err = %v, want the smallest unknown key alpha", err) + } + } +} + func TestSkippedFieldTag(t *testing.T) { type C struct { Keep string `toml:"keep"`