diff --git a/internal/copystructure/copystructure.go b/internal/copystructure/copystructure.go index c55897aaa..3bc4bf04c 100644 --- a/internal/copystructure/copystructure.go +++ b/internal/copystructure/copystructure.go @@ -80,7 +80,7 @@ func copyValue(original reflect.Value) (any, error) { return nil, err } ptr := reflect.New(original.Type().Elem()) - ptr.Elem().Set(reflect.ValueOf(copied)) + setValue(ptr.Elem(), copied) return ptr.Interface(), nil case reflect.Slice: @@ -106,13 +106,20 @@ func copyValue(original reflect.Value) (any, error) { return copied.Interface(), nil case reflect.Struct: - copied := reflect.New(original.Type()).Elem() + t := original.Type() + for field := range t.Fields() { + // Unexported fields can't be read or set through reflection. + if !field.IsExported() { + return nil, fmt.Errorf("unsupported type %v: struct with unexported fields", t) + } + } + copied := reflect.New(t).Elem() for i := 0; i < original.NumField(); i++ { elem, err := copyValue(original.Field(i)) if err != nil { return nil, err } - copied.Field(i).Set(reflect.ValueOf(elem)) + setValue(copied.Field(i), elem) } return copied.Interface(), nil @@ -126,3 +133,13 @@ func copyValue(original reflect.Value) (any, error) { return original.Interface(), fmt.Errorf("unsupported type %v", original) } } + +// setValue sets dst to v. A nil v (the copy of a nil interface) is stored as +// the zero value of dst's type, since reflect.ValueOf(nil) can't be passed to Set. +func setValue(dst reflect.Value, v any) { + if v == nil { + dst.Set(reflect.Zero(dst.Type())) + return + } + dst.Set(reflect.ValueOf(v)) +} diff --git a/internal/copystructure/copystructure_test.go b/internal/copystructure/copystructure_test.go index b21af6460..e970a8188 100644 --- a/internal/copystructure/copystructure_test.go +++ b/internal/copystructure/copystructure_test.go @@ -18,6 +18,7 @@ package copystructure import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -193,37 +194,67 @@ func TestCopy_Map(t *testing.T) { } func TestCopy_Struct(t *testing.T) { - type TestStruct struct { - Name string - Age int - Active bool - Scores []int - Metadata map[string]any - } + t.Run("exported fields", func(t *testing.T) { + type TestStruct struct { + Name string + Age int + Active bool + Scores []int + Metadata map[string]any + } - input := TestStruct{ - Name: "John", - Age: 30, - Active: true, - Scores: []int{95, 87, 92}, - Metadata: map[string]any{ - "level": "advanced", - "tags": []string{"go", "programming"}, - }, - } + input := TestStruct{ + Name: "John", + Age: 30, + Active: true, + Scores: []int{95, 87, 92}, + Metadata: map[string]any{ + "level": "advanced", + "tags": []string{"go", "programming"}, + }, + } - result, err := Copy(input) - require.NoError(t, err) + result, err := Copy(input) + require.NoError(t, err) - resultStruct, ok := result.(TestStruct) - require.True(t, ok) - assert.Equal(t, input, resultStruct) + resultStruct, ok := result.(TestStruct) + require.True(t, ok) + assert.Equal(t, input, resultStruct) + + // Verify deep copy + input.Name = "Modified" + input.Scores[0] = 999 + assert.Equal(t, "John", resultStruct.Name) + assert.Equal(t, 95, resultStruct.Scores[0]) + }) + + t.Run("nil interface field", func(t *testing.T) { + type WithAny struct { + Name string + Extra any + } - // Verify deep copy - input.Name = "Modified" - input.Scores[0] = 999 - assert.Equal(t, "John", resultStruct.Name) - assert.Equal(t, 95, resultStruct.Scores[0]) + input := map[string]any{"s": WithAny{Name: "a"}} + + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("unexported fields", func(t *testing.T) { + type withUnexported struct { + Name string + secret int + } + + _, err := Copy(map[string]any{"s": withUnexported{Name: "a", secret: 1}}) + require.Error(t, err) + }) + + t.Run("time.Time", func(t *testing.T) { + _, err := Copy(map[string]any{"t": time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}) + require.Error(t, err) + }) } func TestCopy_Pointer(t *testing.T) { @@ -269,6 +300,18 @@ func TestCopy_Pointer(t *testing.T) { require.NoError(t, err) assert.Nil(t, result) }) + + t.Run("pointer to nil interface", func(t *testing.T) { + var value any + + result, err := Copy(&value) + require.NoError(t, err) + + resultPtr, ok := result.(*any) + require.True(t, ok) + assert.Nil(t, *resultPtr) + assert.NotSame(t, &value, resultPtr) + }) } func TestCopy_Interface(t *testing.T) { diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 2d83abe27..c7aac495b 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -329,6 +329,13 @@ func TestInstallRelease_NoName(t *testing.T) { assert.ErrorContains(t, err, "no name provided") } +func TestInstallRelease_UnsupportedValueType(t *testing.T) { + instAction := installAction(t) + vals := map[string]any{"deployedAt": time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)} + _, err := instAction.Run(buildChart(), vals) + assert.ErrorContains(t, err, "struct with unexported fields") +} + func TestInstallRelease_WithNotes(t *testing.T) { is := assert.New(t) req := require.New(t)