From 158ade2538c83f6fa936d33e6f15ae8edff2eb4c Mon Sep 17 00:00:00 2001 From: Tommy Date: Sat, 22 Aug 2026 12:14:00 +0200 Subject: [PATCH] fix(dict): keys accepts maps of any value type, not just map[string]interface{} keys() was declared as func(...map[string]interface{}) []string, so text/template's static argument type-checking rejected any other map type (map[string]string, map[string]*T, ...) before the function body ever ran, panicking with "wrong type for value; expected map[string]interface {}; got ...". Change the signature to variadic interface{} and use reflection to walk the map keys, returning an error for non-map arguments or maps with non-string keys. Fixes #455 Signed-off-by: Tommy --- dict.go | 26 ++++++++++++++++++++++---- dict_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/dict.go b/dict.go index 4315b354..73193b93 100644 --- a/dict.go +++ b/dict.go @@ -1,6 +1,9 @@ package sprig import ( + "fmt" + "reflect" + "dario.cat/mergo" "github.com/mitchellh/copystructure" ) @@ -37,14 +40,29 @@ func pluck(key string, d ...map[string]interface{}) []interface{} { return res } -func keys(dicts ...map[string]interface{}) []string { +// keys returns the string keys of one or more maps. Unlike most sprig dict +// functions, it accepts maps with any value type (e.g. map[string]string, +// map[string]*MyStruct), not just map[string]interface{}, since text/template +// performs static argument type-checking and would otherwise reject any map +// whose value type isn't exactly interface{}. +func keys(dicts ...interface{}) ([]string, error) { k := []string{} for _, dict := range dicts { - for key := range dict { - k = append(k, key) + v := reflect.ValueOf(dict) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Map { + return nil, fmt.Errorf("keys: argument must be a map, got %T", dict) + } + for _, mk := range v.MapKeys() { + if mk.Kind() != reflect.String { + return nil, fmt.Errorf("keys: map key type must be string, got %s", mk.Kind()) + } + k = append(k, mk.String()) } } - return k + return k, nil } func pick(dict map[string]interface{}, keys ...string) map[string]interface{} { diff --git a/dict_test.go b/dict_test.go index c829daa5..604b9f87 100644 --- a/dict_test.go +++ b/dict_test.go @@ -82,6 +82,42 @@ func TestKeys(t *testing.T) { } } +func TestKeysNonInterfaceMaps(t *testing.T) { + // keys must accept maps whose value type isn't interface{} (e.g. + // map[string]string, map[string]int), since Go's text/template does + // static argument type-checking and would otherwise reject them before + // the function body ever runs. See #455. + tests := []struct { + tpl string + expect string + vars interface{} + }{ + { + tpl: `{{ .M | keys | sortAlpha }}`, + expect: "[bar foo]", + vars: map[string]interface{}{"M": map[string]string{"foo": "1", "bar": "2"}}, + }, + { + tpl: `{{ .M | keys | sortAlpha }}`, + expect: "[bar foo]", + vars: map[string]interface{}{"M": map[string]int{"foo": 1, "bar": 2}}, + }, + } + for _, tt := range tests { + if err := runtv(tt.tpl, tt.expect, tt.vars); err != nil { + t.Error(err) + } + } +} + +func TestKeysNonMapErrors(t *testing.T) { + tpl := `{{ .M | keys }}` + _, err := runRaw(tpl, map[string]interface{}{"M": "not a map"}) + if err == nil { + t.Error("expected an error when keys is given a non-map argument") + } +} + func TestPick(t *testing.T) { tests := map[string]string{ `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" | len -}}`: "1",