From 7bf81bd0692b96273623d891102cd01031648fdd Mon Sep 17 00:00:00 2001 From: Sueun Cho Date: Fri, 7 Aug 2026 14:17:31 +0900 Subject: [PATCH] fix(strvals): preserve integer precision in --set-json --set-json decoded JSON numbers into an any via encoding/json, so every number became a float64 and integers above 2^53 lost precision: 9007199254740993 became 9007199254740992, and larger values rendered in scientific notation. The plain --set path parses integers as int64 (typedVal), so the two flags disagreed on the same input. Decode with UseNumber and convert each integral json.Number to int64 (fractional ones stay float64), recursing through objects and arrays. Signed-off-by: Sueun Cho --- pkg/cli/values/options_test.go | 2 +- pkg/strvals/parser.go | 36 +++++++++++++++++++++++++-- pkg/strvals/parser_test.go | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go index 6debb693e..571c5b3c3 100644 --- a/pkg/cli/values/options_test.go +++ b/pkg/cli/values/options_test.go @@ -294,7 +294,7 @@ func TestMergeValuesCLI(t *testing.T) { }, expected: map[string]any{ "foo": map[string]any{ - "bar": []any{1.0, 2.0, 3.0}, + "bar": []any{int64(1), int64(2), int64(3)}, }, }, }, diff --git a/pkg/strvals/parser.go b/pkg/strvals/parser.go index be3b1e71b..950c63ab0 100644 --- a/pkg/strvals/parser.go +++ b/pkg/strvals/parser.go @@ -224,10 +224,11 @@ func (t *parser) key(data map[string]any, nestedNameLevel int) (reterr error) { // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). var jsonval any dec := json.NewDecoder(strings.NewReader(t.sc.String())) + dec.UseNumber() if err := dec.Decode(&jsonval); err != nil { return err } - set(data, string(k), jsonval) + set(data, string(k), convertJSONNumbers(jsonval)) if _, err = io.CopyN(io.Discard, t.sc, dec.InputOffset()); err != nil { return err } @@ -356,10 +357,11 @@ func (t *parser) listItem(list []any, i, nestedNameLevel int) ([]any, error) { // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). var jsonval any dec := json.NewDecoder(strings.NewReader(t.sc.String())) + dec.UseNumber() if err := dec.Decode(&jsonval); err != nil { return list, err } - if list, err = setIndex(list, i, jsonval); err != nil { + if list, err = setIndex(list, i, convertJSONNumbers(jsonval)); err != nil { return list, err } if _, err = io.CopyN(io.Discard, t.sc, dec.InputOffset()); err != nil { @@ -555,3 +557,33 @@ func typedVal(v []rune, st bool) any { return val } + +// convertJSONNumbers walks a value decoded by encoding/json with UseNumber +// enabled and turns every json.Number into an int64 when it is integral, or a +// float64 otherwise. Decoding into an any without UseNumber makes every JSON +// number a float64, which silently loses precision for integers beyond 2^53 +// (for example 9007199254740993 becomes 9007199254740992). This keeps the +// --set-json path consistent with the plain --set path, whose typedVal parses +// integers as int64. +func convertJSONNumbers(v any) any { + switch val := v.(type) { + case json.Number: + if iv, err := val.Int64(); err == nil { + return iv + } + fv, _ := val.Float64() + return fv + case map[string]any: + for k, e := range val { + val[k] = convertJSONNumbers(e) + } + return val + case []any: + for i, e := range val { + val[i] = convertJSONNumbers(e) + } + return val + default: + return v + } +} diff --git a/pkg/strvals/parser_test.go b/pkg/strvals/parser_test.go index 73fc3f206..85e60f840 100644 --- a/pkg/strvals/parser_test.go +++ b/pkg/strvals/parser_test.go @@ -642,6 +642,51 @@ func TestParseJSON(t *testing.T) { } } +func TestParseJSONNumbers(t *testing.T) { + // A JSON integer must round-trip exactly, including values above 2^53 + // where a float64 would lose precision. This mirrors the plain --set + // path, which parses integers as int64. + tests := []struct { + name string + input string + expect map[string]any + }{ + { + name: "integer beyond 2^53 keeps its exact value", + input: "id=9007199254740993", + expect: map[string]any{"id": int64(9007199254740993)}, + }, + { + name: "small integer decodes as int64", + input: "n=3", + expect: map[string]any{"n": int64(3)}, + }, + { + name: "negative large integer keeps its exact value", + input: "n=-9223372036854775807", + expect: map[string]any{"n": int64(-9223372036854775807)}, + }, + { + name: "fractional value stays a float", + input: "ratio=1.5", + expect: map[string]any{"ratio": float64(1.5)}, + }, + { + name: "integers nested in objects and arrays keep their exact value", + input: `obj={"big":9007199254740993,"list":[9007199254740995]}`, + expect: map[string]any{"obj": map[string]any{"big": int64(9007199254740993), "list": []any{int64(9007199254740995)}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := map[string]any{} + err := ParseJSON(tt.input, got) + require.NoError(t, err) + assert.Equal(t, tt.expect, got) + }) + } +} + func TestParseFile(t *testing.T) { input := "name1=path1" expect := map[string]any{