Skip to content

Reject underscore separators and whitespace in numeric coercion - #82

Merged
VSN2015 merged 1 commit into
masterfrom
fix/strict-numeric-coercion
Sep 29, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/strict-numeric-coercion

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

The bug

Numeric scalar casts (:integer/:float/:decimal) delegate straight to Kernel#Integer/Float/BigDecimal, which silently accept underscore digit separators and surrounding whitespace — a convenience meant for a number literal in Ruby source code, not for a value arriving in a request body. This contradicts the module's documented "deliberately strict" coercion philosophy, and is the same class of leniency Coercion already deliberately hardens against for NaN/Infinity and bare-significand 0e10 forms — just slipping through a different door:

Permittable::Coercion.cast(:integer, "1_8")    # => [:ok, 18]      (should be rejected)
Permittable::Coercion.cast(:integer, " 99 ")   # => [:ok, 99]      (should be rejected)
Permittable::Coercion.cast(:float, "1_8.5")    # => [:ok, 18.5]    (should be rejected)

"1_8" is not how a client spells eighteen, and " 99 " is not how one spells ninety-nine; a client sending either is more likely to have a formatting bug upstream than to mean the number that results.

The fix

Before delegating to Integer()/Float()/BigDecimal(), the String branch of each numeric cast (cast_integer, cast_float, cast_decimal) is now checked against a strict canonical numeric format — an optional sign, digits, an optional .digits, and an optional exponent, with no underscores and no surrounding whitespace:

INTEGER_FORMAT = /\A[+-]?\d+\z/
NUMERIC_FORMAT = /\A[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?\z/

A String that doesn't match reports the same "invalid_type" violation the module's other strictness checks already use, rejected before it ever reaches Integer/Float/BigDecimal. Legitimately valid numeric strings keep working exactly as before — plain integers, leading-zero integers ("01"), decimals, negative numbers, and valid exponent notation like "1.5e10" or the large exponents :decimal genuinely represents ("1e400") — since none of those forms use underscores or whitespace. :decimal's existing NaN/Infinity string rejection is unaffected: those literals don't match the numeric format either, so they're still rejected with "invalid_type", just at the format-check gate instead of via BigDecimal's own NaN/Infinity parsing.

Verification

  • New tests written first (spec/permittable_spec.rb), confirmed to fail before the implementation change and pass after
  • bundle exec rspec — 853 examples, 0 failures
  • bundle exec rubocop lib/permittable.rb spec/permittable_spec.rb — no offenses

🤖 Generated with Claude Code

Kernel#Integer/Float and BigDecimal() all treat underscore digit
separators and surrounding whitespace as harmless formatting for a
Ruby source literal — not how a client spells a number in a request
body. Coercion.cast(:integer, "1_8") returned 18, and
Coercion.cast(:integer, " 99 ") returned 99, silently accepting input
that isn't a canonical numeric string, the same class of leniency the
module already deliberately hardens against for NaN/Infinity and
bare-significand "0e10" forms.

:integer/:float/:decimal String casts now validate against a strict
canonical numeric format before delegating to Integer()/Float()/
BigDecimal(), rejecting anything with underscores or surrounding
whitespace while still accepting plain integers, decimals, negative
numbers, and valid exponent notation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@VSN2015
VSN2015 merged commit 489980e into master Sep 29, 2026
16 checks passed
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