diff --git a/number.go b/number.go index a58dc4d1..3d5779a5 100644 --- a/number.go +++ b/number.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "regexp" "strconv" "strings" @@ -95,28 +96,76 @@ func toNumber[T Number](i any) (T, bool) { case T: return s, true case int: + if !numberFitsFromInt64[T](int64(s)) { + return 0, false + } + return T(s), true case int8: + if !numberFitsFromInt64[T](int64(s)) { + return 0, false + } + return T(s), true case int16: + if !numberFitsFromInt64[T](int64(s)) { + return 0, false + } + return T(s), true case int32: + if !numberFitsFromInt64[T](int64(s)) { + return 0, false + } + return T(s), true case int64: + if !numberFitsFromInt64[T](s) { + return 0, false + } + return T(s), true case uint: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, false + } + return T(s), true case uint8: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, false + } + return T(s), true case uint16: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, false + } + return T(s), true case uint32: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, false + } + return T(s), true case uint64: + if !numberFitsFromUint64[T](s) { + return 0, false + } + return T(s), true case float32: + if !numberFitsFromFloat64[T](float64(s)) { + return 0, false + } + return T(s), true case float64: + if !numberFitsFromFloat64[T](s) { + return 0, false + } + return T(s), true case bool: if s { @@ -203,52 +252,100 @@ func toUnsignedNumber[T Number](i any) (T, bool, bool) { return 0, false, false } + if !numberFitsFromInt64[T](int64(s)) { + return 0, true, false + } + return T(s), true, true case int8: if s < 0 { return 0, false, false } + if !numberFitsFromInt64[T](int64(s)) { + return 0, true, false + } + return T(s), true, true case int16: if s < 0 { return 0, false, false } + if !numberFitsFromInt64[T](int64(s)) { + return 0, true, false + } + return T(s), true, true case int32: if s < 0 { return 0, false, false } + if !numberFitsFromInt64[T](int64(s)) { + return 0, true, false + } + return T(s), true, true case int64: if s < 0 { return 0, false, false } + if !numberFitsFromInt64[T](s) { + return 0, true, false + } + return T(s), true, true case uint: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, true, false + } + return T(s), true, true case uint8: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, true, false + } + return T(s), true, true case uint16: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, true, false + } + return T(s), true, true case uint32: + if !numberFitsFromUint64[T](uint64(s)) { + return 0, true, false + } + return T(s), true, true case uint64: + if !numberFitsFromUint64[T](s) { + return 0, true, false + } + return T(s), true, true case float32: if s < 0 { return 0, false, false } + if !numberFitsFromFloat64[T](float64(s)) { + return 0, true, false + } + return T(s), true, true case float64: if s < 0 { return 0, false, false } + if !numberFitsFromFloat64[T](s) { + return 0, true, false + } + return T(s), true, true case bool: if s { @@ -275,6 +372,119 @@ func toUnsignedNumber[T Number](i any) (T, bool, bool) { return 0, true, false } +// numberFitsFromInt64 reports whether the int64 value v can be converted to T +// without wrapping. The string conversion paths already reject out-of-range +// values (strconv), but the typed numeric fast paths used unchecked Go +// conversions. Float targets always fit: cast intentionally allows lossy +// integer-to-float conversions. +func numberFitsFromInt64[T Number](v int64) bool { + switch any(T(0)).(type) { + case int8: + return v >= math.MinInt8 && v <= math.MaxInt8 + case int16: + return v >= math.MinInt16 && v <= math.MaxInt16 + case int32: + return v >= math.MinInt32 && v <= math.MaxInt32 + case int64: + return true + case int: + if strconv.IntSize == 32 { + return v >= math.MinInt32 && v <= math.MaxInt32 + } + + return true + case uint8: + return v >= 0 && v <= math.MaxUint8 + case uint16: + return v >= 0 && v <= math.MaxUint16 + case uint32: + return v >= 0 && v <= math.MaxUint32 + case uint64: + return v >= 0 + case uint: + return v >= 0 && (strconv.IntSize == 64 || v <= math.MaxUint32) + case float32, float64: + return true + } + + return false +} + +func numberFitsFromUint64[T Number](v uint64) bool { + switch any(T(0)).(type) { + case int8: + return v <= math.MaxInt8 + case int16: + return v <= math.MaxInt16 + case int32: + return v <= math.MaxInt32 + case int64: + return v <= math.MaxInt64 + case int: + if strconv.IntSize == 32 { + return v <= math.MaxInt32 + } + + return v <= math.MaxInt64 + case uint8: + return v <= math.MaxUint8 + case uint16: + return v <= math.MaxUint16 + case uint32: + return v <= math.MaxUint32 + case uint64: + return true + case uint: + return strconv.IntSize == 64 || v <= math.MaxUint32 + case float32, float64: + return true + } + + return false +} + +func numberFitsFromFloat64[T Number](v float64) bool { + if math.IsNaN(v) || math.IsInf(v, 0) { + return false + } + + switch any(T(0)).(type) { + case int8: + // truncation may land inside the range: -128.9 truncates to -128 + return v > math.MinInt8-1 && v < math.MaxInt8+1 + case int16: + return v > math.MinInt16-1 && v < math.MaxInt16+1 + case int32: + return v > math.MinInt32-1 && v < math.MaxInt32+1 + case int64: + return v >= -9223372036854775808 && v < 9223372036854775808 + case int: + if strconv.IntSize == 32 { + return v > math.MinInt32-1 && v < math.MaxInt32+1 + } + + return v >= -9223372036854775808 && v < 9223372036854775808 + case uint8: + return v >= 0 && v < math.MaxUint8+1 + case uint16: + return v >= 0 && v < math.MaxUint16+1 + case uint32: + return v >= 0 && v < math.MaxUint32+1 + case uint64: + return v >= 0 && v < 18446744073709551616 + case uint: + if strconv.IntSize == 32 { + return v >= 0 && v < math.MaxUint32+1 + } + + return v >= 0 && v < 18446744073709551616 + case float32, float64: + return true + } + + return false +} + func toUnsignedNumberE[T Number](i any, parseFn func(string) (T, error)) (T, error) { n, valid, ok := toUnsignedNumber[T](i) if ok { diff --git a/number_test.go b/number_test.go index 9d84d18d..528c4721 100644 --- a/number_test.go +++ b/number_test.go @@ -464,3 +464,54 @@ func BenchmarkNumber(b *testing.B) { }) } } + +// TestTypedNumericOverflow verifies that out-of-range typed numeric values +// are rejected with an error instead of silently wrapping (issue #356). +// The string path already rejected these values; the typed numeric fast +// path used unchecked Go conversions. +func TestTypedNumericOverflow(t *testing.T) { + c := qt.New(t) + + for name, tc := range map[string]struct { + convert func(any) error + input any + }{ + "uint64 max to int64": {func(i any) error { _, err := cast.ToInt64E(i); return err }, uint64(math.MaxUint64)}, + "float64 2^63 to int64": {func(i any) error { _, err := cast.ToInt64E(i); return err }, float64(math.MaxInt64)}, + "float64 1e300 to int64": {func(i any) error { _, err := cast.ToInt64E(i); return err }, 1e300}, + "inf to int64": {func(i any) error { _, err := cast.ToInt64E(i); return err }, math.Inf(1)}, + "nan to int64": {func(i any) error { _, err := cast.ToInt64E(i); return err }, math.NaN()}, + "float64 1e300 to int8": {func(i any) error { _, err := cast.ToInt8E(i); return err }, 1e300}, + "nan to uint64": {func(i any) error { _, err := cast.ToUint64E(i); return err }, math.NaN()}, + "int64 300 to int8": {func(i any) error { _, err := cast.ToInt8E(i); return err }, int64(300)}, + "uint64 300 to uint8": {func(i any) error { _, err := cast.ToUint8E(i); return err }, uint64(300)}, + "uint64 max to int": {func(i any) error { _, err := cast.ToIntE(i); return err }, uint64(math.MaxUint64)}, + "float64 -1e300 to uint64": {func(i any) error { _, err := cast.ToUint64E(i); return err }, -1e300}, + } { + t.Run(name, func(t *testing.T) { + c.Assert(tc.convert(tc.input), qt.IsNotNil, qt.Commentf("conversion of %v should error", tc.input)) + }) + } +} + +// TestTypedNumericInRange verifies that in-range values still convert +// successfully, including float truncation semantics (-128.9 -> -128). +func TestTypedNumericInRange(t *testing.T) { + c := qt.New(t) + + v64, err := cast.ToInt64E(int64(42)) + c.Assert(err, qt.IsNil) + c.Assert(v64, qt.Equals, int64(42)) + + v64, err = cast.ToInt64E(3.7) + c.Assert(err, qt.IsNil) + c.Assert(v64, qt.Equals, int64(3)) + + v8, err := cast.ToInt8E(-128.9) + c.Assert(err, qt.IsNil) + c.Assert(v8, qt.Equals, int8(-128)) + + vu64, err := cast.ToUint64E(uint64(7)) + c.Assert(err, qt.IsNil) + c.Assert(vu64, qt.Equals, uint64(7)) +}