From 0c8ab13223c4f6c75392763eea3987af08eb39be Mon Sep 17 00:00:00 2001 From: barry3406 Date: Sat, 11 Apr 2026 09:38:08 -0700 Subject: [PATCH] fix(engine): render whole-number floats as TOML integers Helm values round-trip through JSON, so every YAML number enters the template engine as a float64 regardless of whether the source was written as "9" or "9.0". The BurntSushi TOML encoder inspects the Go type via reflection and writes float64(9) as "9.0", which surprises users whose values look like integers. encoding/json already omits the trailing ".0" for whole-number float64 values, so toJson does not exhibit the same issue; this change brings toToml in line with toJson. The fix normalizes whole-number float64 values to int64 inside toToml only, leaving NaN, Inf, non-whole floats, and out-of-range values untouched. Scoping the change to the encoding path preserves the in-template type of values returned by typeOf/typeIs, avoiding the ecosystem regression that caused #13533 to be reverted in #30884. Closes #32035 Signed-off-by: barry3406 --- pkg/engine/funcs.go | 48 ++++++++++++++++++++++++++++++++++++++-- pkg/engine/funcs_test.go | 26 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) 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 {}]",