fix(string): reject signed Infinity and non-decimal literals in StringToNumber - #5509
Conversation
…gToNumber
`JsStr::to_number` accepted two families of strings that the
`StringNumericLiteral` grammar rejects, so `Number(x)`, unary `+` and
arithmetic coercion returned a number where they must return `NaN`.
The infinity guard in `core/string/src/str.rs` matched on the first byte of
the string, so it only caught unsigned spellings. When a sign is present the
first byte is the sign, the guard does not fire, and `fast_float2::parse`
accepts `inf` and `infinity` case insensitively with an optional sign. That
made `Number("+inf")` return `Infinity` and `Number("-INFINITY")` return
`-Infinity`.
The `0b`/`0o`/`0x` branch of the same function passed the text after the
prefix straight to `u32::from_str_radix`, which accepts a leading `+`. A
`NonDecimalIntegerLiteral` is a bare sequence of digits, so `Number("0x+1")`
and `Number("0b+1")` both returned `1`. Only the fast path was affected:
values too wide for `u32` fall through to the slow path, which already
rejects a sign because `+` is not a digit.
The infinity guard now also matches a sign followed by `i` or `I`, and the
non-decimal branch rejects a leading sign before parsing. Adds a `to_number`
test to `core/string/src/tests.rs` covering both families together with the
valid spellings and the slow path.
Test262 conformance changes
Tested main commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5509 +/- ##
===========================================
+ Coverage 47.24% 62.91% +15.66%
===========================================
Files 476 536 +60
Lines 46892 60286 +13394
===========================================
+ Hits 22154 37928 +15774
+ Misses 24738 22358 -2380 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // | ||
| // `Infinity`, `+Infinity` and `-Infinity` are the only spellings accepted by | ||
| // `StrUnsignedDecimalLiteral`, and all three already returned above. Anything else | ||
| // starting with `i` or `I`, with or without a sign, is not a `StringNumericLiteral`, | ||
| // but `fast_float2` would still parse it as an infinity. | ||
| (Some(b'i' | b'I'), _) | (Some(b'+' | b'-'), Some(b'i' | b'I')) => { |
There was a problem hiding this comment.
Can't we just filter for non-finite numbers after fast_float2::parse? What I'm thinking is that we already covered all types of parseable infinities, so if fast_float2::parse returns infinity we can just return NaN in that specific case, right?
There was a problem hiding this comment.
Something like this at the end
match fast_float2::parse::<f64, &str>(string) {
Ok(f) if f.is_finite() => f,
// Rejects any other strings parsed as infinity
Ok(_) | Err(_) => f64::NAN,
}| // A `NonDecimalIntegerLiteral` is a bare sequence of digits. A sign is only part of | ||
| // `StrDecimalLiteral`, which cannot carry a `0b`, `0o` or `0x` prefix, so a sign here | ||
| // makes the whole string invalid. `u32::from_str_radix` accepts a leading `+`, so | ||
| // without this check `0x+1` would parse as `1`. |
There was a problem hiding this comment.
| // A `NonDecimalIntegerLiteral` is a bare sequence of digits. A sign is only part of | |
| // `StrDecimalLiteral`, which cannot carry a `0b`, `0o` or `0x` prefix, so a sign here | |
| // makes the whole string invalid. `u32::from_str_radix` accepts a leading `+`, so | |
| // without this check `0x+1` would parse as `1`. | |
| // Rejects things like `0x+1` or `0o-1` |
Simple is better
Number("+inf")returnsInfinityandNumber("0x+1")returns1. Both must beNaN. TheStringNumericLiteralgrammar behindStringToNumberspells the infiniteStrUnsignedDecimalLiteralas exactlyInfinity, and givesNonDecimalIntegerLiteralno sign of its own. I found these by runningNumber()over a 1256 case corpus of signs, casings, prefixes and surrounding whitespace and diffing against Node 22. There were 144 divergences and every one traced back to one of the two causes below.The infinity guard at
core/string/src/str.rs:335matches on the first byte of the string, so it only catches unsigned spellings.Infinity,+Infinityand-Infinityhave already returned by that point, so anything reaching the guard that starts withiorIis invalid, but when a sign is present the first byte is the sign and the guard does not fire. Control then reachesfast_float2::parse, which acceptsinfandinfinitycase insensitively with an optional sign (parse_inf_naninfast-float2-0.2.4/src/number.rs). SoNumber("+inf"),Number("-Inf"),Number("+infinity")andNumber("-INFINITY")all returned an infinity instead ofNaN.The
0b/0o/0xbranch atcore/string/src/str.rs:342strips the prefix and passes the rest straight tou32::from_str_radixon line 349, andfrom_str_radixaccepts a leading+. SoNumber("0x+1")andNumber("0b+1")returned1, andNumber("0x+0")returned0. Only the fast path is affected:Number("0x+FFFFFFFFFF")is too wide foru32and falls through to the slow path, which already rejects the sign because+is not a digit. A leading-was already rejected becausefrom_str_radixrefuses one for an unsigned type.This reaches user code through every string to number coercion, so
Number("+inf"),+"+inf"and"+inf" * 1were all affected.parseFloatandparseIntare separate code paths, already agree with Node on the same inputs, and are untouched here.It changes the following:
iorI, so signed non-canonical spellings returnNaNalongside the unsigned ones it already caught.NaNwhen the text after the0b/0o/0xprefix starts with+or-, beforefrom_str_radixsees it.to_numbertest tocore/string/src/tests.rscovering both families, the validInfinityspellings, the valid prefixed literals, theu32slow path via0x1FFFFFFFF, and surroundingStrWhiteSpace.Verification on aarch64-apple-darwin:
main,cargo test -p boa_stringfails attests::to_numberwith "+infis not aStringNumericLiteral". With the fix,cargo test -p boa_stringreports25 passed; 0 failedplus2 passeddoctests.boa_cliand rerunning the 1256 case corpus against Node 22 gives zero divergences, down from 144.cargo fmt --all --checkis clean, andtyposv1.50.1, the version pinned in.github/workflows/rust.yml, reports nothing on the changed files or the whole repository.cargo clippy -p boa_string --all-features --all-targets -- -D warningsand the same with--no-default-featuresare clean on rustc 1.98.0.cargo check -p boa_string --all-features --all-targetsexits 0 on the 1.91.0 MSRV, andcargo doc -p boa_string --document-private-items --all-featuresexits 0 withRUSTDOCFLAGS=-D warnings.