diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index 80a5b553..be0e8dd0 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -3,6 +3,7 @@ package apiform import ( "fmt" "io" + "math" "mime/multipart" "net/textproto" "path" @@ -13,6 +14,17 @@ import ( "strings" ) +// formatFloat renders a float field value for a multipart part body. Non-finite +// values (NaN, +Inf, -Inf) have no valid multipart representation; reject them +// the way encoding/json rejects them ("json: unsupported value: +Inf") instead +// of writing "+Inf"/"NaN" on the wire. +func formatFloat(f float64, bitSize int) (string, error) { + if math.IsNaN(f) || math.IsInf(f, 0) { + return "", fmt.Errorf("apiform: unsupported value: %s", strconv.FormatFloat(f, 'g', -1, bitSize)) + } + return strconv.FormatFloat(f, 'f', -1, bitSize), nil +} + // Marshal encodes a value as multipart form data using default settings func Marshal(value any, writer *multipart.Writer) error { e := &encoder{ @@ -91,10 +103,18 @@ func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.W return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10)) case reflect.Float32: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32)) + strVal, err := formatFloat(val.Float(), 32) + if err != nil { + return err + } + return writer.WriteField(key, strVal) case reflect.Float64: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64)) + strVal, err := formatFloat(val.Float(), 64) + if err != nil { + return err + } + return writer.WriteField(key, strVal) default: return fmt.Errorf("unknown type: %s", t.String()) @@ -127,9 +147,17 @@ func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.W case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: strValue = strconv.FormatUint(item.Uint(), 10) case reflect.Float32: - strValue = strconv.FormatFloat(item.Float(), 'f', -1, 32) + s, err := formatFloat(item.Float(), 32) + if err != nil { + return err + } + strValue = s case reflect.Float64: - strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64) + s, err := formatFloat(item.Float(), 64) + if err != nil { + return err + } + strValue = s case reflect.Bool: strValue = strconv.FormatBool(item.Bool()) default: diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index c727ea45..692c3c7b 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -2,8 +2,12 @@ package apiform import ( "bytes" + "math" "mime/multipart" + "strings" "testing" + + "github.com/goccy/go-yaml" ) // Define test cases @@ -32,6 +36,14 @@ var tests = map[string]struct { value: float32(0.1), expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n0.1\r\n--xxx--\r\n", }, + "negative zero float": { + value: math.Copysign(0, -1), + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n-0\r\n--xxx--\r\n", + }, + "exponent form float": { + value: 1e3, + expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n1000\r\n--xxx--\r\n", + }, "bool": { value: true, expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n", @@ -125,3 +137,63 @@ func TestEncode(t *testing.T) { }) } } + +func TestMarshalRejectsNonFiniteFloats(t *testing.T) { + t.Parallel() + + inf32 := float32(math.Inf(1)) + nan32 := float32(math.NaN()) + infPtr := math.Inf(1) + + tests := map[string]struct { + value any + format FormFormat + }{ + "float64 +Inf": {value: math.Inf(1)}, + "float64 -Inf": {value: math.Inf(-1)}, + "float64 NaN": {value: math.NaN()}, + "float32 +Inf": {value: inf32}, + "float32 NaN": {value: nan32}, + "pointer to +Inf": {value: &infPtr}, + "nested map +Inf": {value: map[string]any{"nested": math.Inf(1)}}, + "comma slice +Inf": {value: []float64{1.5, math.Inf(1)}, format: FormatComma}, + "comma slice NaN": {value: []float32{nan32}, format: FormatComma}, + "repeat slice -Inf": {value: []float64{math.Inf(-1)}, format: FormatRepeat}, + "indices slice NaN": {value: []float64{math.NaN()}, format: FormatIndicesDots}, + "piped YAML .inf": {value: pipedYAMLBody(t, "temperature: .inf\n")}, + "piped YAML -.inf": {value: pipedYAMLBody(t, "temperature: -.inf\n")}, + "piped YAML .nan": {value: pipedYAMLBody(t, "temperature: .nan\n")}, + "piped YAML .Inf": {value: pipedYAMLBody(t, "temperature: .Inf\n")}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + buf := bytes.NewBuffer(nil) + writer := multipart.NewWriter(buf) + writer.SetBoundary("xxx") + + form := map[string]any{"foo": test.value} + err := MarshalWithSettings(form, writer, test.format) + if err == nil { + t.Fatalf("expected an error encoding %v, got body %q", test.value, buf.String()) + } + if !strings.Contains(err.Error(), "unsupported value") { + t.Errorf("expected an unsupported value error, got %v", err) + } + }) + } +} + +// pipedYAMLBody simulates the stdin/YAML route into the encoder: a piped +// request body is parsed with the YAML decoder into a generic map before +// Marshal runs, bypassing typed flag parsing entirely. +func pipedYAMLBody(t *testing.T, source string) map[string]any { + t.Helper() + var body map[string]any + if err := yaml.Unmarshal([]byte(source), &body); err != nil { + t.Fatalf("failed to parse test YAML %q: %v", source, err) + } + return body +} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index cc56adf0..3cf8730d 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -3,6 +3,7 @@ package requestflag import ( "encoding/json" "fmt" + "math" "reflect" "strconv" "strings" @@ -13,6 +14,23 @@ import ( "github.com/urfave/cli/v3" ) +// parseFiniteFloat parses value as a float64 and rejects the non-finite +// spellings accepted by strconv.ParseFloat ("inf", "-inf", "nan", "infinity", +// in any case). They have no valid wire representation: JSON request bodies +// fail late with "json: unsupported value" and multipart bodies would send +// "+Inf"/"NaN". The returned error is the same *strconv.NumError reported for +// malformed numeric input. +func parseFiniteFloat(value string) (float64, error) { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, err + } + if math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return 0, &strconv.NumError{Func: "ParseFloat", Num: value, Err: strconv.ErrSyntax} + } + return parsed, nil +} + // formatForFlagSet converts a Go value parsed from YAML/JSON stdin data into a string // that flag.Set (and thus parseCLIArg) can parse correctly for each flag type. // Strings are returned as-is (parseCLIArg[string] assigns the raw value directly, so @@ -570,7 +588,7 @@ func parseCLIArg[ case int64: parsedValue, err = strconv.ParseInt(value, 0, 64) case float64: - parsedValue, err = strconv.ParseFloat(value, 64) + parsedValue, err = parseFiniteFloat(value) case bool: parsedValue, err = strconv.ParseBool(value) case DateTimeValue: @@ -607,7 +625,7 @@ func parseCLIArg[ } case *float64: var v float64 - v, err = strconv.ParseFloat(value, 64) + v, err = parseFiniteFloat(value) if err == nil { parsedValue = &v } diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 5d9adc4f..48bafaaa 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -1239,3 +1239,77 @@ func TestApplyStdinDataToFlags(t *testing.T) { assert.False(t, flag.IsSet()) }) } + +func TestNonFiniteFloatFlagsRejected(t *testing.T) { + t.Parallel() + + // strconv.ParseFloat accepts these spellings of infinity and NaN, but they + // have no valid wire representation: JSON encoding fails late with + // "json: unsupported value" and multipart encoding would put "+Inf"/"NaN" + // on the wire. Flag parsing must reject them up front. + inputs := []string{ + "inf", "-inf", "+inf", "Inf", "-Inf", "+Inf", "INF", "-INFINITY", + "infinity", "-infinity", "Infinity", "InFiNiTy", + "nan", "-nan", "+nan", "NaN", "NAN", "nAn", + } + + for _, input := range inputs { + t.Run("Flag[float64] rejects "+input, func(t *testing.T) { + t.Parallel() + cv := &cliValue[float64]{} + assert.ErrorContains(t, cv.Set(input), input) + }) + + t.Run("Flag[*float64] rejects "+input, func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.ErrorContains(t, cv.Set(input), input) + }) + + t.Run("Flag[[]float64] rejects "+input, func(t *testing.T) { + t.Parallel() + cv := &cliValue[[]float64]{} + assert.ErrorContains(t, cv.Set(input), input) + }) + } +} + +func TestFiniteFloatFlagsStillAccepted(t *testing.T) { + t.Parallel() + + assertJSONBody := func(t *testing.T, value any, expected string) { + t.Helper() + body, err := json.Marshal(map[string]any{"foo": value}) + assert.NoError(t, err) + assert.JSONEq(t, expected, string(body)) + } + + tests := []struct { + input string + want string + }{ + {"1.5", `{"foo":1.5}`}, + {"-2.25", `{"foo":-2.25}`}, + {"1e3", `{"foo":1000}`}, + {"1E+2", `{"foo":100}`}, + {"5e-3", `{"foo":0.005}`}, + {"-0", `{"foo":0}`}, + {"1.7976931348623157e308", `{"foo":1.7976931348623157e308}`}, + } + + for _, tt := range tests { + t.Run("Flag[float64] accepts "+tt.input, func(t *testing.T) { + t.Parallel() + cv := &cliValue[float64]{} + assert.NoError(t, cv.Set(tt.input)) + assertJSONBody(t, cv.Get(), tt.want) + }) + + t.Run("Flag[*float64] accepts "+tt.input, func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set(tt.input)) + assertJSONBody(t, cv.Get(), tt.want) + }) + } +}