From d95baf67f6f8c18ad08542fc29b99fddd84c028b Mon Sep 17 00:00:00 2001 From: aplefull Date: Tue, 10 Feb 2026 14:26:53 +0100 Subject: [PATCH] LibJS: Prevent escaped surrogates from combining in Unicode regexes Escaped surrogate sequences should not combine with adjacent literal surrogates in Unicode mode. We now use `\u{XXXX}` braces instead of `\uXXXX` when escaping code units in Unicode mode, so LibRegex treats each as a standalone code point. Also prevent GenericLexer from combining `\uXXXX` and `\u{XXXX}`. --- AK/GenericLexer.h | 5 +++++ Libraries/LibJS/Runtime/RegExpObject.cpp | 19 +++++++++++++++++-- Tests/LibJS/Runtime/builtins/RegExp/RegExp.js | 11 +++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/AK/GenericLexer.h b/AK/GenericLexer.h index badfeb8351..56b9fccd36 100644 --- a/AK/GenericLexer.h +++ b/AK/GenericLexer.h @@ -452,6 +452,11 @@ protected: if (!combine_surrogate_pairs || !consume_specific("\\u"sv)) return *high_surrogate; + if (next_is('{')) { + retreat(2); + return *high_surrogate; + } + auto low_surrogate = decode_one_surrogate(); if (!low_surrogate.has_value()) return UnicodeEscapeError::MalformedUnicodeEscape; diff --git a/Libraries/LibJS/Runtime/RegExpObject.cpp b/Libraries/LibJS/Runtime/RegExpObject.cpp index 2dfa731193..0118fbcb84 100644 --- a/Libraries/LibJS/Runtime/RegExpObject.cpp +++ b/Libraries/LibJS/Runtime/RegExpObject.cpp @@ -6,6 +6,7 @@ */ #include +#include #include #include #include @@ -97,7 +98,6 @@ ErrorOr parse_regex_pattern(Utf16View const& pat StringBuilder builder; - // FIXME: We need to escape multi-byte code units for LibRegex to parse since the lexer there doesn't handle unicode. auto previous_code_unit_was_backslash = false; for (size_t i = 0; i < pattern.length_in_code_units(); ++i) { u16 code_unit = pattern.code_unit_at(i); @@ -109,7 +109,22 @@ ErrorOr parse_regex_pattern(Utf16View const& pat // As such, we're going to remove the (invalid) backslash and pretend it never existed. if (!previous_code_unit_was_backslash) builder.append('\\'); - builder.appendff("u{:04x}", code_unit); + + if ((unicode || unicode_sets) && AK::UnicodeUtils::is_utf16_high_surrogate(code_unit) && i + 1 < pattern.length_in_code_units()) { + u16 next_code_unit = pattern.code_unit_at(i + 1); + if (AK::UnicodeUtils::is_utf16_low_surrogate(next_code_unit)) { + u32 combined = AK::UnicodeUtils::decode_utf16_surrogate_pair(code_unit, next_code_unit); + builder.appendff("u{{{:x}}}", combined); + ++i; + previous_code_unit_was_backslash = false; + continue; + } + } + + if (unicode || unicode_sets) + builder.appendff("u{{{:04x}}}", code_unit); + else + builder.appendff("u{:04x}", code_unit); } else { builder.append_code_point(code_unit); } diff --git a/Tests/LibJS/Runtime/builtins/RegExp/RegExp.js b/Tests/LibJS/Runtime/builtins/RegExp/RegExp.js index 72a29c2aa1..a63af9d4a3 100644 --- a/Tests/LibJS/Runtime/builtins/RegExp/RegExp.js +++ b/Tests/LibJS/Runtime/builtins/RegExp/RegExp.js @@ -555,3 +555,14 @@ test("Unicode case-insensitive matching", () => { testMatch(/[\u03A9]/i, "\u2126", null); testMatch(/[\u03A9]/iv, "\u2126", ["\u2126"]); }); + +test("surrogate pairs", () => { + expect(eval(`/[\uD83D\uDC38]/u`).exec("\u{1F438}")?.[0]).toBe("\u{1F438}"); + expect(eval(`/[\uD83D\uDC38]/`).exec("\u{1F438}")?.[0]).toBe("\uD83D"); + expect(eval(`/[\\uD83D\uDC38]/u`).exec("\u{1F438}")).toBeNull(); + expect(eval(`/[\\u{D83D}\uDC38]/u`).exec("\u{1F438}")).toBeNull(); + expect(eval(`/[\uD83D\\uDC38]/u`).exec("\u{1F438}")).toBeNull(); + expect(eval(`/[\uD83D\\u{DC38}]/u`).exec("\u{1F438}")).toBeNull(); + expect(eval(`/[\\uD83D\uDC38]/`).exec("\u{1F438}")?.[0]).toBe("\uD83D"); + expect(eval(`/[\uD83D\\uDC38]/`).exec("\u{1F438}")?.[0]).toBe("\uD83D"); +});