spf13 / spf13/cast

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

Open
#356 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
4k
Forks
337
Avg merge
15m
Merged PRs (30d)
1

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with toNumber and toNumberE, especially the typed numeric cases and the parseFn fallback described in the issue. Confirm the intended range-checking and compatibility decision for all To*E conversions; done means out-of-range typed values return errors like rejected string inputs without changing valid conversions.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.