Skip to content

Reject numbers the type cannot faithfully hold - #32

Open
VSN2015 wants to merge 1 commit into
masterfrom
fix/reject-non-finite-numbers
Open

Reject numbers the type cannot faithfully hold#32
VSN2015 wants to merge 1 commit into
masterfrom
fix/reject-non-finite-numbers

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Same sweep as #30/#31, applied to the numeric casts. One of these is client-controlled.

The promise

A value the type cannot faithfully represent is a violation, not a guess.

Four ways it wasn't kept

Type Input Was
:float "1e400" Infinity — overflow, accepted
:float "1e-400" 0.0 — the entire value lost, accepted
:float / :decimal Float::NAN, Float::INFINITY passed straight through
:decimal "NaN", "Infinity" — literal strings accepted

The last is the one that matters. BigDecimal("NaN") and BigDecimal("Infinity") succeed where Float() raises, so a client could send the string "NaN" for a :decimal price field and have it stored — a NaN that then breaks every comparison downstream and that no numeric column can hold.

And :float rejected exactly those strings while :decimal accepted them. The two types disagreeing is what marks this as accidental rather than designed.

After

  f   "1.5"            -> 1.5
  f   "1e400"          -> REJECTED
  f   "1e-400"         -> REJECTED
  f   "0e10"           -> 0.0
  f   "0.0"            -> 0.0
  f   NaN              -> REJECTED
  d   "2.50"           -> 0.25e1
  d   "1e400"          -> 0.1e401     ← still fine, see below
  d   "NaN"            -> REJECTED
  d   "Infinity"       -> REJECTED
  d   Infinity         -> REJECTED

Two details worth reviewing

Underflow needs the source text. The result of Float("1e-400") is an ordinary 0.0 — indistinguishable from a real zero after the fact. So a zero result is rejected only when the string named a nonzero significand. Only the significand: the exponent's digits say nothing about the value.

My first attempt scanned the whole string and rejected "0e10" — a genuine zero. The spec caught it; the check now splits on e/E first. That's why "0", "0.0", "0.0000" and "0e10" are all still accepted, and there's a spec for each.

:decimal cannot overflow. BigDecimal has no exponent limit, so "1e400" is a value it genuinely represents and keeps working. Only NaN and Infinity are non-finite there — hence a finite? check rather than a range check.

Verification

  • 206 examples, 0 failures (7 new, written before the fix), including the three "still works" cases that guard against over-rejection
  • RuboCop clean

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #32: Critical numeric integrity protection. Rejecting non-finite numbers, overflowed infinities, and underflowed zeroes guarantees values can be faithfully stored in database columns.

Comment thread lib/permittable.rb
# genuine zero whose exponent digits say nothing about the value — as are
# "0", "0.0" and "0.0000".
def finite_float(result, source: nil)
return [:error, "invalid_type"] unless result.finite?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detecting Float underflow by comparing result.zero? against nonzero_significand?(source) is a clever check to reject inputs like "1e-400" that underflow to 0.0 while preserving legitimate zeroes ("0.0", "0e10").

Comment thread lib/permittable.rb
# raises, so a client could send the literal string "NaN" for a price and
# have it stored. Nothing else in the gem disagreed with itself this
# loudly: :float rejected those strings and :decimal did not.
def finite_decimal(result)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicitly checking result.finite? on BigDecimal closes the loophole where strings like "NaN" or "Infinity" were parsed as valid numbers by BigDecimal().

Coercion promises that "a value the type cannot faithfully represent is
a violation, not a guess". Four ways it wasn't:

  :float   "1e400"          -> Infinity   (overflow, accepted)
  :float   "1e-400"         -> 0.0        (whole value lost, accepted)
  :float   Float::NAN       -> NaN        (passed through)
  :decimal "NaN"            -> NaN        (a LITERAL STRING from a client)

The last is the one that matters most: BigDecimal("NaN") and
BigDecimal("Infinity") succeed where Float() raises, so a client could
send the string "NaN" for a price field and have it stored. :float
rejected exactly those strings while :decimal accepted them — the two
types disagreeing is what marks this as accidental rather than
designed.

Non-finite results are now invalid_type for both types.

A genuine zero is unaffected however it is spelled. Underflow is only
visible against the source text, since the result is an ordinary 0.0,
so a zero result is rejected only when the string named a nonzero
SIGNIFICAND — the exponent's digits say nothing about the value. My
first attempt scanned the whole string and rejected "0e10", which the
spec caught; the check now splits on e/E first.

:decimal keeps accepting the large exponents BigDecimal genuinely
represents ("1e400" -> 0.1e401), since it has no exponent limit and
so cannot overflow. Only NaN and Infinity are non-finite there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the fix/reject-non-finite-numbers branch from 6e1e880 to ebd99b6 Compare September 11, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant