Every To<Int>E / To<Uint>E conversion from a typed numeric value is an unchecked Go conversion. Out-of-range inputs wrap silently and return err == nil. The same values arriving as strings are correctly rejected, so the two paths through the same function disagree.
Observed on cast v1.10.0, go1.24, linux/amd64.
Reproduction
package main
import (
"fmt"
"math"
"github.com/spf13/cast"
)
func main() {
fmt.Println(cast.ToInt64E(uint64(math.MaxUint64))) // -1 <nil>
fmt.Println(cast.ToInt64E(float64(math.MaxInt64))) // -9223372036854775808 <nil>
fmt.Println(cast.ToInt64E(1e300)) // -9223372036854775808 <nil>
fmt.Println(cast.ToInt64E(math.Inf(1))) // -9223372036854775808 <nil>
fmt.Println(cast.ToInt64E(math.NaN())) // -9223372036854775808 <nil>
fmt.Println(cast.ToInt8E(1e300)) // 0 <nil>
fmt.Println(cast.ToUint64E(math.NaN())) // 9223372036854775808 <nil>
// the string path, same values, correctly rejected:
fmt.Println(cast.ToInt64E("1e300")) // 0 unable to cast ...
}
| input |
ToInt64E |
error? |
uint64(math.MaxUint64) |
-1 |
no |
float64(math.MaxInt64) |
-9223372036854775808 |
no |
1e300 |
-9223372036854775808 |
no |
math.Inf(1) |
-9223372036854775808 |
no |
math.NaN() |
-9223372036854775808 |
no |
"1e300" (string) |
0 |
yes |
The first one has no float in it at all — it is a plain integer wraparound.
Cause
toNumber[T] converts every typed numeric case with a bare T(s) and unconditionally reports success:
func toNumber[T Number](i any) (T, bool) {
i, _ = indirect(i)
switch s := i.(type) {
case T:
return s, true
...
case uint64:
return T(s), true // wraps
case float64:
return T(s), true // out-of-range float -> int is implementation-defined
...
}
return 0, false
}
toNumberE only reaches parseFn (strconv.ParseInt with an explicit bit size, which does range-check) when toNumber returns ok == false — i.e. for strings and json.Number. So the checked path is only ever taken for text input.
Per the Go spec, converting a float to an integer type when the value is out of range is implementation-defined; on amd64 and arm64 it yields the "integer indefinite" value, math.MinInt64.
That the unsigned helper already returns errNegativeNotAllowed for negative input suggests out-of-range is meant to be an error condition here — the range check simply never got applied to the typed path.
Why it is reachable in practice
spf13/viper is built on cast, and encoding/json decodes every JSON number to a float64. So an ordinary config file silently produces a negative limit:
v := viper.New()
v.SetConfigType("json")
v.ReadConfig(bytes.NewBufferString(`{"max_bytes": 9223372036854775807}`))
v.GetInt64("max_bytes") // -9223372036854775808
The identical value in YAML returns 9223372036854775807, because yaml.v3 decodes to a real int64 and never enters the float path. So the same configuration behaves differently depending on the file format — which is the part most likely to cost someone a long afternoon.
Verified with viper v1.21.0 and cast v1.10.0.
Possible fix
Range-check in toNumber before converting, and let the caller turn ok == false into the existing error. For the float cases the bound has to be written against 2^63 rather than math.MaxInt64 — the untyped constant converts to the same float64 as the values that overflow, so f > math.MaxInt64 is false exactly when it matters:
const maxInt64AsFloat = float64(1 << 63) // exactly representable; math.MaxInt64 is not
if math.IsNaN(f) || f >= maxInt64AsFloat || f < float64(math.MinInt64) {
return 0, false
}
Happy to open a PR if the approach sounds right — though given it touches every To*E and changes previously-silent behaviour into errors, you may prefer to decide the shape (and whether it is a breaking change) first.
Every
To<Int>E/To<Uint>Econversion from a typed numeric value is an unchecked Go conversion. Out-of-range inputs wrap silently and returnerr == nil. The same values arriving as strings are correctly rejected, so the two paths through the same function disagree.Observed on cast v1.10.0, go1.24, linux/amd64.
Reproduction
ToInt64Euint64(math.MaxUint64)-1float64(math.MaxInt64)-92233720368547758081e300-9223372036854775808math.Inf(1)-9223372036854775808math.NaN()-9223372036854775808"1e300"(string)0The first one has no float in it at all — it is a plain integer wraparound.
Cause
toNumber[T]converts every typed numeric case with a bareT(s)and unconditionally reports success:toNumberEonly reachesparseFn(strconv.ParseIntwith an explicit bit size, which does range-check) whentoNumberreturnsok == false— i.e. for strings andjson.Number. So the checked path is only ever taken for text input.Per the Go spec, converting a float to an integer type when the value is out of range is implementation-defined; on amd64 and arm64 it yields the "integer indefinite" value,
math.MinInt64.That the unsigned helper already returns
errNegativeNotAllowedfor negative input suggests out-of-range is meant to be an error condition here — the range check simply never got applied to the typed path.Why it is reachable in practice
spf13/viperis built on cast, andencoding/jsondecodes every JSON number to afloat64. So an ordinary config file silently produces a negative limit:The identical value in YAML returns
9223372036854775807, becauseyaml.v3decodes to a realint64and never enters the float path. So the same configuration behaves differently depending on the file format — which is the part most likely to cost someone a long afternoon.Verified with viper v1.21.0 and cast v1.10.0.
Possible fix
Range-check in
toNumberbefore converting, and let the caller turnok == falseinto the existing error. For the float cases the bound has to be written against2^63rather thanmath.MaxInt64— the untyped constant converts to the samefloat64as the values that overflow, sof > math.MaxInt64is false exactly when it matters:Happy to open a PR if the approach sounds right — though given it touches every
To*Eand changes previously-silent behaviour into errors, you may prefer to decide the shape (and whether it is a breaking change) first.