diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index e03c13b38..b928dfedd 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -20,6 +20,7 @@ import ( "bytes" "encoding/json" "maps" + "math" "strings" "text/template" @@ -157,7 +158,7 @@ func fromYAMLArray(str string) []any { func toTOML(v any) string { b := bytes.NewBuffer(nil) e := toml.NewEncoder(b) - err := e.Encode(v) + err := e.Encode(normalizeForTOML(v)) if err != nil { return err.Error() } @@ -172,13 +173,56 @@ func toTOML(v any) string { func mustToTOML(v any) string { b := bytes.NewBuffer(nil) e := toml.NewEncoder(b) - err := e.Encode(v) + err := e.Encode(normalizeForTOML(v)) if err != nil { panic(err) } return b.String() } +// normalizeForTOML walks v and rewrites any float64 that is a whole number +// (within the int64 range) as an int64. Helm values round-trip through JSON, +// so every YAML number arrives in the template engine as a float64 regardless +// of whether the source was written as "9" or "9.0"; the BurntSushi TOML +// encoder then writes float64(9) as "9.0", which surprises users. This brings +// toToml in line with encoding/json (which already drops trailing ".0" for +// whole-number float64). Non-whole floats and values outside the int64 range +// are left untouched so that true floats still round-trip as floats. +// +// This fix is intentionally scoped to the TOML encoding path. #13533 solved +// the same issue by switching the values loader to json.Decoder.UseNumber, +// which changed every numeric value to json.Number globally and broke charts +// relying on typeOf/typeIs returning "float64" (#30880). Normalizing only +// inside toTOML preserves the in-template type of values so that regression +// cannot recur. +func normalizeForTOML(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, val := range x { + out[k] = normalizeForTOML(val) + } + return out + case []any: + out := make([]any, len(x)) + for i, val := range x { + out[i] = normalizeForTOML(val) + } + return out + case float64: + // Guard against NaN/Inf and against Go's implementation-defined + // behavior for out-of-range float-to-int conversions. 1<<63 is the + // first positive float64 strictly greater than math.MaxInt64. + if math.IsNaN(x) || math.IsInf(x, 0) || x != math.Trunc(x) || + x >= 1<<63 || x < -(1<<63) { + return x + } + return int64(x) + default: + return v + } +} + // fromTOML converts a TOML document into a map[string]interface{}. // // This is not a general-purpose TOML parser, and will not parse all valid diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go index be9d0153f..a76a9810b 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -86,6 +86,32 @@ keyInElement1 = "valueInElement1"`, tpl: `{{ toToml . }}`, expect: "[mast]\n sail = \"white\"\n", vars: map[string]map[string]string{"mast": {"sail": "white"}}, + }, { + // Regression for https://github.com/helm/helm/issues/32035 + // Helm values round-trip through JSON so YAML integers arrive as + // float64. toToml must still emit them as TOML integers, matching + // what toJson already does. Fractional floats must be preserved. + tpl: `{{ toToml . }}`, + expect: "bar = 9\nbaz = 9.5\n\n[[items]]\n id = 1\n\n" + + "[[items]]\n id = 2\n\n[nested]\n deep = 42\n", + vars: map[string]any{ + "bar": float64(9), + "baz": float64(9.5), + "nested": map[string]any{ + "deep": float64(42), + }, + "items": []any{ + map[string]any{"id": float64(1)}, + map[string]any{"id": float64(2)}, + }, + }, + }, { + // Regression for https://github.com/helm/helm/issues/32035 + // Non-finite floats and floats outside the int64 range must stay + // floats so that true floats still round-trip as floats. + tpl: `{{ toToml . }}`, + expect: "big = 1e+20\n", + vars: map[string]any{"big": float64(1e20)}, }, { tpl: `{{ fromYaml . }}`, expect: "map[Error:error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into Go value of type map[string]interface {}]",