Skip to content

Commit 3e319e7

Browse files
committed
feat: implement xyaml.UnmarshalStrict
Works like a normal `yaml.Unmarshal`, but also verifies that there are no extra fields in the provided YAML data, and provides a meaningful error back. Signed-off-by: Artem Chernyshev <[email protected]>
1 parent 7c0324f commit 3e319e7

File tree

6 files changed

+293
-2
lines changed

6 files changed

+293
-2
lines changed

go.mod

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ module github.com/siderolabs/gen
22

33
go 1.24.0
44

5-
require github.com/stretchr/testify v1.9.0
5+
require (
6+
github.com/stretchr/testify v1.9.0
7+
gopkg.in/yaml.v3 v3.0.1
8+
)
69

710
require (
811
github.com/davecgh/go-spew v1.1.1 // indirect
912
github.com/pmezard/go-difflib v1.0.0 // indirect
10-
gopkg.in/yaml.v3 v3.0.1 // indirect
1113
)

xyaml/testdata/invalid-nested.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
field: a
3+
slice:
4+
- field: b
5+
this: shouldn't be there
6+
map:
7+
some: value

xyaml/testdata/invalid.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
field: hi
3+
g: bye

xyaml/testdata/valid.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
field: a
3+
slice:
4+
- field: b
5+
map:
6+
some: value

xyaml/xyaml.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
// Package xyaml contains utility functions for parsing YAML.
6+
package xyaml
7+
8+
import (
9+
"errors"
10+
"fmt"
11+
"reflect"
12+
"strings"
13+
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
// UnmarshalStrict decodes YAML document validating that there are no extra fields found.
18+
func UnmarshalStrict[T any](data []byte, t T) error {
19+
var node yaml.Node
20+
21+
if err := yaml.Unmarshal(data, &node); err != nil {
22+
return err
23+
}
24+
25+
if err := checkUnknownKeys(t, &node); err != nil {
26+
return err
27+
}
28+
29+
return node.Decode(t)
30+
}
31+
32+
func checkUnknownKeys(t any, node *yaml.Node) error {
33+
if node.Kind == yaml.DocumentNode {
34+
if len(node.Content) == 0 {
35+
return nil
36+
}
37+
38+
node = node.Content[0]
39+
}
40+
41+
unknown, err := internalCheckUnknownKeys(reflect.TypeOf(t), node)
42+
if err != nil {
43+
return err
44+
}
45+
46+
if unknown != nil {
47+
var data []byte
48+
49+
if data, err = yaml.Marshal(unknown); err != nil {
50+
return fmt.Errorf("failed to marshal error summary %w", err)
51+
}
52+
53+
return fmt.Errorf("unknown keys found during decoding:\n%s", string(data))
54+
}
55+
56+
return nil
57+
}
58+
59+
// structKeys builds a set of known YAML fields by name and their indexes in the struct.
60+
//
61+
//nolint:gocyclo
62+
func structKeys(typ reflect.Type) (map[string][]int, reflect.Type) {
63+
fields := reflect.VisibleFields(typ)
64+
65+
availableKeys := make(map[string][]int, len(fields))
66+
67+
for _, field := range fields {
68+
if tag := field.Tag.Get("yaml"); tag != "" {
69+
if tag == "-" {
70+
continue
71+
}
72+
73+
idx := strings.IndexByte(tag, ',')
74+
75+
inlined := false
76+
77+
if idx >= 0 {
78+
options := strings.Split(tag[idx+1:], ",")
79+
80+
for _, opt := range options {
81+
if opt == "inline" {
82+
inlined = true
83+
}
84+
}
85+
}
86+
87+
// handle inlined `map` objects, inlining structs in general is not supported yet
88+
if inlined {
89+
inlinedTyp := field.Type
90+
91+
if inlinedTyp.Kind() == reflect.Map {
92+
return nil, inlinedTyp
93+
}
94+
}
95+
96+
if idx == -1 {
97+
availableKeys[tag] = field.Index
98+
} else if idx > 0 {
99+
availableKeys[tag[:idx]] = field.Index
100+
}
101+
} else {
102+
availableKeys[strings.ToLower(field.Name)] = field.Index
103+
}
104+
}
105+
106+
return availableKeys, typ
107+
}
108+
109+
var typeOfInterfaceAny = reflect.TypeOf((*any)(nil)).Elem()
110+
111+
//nolint:gocyclo,cyclop
112+
func internalCheckUnknownKeys(typ reflect.Type, spec *yaml.Node) (unknown any, err error) {
113+
for typ.Kind() == reflect.Ptr {
114+
typ = typ.Elem()
115+
}
116+
117+
// anything can be unmarshaled into `interface{}`
118+
if typ == typeOfInterfaceAny {
119+
return nil, nil
120+
}
121+
122+
switch spec.Kind { //nolint:exhaustive // not checking for scalar types
123+
case yaml.MappingNode:
124+
var availableKeys map[string][]int
125+
126+
switch typ.Kind() { //nolint:exhaustive
127+
case reflect.Map:
128+
// any key is fine in the map
129+
case reflect.Struct:
130+
availableKeys, typ = structKeys(typ)
131+
default:
132+
return unknown, fmt.Errorf("unexpected type for yaml mapping: %s", typ)
133+
}
134+
135+
for i := 0; i < len(spec.Content); i += 2 {
136+
keyNode := spec.Content[i]
137+
138+
if keyNode.Kind != yaml.ScalarNode {
139+
return unknown, errors.New("unexpected mapping key type")
140+
}
141+
142+
key := keyNode.Value
143+
144+
var elemType reflect.Type
145+
146+
switch typ.Kind() { //nolint:exhaustive
147+
case reflect.Struct:
148+
fieldIndex, ok := availableKeys[key]
149+
if !ok {
150+
if unknown == nil {
151+
unknown = map[string]any{}
152+
}
153+
154+
unknown.(map[string]any)[key] = spec.Content[i+1]
155+
156+
continue
157+
}
158+
159+
elemType = typ.FieldByIndex(fieldIndex).Type
160+
case reflect.Map:
161+
elemType = typ.Elem()
162+
}
163+
164+
// validate nested values
165+
innerUnknown, err := internalCheckUnknownKeys(elemType, spec.Content[i+1])
166+
if err != nil {
167+
return unknown, err
168+
}
169+
170+
if innerUnknown != nil {
171+
if unknown == nil {
172+
unknown = map[string]any{}
173+
}
174+
175+
unknown.(map[string]any)[key] = innerUnknown
176+
}
177+
}
178+
case yaml.SequenceNode:
179+
if typ.Kind() != reflect.Slice {
180+
return unknown, fmt.Errorf("unexpected type for yaml sequence: %s", typ)
181+
}
182+
183+
for i := range len(spec.Content) {
184+
innerUnknown, err := internalCheckUnknownKeys(typ.Elem(), spec.Content[i])
185+
if err != nil {
186+
return unknown, err
187+
}
188+
189+
if innerUnknown != nil {
190+
if unknown == nil {
191+
unknown = []any{}
192+
}
193+
194+
unknown = append(unknown.([]any), innerUnknown)
195+
}
196+
}
197+
}
198+
199+
return unknown, nil
200+
}

xyaml/xyaml_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
package xyaml_test
6+
7+
import (
8+
_ "embed"
9+
"testing"
10+
11+
"github.com/siderolabs/gen/xyaml"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
type A struct {
16+
Field string `yaml:"field"`
17+
Map map[string]string `yaml:"map"`
18+
Slice []A `yaml:"slice"`
19+
}
20+
21+
//go:embed testdata/valid.yaml
22+
var valid []byte
23+
24+
//go:embed testdata/invalid.yaml
25+
var invalid []byte
26+
27+
//go:embed testdata/invalid-nested.yaml
28+
var invalidNested []byte
29+
30+
func TestUnmarshalStrict(t *testing.T) {
31+
for _, tt := range []struct {
32+
name string
33+
err string
34+
data []byte
35+
}{
36+
{
37+
name: "valid",
38+
data: valid,
39+
},
40+
{
41+
name: "invalid",
42+
data: invalid,
43+
err: "unknown keys",
44+
},
45+
{
46+
name: "invalid nested",
47+
data: invalidNested,
48+
err: "this",
49+
},
50+
{
51+
name: "empty",
52+
data: []byte{},
53+
},
54+
} {
55+
t.Run(tt.name, func(t *testing.T) {
56+
var a A
57+
58+
err := xyaml.UnmarshalStrict(tt.data, &a)
59+
60+
if tt.err != "" {
61+
require.ErrorContains(t, err, tt.err)
62+
63+
return
64+
}
65+
66+
if len(tt.data) != 0 {
67+
require.NotEmpty(t, a)
68+
}
69+
70+
require.NoError(t, err)
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)