Skip to content

Commit df91d43

Browse files
committed
deep copy default values for mapstructure to remove footgun
1 parent 0a0ca55 commit df91d43

File tree

5 files changed

+190
-23
lines changed

5 files changed

+190
-23
lines changed

cmd/tools/gendynamicconfig/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ type {{.P.Name}}TypedSetting[T any] setting[T, func({{.P.GoArgs}})]
158158
type {{.P.Name}}TypedConstrainedDefaultSetting[T any] constrainedDefaultSetting[T, func({{.P.GoArgs}})]
159159
160160
// New{{.P.Name}}TypedSetting creates a setting that uses mapstructure to handle complex structured
161-
// values. The value from dynamic config will be copied over a shallow copy of 'def', which means
162-
// 'def' must not contain any non-nil slices, maps, or pointers.
161+
// values. The value from dynamic config will be _merged_ over a deep copy of 'def'. Be very careful
162+
// when using non-empty maps or slices as defaults, the result may not be what you want.
163163
func New{{.P.Name}}TypedSetting[T any](key Key, def T, description string) {{.P.Name}}TypedSetting[T] {
164164
s := {{.P.Name}}TypedSetting[T]{
165165
key: key,

common/dynamicconfig/collection.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,9 @@ func convertMap(val any) (map[string]any, error) {
583583
// Note that any failure in conversion of _any_ field will result in the overall default being used,
584584
// ignoring the fields that successfully converted.
585585
//
586-
// Note that the default value will be shallow-copied, so it should not have any deep structure.
587-
// Scalar types and values are fine, and slice and map types are fine too as long as they're set to
588-
// nil in the default.
586+
// Note that the default value will be deep-copied and then passed to mapstructure with the
587+
// ZeroFields setting false, so the config value will be _merged_ on top of it. Be very careful
588+
// when using non-empty maps or slices, the result may not be what you want.
589589
//
590590
// To avoid confusion, the default passed to ConvertStructure should be either the same as the
591591
// overall default for the setting (if you want any value set to be merged over the default, i.e.
@@ -598,10 +598,9 @@ func ConvertStructure[T any](def T) func(v any) (T, error) {
598598
return typedV, nil
599599
}
600600

601-
// TODO: This does a shallow copy, but we should do a deep copy instead to make this
602-
// interface less error-prone. Now that we have conversion caching, the cost of a deep
603-
// copy isn't a problem.
604-
out := def
601+
// Deep-copy the default and decode over it. This allows using e.g. a struct with some
602+
// default fields filled in and a config that only set some fields.
603+
out := deepCopyForMapstructure(def)
605604

606605
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
607606
Result: &out,

common/dynamicconfig/deepcopy.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// The MIT License
2+
//
3+
// Copyright (c) 2025 Temporal Technologies Inc. All rights reserved.
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
// THE SOFTWARE.
22+
23+
package dynamicconfig
24+
25+
import (
26+
"fmt"
27+
"reflect"
28+
)
29+
30+
// deepCopyForMapstructure does a simple deep copy of T. Fancy cases (anything other than plain
31+
// old data) is not handled and will panic.
32+
func deepCopyForMapstructure[T any](t T) T {
33+
return deepCopyValue(reflect.ValueOf(t)).Interface().(T)
34+
}
35+
36+
func deepCopyValue(v reflect.Value) reflect.Value {
37+
switch v.Kind() {
38+
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
39+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
40+
reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.String:
41+
nv := reflect.New(v.Type()).Elem()
42+
nv.Set(v)
43+
return nv
44+
case reflect.Array:
45+
nv := reflect.New(v.Type()).Elem()
46+
for i := range v.Len() {
47+
nv.Index(i).Set(deepCopyValue(v.Index(i)))
48+
}
49+
return nv
50+
case reflect.Map:
51+
if v.IsNil() {
52+
return v
53+
}
54+
nv := reflect.MakeMapWithSize(v.Type(), v.Len())
55+
for i := v.MapRange(); i.Next(); {
56+
nv.SetMapIndex(i.Key(), deepCopyValue(i.Value()))
57+
}
58+
return nv
59+
case reflect.Pointer:
60+
if v.IsNil() {
61+
return v
62+
}
63+
return deepCopyValue(v.Elem()).Addr()
64+
case reflect.Slice:
65+
if v.IsNil() {
66+
return v
67+
}
68+
nv := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
69+
for i := range v.Len() {
70+
nv.Index(i).Set(deepCopyValue(v.Index(i)))
71+
}
72+
return nv
73+
case reflect.Struct:
74+
nv := reflect.New(v.Type()).Elem()
75+
for i := range v.Type().NumField() {
76+
nv.Field(i).Set(deepCopyValue(v.Field(i)))
77+
}
78+
return nv
79+
default:
80+
panic(fmt.Sprintf("Can't deep copy value of type %T: %v", v, v))
81+
}
82+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// The MIT License
2+
//
3+
// Copyright (c) 2025 Temporal Technologies Inc. All rights reserved.
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
// THE SOFTWARE.
22+
23+
package dynamicconfig
24+
25+
import (
26+
"testing"
27+
28+
"github.com/stretchr/testify/assert"
29+
)
30+
31+
func TestDeepCopy_IntSlice(t *testing.T) {
32+
a := []int{1, 2, 3}
33+
b := deepCopyForMapstructure(a)
34+
a[1]++
35+
assert.NotEqual(t, a[1], b[1])
36+
}
37+
38+
func TestDeepCopy_StringSlice(t *testing.T) {
39+
a := []string{"one", "two", "three"}
40+
b := deepCopyForMapstructure(a)
41+
a[1] = "four"
42+
assert.NotEqual(t, a[1], b[1])
43+
}
44+
45+
func TestDeepCopy_SimpleStruct(t *testing.T) {
46+
a := struct {
47+
A, B int
48+
C string
49+
D [2]float32
50+
}{A: 4, B: 6, C: "eight", D: [2]float32{5.555, 6.666}}
51+
b := deepCopyForMapstructure(a)
52+
a.B++
53+
a.C = "ten"
54+
a.D[0] *= 1.1
55+
assert.NotEqual(t, a.B, b.B)
56+
assert.NotEqual(t, a.C, b.C)
57+
assert.NotEqual(t, a.D, b.D)
58+
}
59+
60+
func TestDeepCopy_Pointer(t *testing.T) {
61+
v := 10
62+
a := &v
63+
b := deepCopyForMapstructure(a)
64+
(*a)++
65+
assert.NotEqual(t, *a, *b)
66+
}
67+
68+
func TestDeepCopy_Pointers(t *testing.T) {
69+
type L struct {
70+
L *L
71+
V *int
72+
}
73+
v := 10
74+
a := L{
75+
L: &L{
76+
L: &L{
77+
L: &L{
78+
V: &v,
79+
},
80+
},
81+
},
82+
}
83+
b := deepCopyForMapstructure(a)
84+
(*a.L.L.L.V)++
85+
assert.NotEqual(t, *a.L.L.L.V, *b.L.L.L.V)
86+
}

common/dynamicconfig/setting_gen.go

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)