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:
parent
ce365e6513
commit
a1ae402bb9
3 changed files with 173 additions and 32 deletions
|
|
@ -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'-'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in a new issue