Skip to content

Out-of-range numeric conversions silently wrap instead of erroring (ToInt64E(uint64max) == -1) #356

Description

@earfman

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions