Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions internal/apiform/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package apiform
import (
"fmt"
"io"
"math"
"mime/multipart"
"net/textproto"
"path"
Expand All @@ -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{
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions internal/apiform/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package apiform

import (
"bytes"
"math"
"mime/multipart"
"strings"
"testing"

"github.com/goccy/go-yaml"
)

// Define test cases
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
22 changes: 20 additions & 2 deletions internal/requestflag/requestflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package requestflag
import (
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
"strings"
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down
74 changes: 74 additions & 0 deletions internal/requestflag/requestflag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}