LibJS: Make folded non-decimal prefix parsing UTF-8-safe

Folded StringToNumber() and StringToBigInt() detected non-decimal
prefixes by slicing the string at byte offset 2. On UTF-8 input this
could split at a non-character boundary and panic.

To prevent this, we replace the byte-based split with ASCII prefix
stripping and preserve rejection of empty suffixes such as "0x", "0o",
and "0b" explicitly before parsing the remaining digits.

This makes non-decimal prefix folding UTF-8-safe and preserves the
expected invalid-result behavior for empty prefixed literals.

Tests:

Add regression coverage for folded StringToNumber() and StringToBigInt()
non-decimal prefix handling to validate the UTF-8 safety fix as
'string-to-number-and-bigint-non-decimal-prefixes.js'.

These tests ensure empty suffixes like "0x", "0o", and "0b" and
other invalid prefixed forms stay invalid, while valid prefixed
literals continue to be accepted.

Since we removed a byte-index split in folded
StringToNumber()/StringToBigInt() coercion that could panic when byte
index 2 landed inside a multi-byte UTF-8 scalar, we add regression
tests for representative panic-shape inputs to ensure these coercions
now return invalid results instead of crashing as
'string-to-number-and-bigint-utf8-boundary.js'
This commit is contained in:
RubenKelevra 2026-03-20 13:07:25 +01:00 committed by Shannon Booth
parent ce365e6513
commit a1ae402bb9
3 changed files with 173 additions and 32 deletions

View file

@ -8904,6 +8904,52 @@ fn try_constant_loosely_equals(lhs: &ConstantValue, rhs: &ConstantValue) -> Opti
}
}
#[derive(Clone, Copy)]
enum NonDecimalRadix {
Binary,
Octal,
Hexadecimal,
}
impl NonDecimalRadix {
fn as_u32(self) -> u32 {
match self {
Self::Binary => 2,
Self::Octal => 8,
Self::Hexadecimal => 16,
}
}
}
fn strip_non_decimal_prefix(text: &str) -> Option<(NonDecimalRadix, &str)> {
// Detect an ASCII non-decimal prefix without slicing at a UTF-8-invalid boundary.
// Empty suffixes ("0x", "0o", "0b") remain invalid and are rejected here.
// Callers validate suffix digits before delegating conversion to numeric parsers.
if let Some(rest) = text.strip_prefix("0b").or_else(|| text.strip_prefix("0B")) {
return (!rest.is_empty()).then_some((NonDecimalRadix::Binary, rest));
}
if let Some(rest) = text.strip_prefix("0o").or_else(|| text.strip_prefix("0O")) {
return (!rest.is_empty()).then_some((NonDecimalRadix::Octal, rest));
}
if let Some(rest) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
return (!rest.is_empty()).then_some((NonDecimalRadix::Hexadecimal, rest));
}
None
}
fn is_valid_non_decimal_digits(text: &str, radix: NonDecimalRadix) -> bool {
// Keep this JS-specific precheck even though parse_bytes() also validates:
// num-bigint accepts forms that are invalid in JS StringToNumber/StringToBigInt
// non-decimal parsing (for example a leading '+' and '_' separators).
// strip_non_decimal_prefix() guarantees that this suffix is non-empty.
debug_assert!(!text.is_empty());
match radix {
NonDecimalRadix::Binary => text.bytes().all(|b| matches!(b, b'0' | b'1')),
NonDecimalRadix::Octal => text.bytes().all(|b| matches!(b, b'0'..=b'7')),
NonDecimalRadix::Hexadecimal => text.bytes().all(|b| b.is_ascii_hexdigit()),
}
}
/// Implements StringToBigInt per https://tc39.es/ecma262/#sec-stringtobigint.
/// Trims whitespace, handles optional sign (decimal only), and handles
/// 0b/0o/0x prefixes. Returns None if the string is not a valid
@ -8918,14 +8964,12 @@ fn string_to_bigint(s: &Utf16String) -> Option<BigInt> {
return Some(BigInt::from(0));
}
// Check for non-decimal prefixes (no sign allowed).
if s_trimmed.len() > 2 {
let (prefix, rest) = s_trimmed.split_at(2);
match prefix {
"0b" | "0B" => return BigInt::parse_bytes(rest.as_bytes(), 2),
"0o" | "0O" => return BigInt::parse_bytes(rest.as_bytes(), 8),
"0x" | "0X" => return BigInt::parse_bytes(rest.as_bytes(), 16),
_ => {}
if let Some((radix, rest)) = strip_non_decimal_prefix(s_trimmed) {
if !is_valid_non_decimal_digits(rest, radix) {
return None;
}
// Convert validated suffix digits using the parser.
return BigInt::parse_bytes(rest.as_bytes(), radix.as_u32());
}
// Decimal with optional sign. Only allow digits (no dots, no exponents).
let (is_negative, digits) = if let Some(rest) = s_trimmed.strip_prefix('-') {
@ -9068,32 +9112,12 @@ fn string_to_number(s: &Utf16String) -> f64 {
if trimmed == "-Infinity" {
return f64::NEG_INFINITY;
}
if trimmed.len() > 2 {
let (prefix, rest) = trimmed.split_at(2);
match prefix {
"0b" | "0B" => {
return if rest.bytes().all(|b| b == b'0' || b == b'1') {
bigint_string_to_f64(rest, 2)
} else {
f64::NAN
};
}
"0o" | "0O" => {
return if rest.bytes().all(|b| b.is_ascii_digit() && b < b'8') {
bigint_string_to_f64(rest, 8)
} else {
f64::NAN
};
}
"0x" | "0X" => {
return if rest.bytes().all(|b| b.is_ascii_hexdigit()) {
bigint_string_to_f64(rest, 16)
} else {
f64::NAN
};
}
_ => {}
if let Some((radix, rest)) = strip_non_decimal_prefix(trimmed) {
if !is_valid_non_decimal_digits(rest, radix) {
return f64::NAN;
}
// Convert validated suffix digits using the parser.
return bigint_string_to_f64(rest, radix.as_u32());
}
if !trimmed.bytes().all(|b| {
b.is_ascii_digit() || b == b'.' || b == b'e' || b == b'E' || b == b'+' || b == b'-'

View file

@ -0,0 +1,101 @@
// Literal-only regression tests that pin non-decimal prefix behavior in
// numeric and bigint coercion expressions.
function expectLooseNumberEquality(value, target, expected) {
expect(value == target).toBe(expected);
expect(value != target).toBe(!expected);
expect(target == value).toBe(expected);
expect(target != value).toBe(!expected);
}
function expectLooseBigIntEquality(value, target, expected) {
expect(value == target).toBe(expected);
expect(value != target).toBe(!expected);
expect(target == value).toBe(expected);
expect(target != value).toBe(!expected);
}
function expectLooseEquality(value, numberTarget, bigintTarget, expected) {
expectLooseNumberEquality(value, numberTarget, expected);
expectLooseBigIntEquality(value, bigintTarget, expected);
}
test("naked non-decimal prefixes stay invalid in folded number contexts", () => {
for (const value of ["0x", "0o", "0b"]) expect(+value).toBeNaN();
});
test("uppercase naked prefixes stay invalid in folded number contexts", () => {
for (const value of ["0X", "0O", "0B"]) expect(+value).toBeNaN();
});
test("uppercase prefixed literals stay valid in folded loose equality", () => {
for (const [value, numberTarget, bigintTarget] of [
["0X10", 16, 16n],
["0O10", 8, 8n],
["0B10", 2, 2n],
]) {
expectLooseEquality(value, numberTarget, bigintTarget, true);
}
});
test("trimmed uppercase prefixed literals stay valid in folded loose equality", () => {
for (const [value, numberTarget, bigintTarget] of [
[" 0X10 ", 16, 16n],
[" 0O10 ", 8, 8n],
[" 0B10 ", 2, 2n],
]) {
expectLooseEquality(value, numberTarget, bigintTarget, true);
}
});
test("valid non-decimal literals stay valid in folded number contexts", () => {
expect(+"0x10").toBe(16);
expect(+"0o10").toBe(8);
expect(+"0b10").toBe(2);
expect(+" 0X10 ").toBe(16);
expect(+" 0O10 ").toBe(8);
expect(+" 0B10 ").toBe(2);
});
test("non-JS suffix syntax stays invalid in folded number contexts", () => {
for (const value of ["0x+1", "0x1_0", "0b+1", "0b1_0", "0o+7", "0o1_0"]) expect(+value).toBeNaN();
});
test("naked non-decimal prefixes stay invalid in folded bigint relational contexts", () => {
for (const value of ["0x", "0o", "0b"]) {
expect(value < 1n).toBeFalse();
expect(1n < value).toBeFalse();
}
});
test("naked prefixes stay invalid in folded loose equality", () => {
for (const value of ["0x", "0o", "0b"]) expectLooseEquality(value, 0, 0n, false);
});
test("invalid prefixed digits stay invalid in folded loose equality", () => {
for (const value of ["0xg", "0b2", "0o8"]) expectLooseEquality(value, 0, 0n, false);
});
test("non-JS suffix syntax stays invalid in folded loose equality", () => {
for (const [value, numberTarget, bigintTarget] of [
["0x+1", 1, 1n],
["0x1_0", 16, 16n],
["0b+1", 1, 1n],
["0b1_0", 2, 2n],
["0o+7", 7, 7n],
["0o1_0", 8, 8n],
]) {
expectLooseEquality(value, numberTarget, bigintTarget, false);
}
});
test("valid prefixed digits stay valid in folded loose equality", () => {
for (const [value, numberTarget, bigintTarget] of [
["0x10", 16, 16n],
["0o10", 8, 8n],
["0b10", 2, 2n],
]) {
expectLooseEquality(value, numberTarget, bigintTarget, true);
}
});

View file

@ -0,0 +1,16 @@
// Literal-only regression tests for inputs that previously panicked during
// numeric and bigint coercion.
// In these inputs, byte index 2 can land inside a multi-byte UTF-8 scalar.
// The old split_at(2) path could panic on this shape.
test("panic-shape inputs do not crash folded StringToNumber", () => {
expect(+"aä").toBeNaN();
expect(+"€").toBeNaN();
expect(+"0💩").toBeNaN();
});
test("panic-shape inputs do not crash folded StringToBigInt", () => {
expect("aä" == 0n).toBeFalse();
expect("€" == 0n).toBeFalse();
expect("0💩" == 0n).toBeFalse();
});