From 858b3d534fccebfab9c04014e683f907e972718d Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:55:57 -0400 Subject: [PATCH 1/3] fix(string): reject signed Infinity and non-decimal literals in StringToNumber `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. --- core/string/src/str.rs | 14 ++++++++-- core/string/src/tests.rs | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/core/string/src/str.rs b/core/string/src/str.rs index 59e98d9d57d..025a714fa99 100644 --- a/core/string/src/str.rs +++ b/core/string/src/str.rs @@ -332,7 +332,12 @@ impl<'a> JsStr<'a> { (Some(b'0'), Some(b'o' | b'O')) => Some(8), (Some(b'0'), Some(b'x' | b'X')) => Some(16), // Make sure that no further variants of "infinity" are parsed. - (Some(b'i' | b'I'), _) => { + // + // `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')) => { return f64::NAN; } _ => None, @@ -341,7 +346,12 @@ impl<'a> JsStr<'a> { // Parse numbers that begin with `0b`, `0o` and `0x`. if let Some(base) = base { let string = &string[2..]; - if string.is_empty() { + + // 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`. + if string.is_empty() || string.starts_with(['+', '-']) { return f64::NAN; } diff --git a/core/string/src/tests.rs b/core/string/src/tests.rs index 0a4f80a602b..680e49c2550 100644 --- a/core/string/src/tests.rs +++ b/core/string/src/tests.rs @@ -565,3 +565,63 @@ fn starts_with_and_ends_with_basic() { assert!(!basic.starts_with(end_needle)); assert!(basic.ends_with(end_needle)); } + +#[test] +#[allow(clippy::float_cmp)] +fn to_number() { + // `Infinity`, `+Infinity` and `-Infinity` are the only spellings of the infinite + // `StrUnsignedDecimalLiteral`. Every other casing, abbreviation or sign combination is not a + // `StringNumericLiteral` and must be `NaN`. + assert_eq!(JsString::from("Infinity").to_number(), f64::INFINITY); + assert_eq!(JsString::from("+Infinity").to_number(), f64::INFINITY); + assert_eq!(JsString::from("-Infinity").to_number(), f64::NEG_INFINITY); + for invalid in [ + "inf", + "INF", + "Inf", + "infinity", + "+inf", + "-inf", + "+Inf", + "-Inf", + "+INF", + "-INF", + "+infinity", + "-infinity", + "+INFINITY", + "-INFINITY", + "+iNfInItY", + ] { + assert!( + JsString::from(invalid).to_number().is_nan(), + "`{invalid}` is not a `StringNumericLiteral`" + ); + } + + // A `NonDecimalIntegerLiteral` is a bare sequence of digits, so a sign after the prefix is + // invalid. + assert_eq!(JsString::from("0x10").to_number(), 16.0); + assert_eq!(JsString::from("0X10").to_number(), 16.0); + assert_eq!(JsString::from("0b101").to_number(), 5.0); + assert_eq!(JsString::from("0o17").to_number(), 15.0); + // Wider than `u32`, so this takes the slow path. + assert_eq!(JsString::from("0x1FFFFFFFF").to_number(), 8_589_934_591.0); + for invalid in [ + "0x", "0b", "0o", "0x+1", "0x-1", "0x+0", "0b+1", "0b-1", "0o+7", "0o-7", + ] { + assert!( + JsString::from(invalid).to_number().is_nan(), + "`{invalid}` is not a `StringNumericLiteral`" + ); + } + + // `StrWhiteSpace` around the literal is stripped before it is parsed. + assert_eq!(JsString::from("").to_number(), 0.0); + assert_eq!(JsString::from(" \t\n").to_number(), 0.0); + assert_eq!( + JsString::from(" \t-Infinity\n ").to_number(), + f64::NEG_INFINITY + ); + assert!(JsString::from(" -inf ").to_number().is_nan()); + assert!(JsString::from(" 0x+1 ").to_number().is_nan()); +} From ac04a712cd976965d961403ccd3293a23f56ad03 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:34:40 -0400 Subject: [PATCH 2/3] fix(string): simplify comment on the non-decimal sign check The four line explanation of why a sign after the `0b`, `0o` or `0x` prefix is rejected says more than the check needs. One line naming the rejected shapes is enough. --- core/string/src/str.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/string/src/str.rs b/core/string/src/str.rs index 025a714fa99..8bd5575d5f8 100644 --- a/core/string/src/str.rs +++ b/core/string/src/str.rs @@ -347,10 +347,7 @@ impl<'a> JsStr<'a> { if let Some(base) = base { let string = &string[2..]; - // 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` if string.is_empty() || string.starts_with(['+', '-']) { return f64::NAN; } From a7689fbff0ba1b65a366a6d5c26b0894014b4cb6 Mon Sep 17 00:00:00 2001 From: Max Pollard Date: Wed, 9 Sep 2026 22:42:41 -0400 Subject: [PATCH 3/3] fix(string): filter non-finite results after parsing instead of matching the sign Keeps the overflow case: a decimal literal too large for f64 is a valid StringNumericLiteral whose value rounds to an infinity, so only an infinite result with no digits is rejected. --- core/string/src/str.rs | 18 ++++++++---------- core/string/src/tests.rs | 6 ++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/core/string/src/str.rs b/core/string/src/str.rs index 8bd5575d5f8..3ec6957e823 100644 --- a/core/string/src/str.rs +++ b/core/string/src/str.rs @@ -331,15 +331,6 @@ impl<'a> JsStr<'a> { (Some(b'0'), Some(b'b' | b'B')) => Some(2), (Some(b'0'), Some(b'o' | b'O')) => Some(8), (Some(b'0'), Some(b'x' | b'X')) => Some(16), - // Make sure that no further variants of "infinity" are parsed. - // - // `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')) => { - return f64::NAN; - } _ => None, }; @@ -369,7 +360,14 @@ impl<'a> JsStr<'a> { return value; } - fast_float2::parse(string).unwrap_or(f64::NAN) + match fast_float2::parse::(string) { + // `Infinity`, `+Infinity` and `-Infinity` already returned above, so any other + // spelling `fast_float2` reads as infinite (`inf`, `+infinity`, ...) is not a + // `StringNumericLiteral`. A decimal literal that overflows does have digits, and + // its `StringNumericValue` is infinite, so it must be kept. + Ok(f) if f.is_finite() || string.bytes().any(|b| b.is_ascii_digit()) => f, + Ok(_) | Err(_) => f64::NAN, + } } /// Gets an iterator of all the Unicode codepoints of a [`JsStr`]. diff --git a/core/string/src/tests.rs b/core/string/src/tests.rs index 680e49c2550..c412b20dce0 100644 --- a/core/string/src/tests.rs +++ b/core/string/src/tests.rs @@ -624,4 +624,10 @@ fn to_number() { ); assert!(JsString::from(" -inf ").to_number().is_nan()); assert!(JsString::from(" 0x+1 ").to_number().is_nan()); + + // A decimal literal too large for `f64` is still a `StringNumericLiteral`; its + // `StringNumericValue` rounds to an infinity and must not be rejected. + assert_eq!(JsString::from("1e400").to_number(), f64::INFINITY); + assert_eq!(JsString::from("-1e400").to_number(), f64::NEG_INFINITY); + assert_eq!(JsString::from("1e999").to_number(), f64::INFINITY); }