fix(regex): reject templates without cooked values (#28345)

Reject no-substitution template inputs without a defined cooked value,
so RegExp constructor parsing cannot invent characters for invalid
escapes.

AI-assisted implementation.
This commit is contained in:
Boshen authored and GitHub committed 2026-10-29 16:30:24 +08:00
1 parent c2d663de29
commit 8e6165265e
10 files changed
+91 -134

No files matched your search

@@ -53,11 +53,12 @@ impl<'a, 'b> LiteralParser<'a, 'b> {
}
}
/// Regular expression parser for `new RegExp("constrocutor")` usage.
/// - `pattern_text`: the string literal text as 1st argument of `RegExp` constructor
/// - `flags_text`: the string literal text as 2nd argument of `RegExp` constructor
/// Regular expression parser for `RegExp` constructor arguments.
/// - `pattern_text`: the source text of the first argument
/// - `flags_text`: the source text of the second argument
///
/// String literal text should be in the form of `'...'` or `"..."` and may contain escape sequences.
/// Arguments must be quoted strings (`'...'` or `"..."`) or no-substitution
/// templates with a defined cooked value. Escape sequences are decoded.
pub struct ConstructorParser<'a> {
allocator: &'a Allocator,
pattern_text: &'a str,
@@ -154,9 +154,12 @@ impl Parser {
if let Some((cp, escape_kind)) = self.parse_template_escape_sequence(offset_start)? {
return Ok(Some(((offset_start, self.offset()), cp, escape_kind)));
}
if let Some(cp) = self.parse_not_escape_sequence()? {
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
// NotEscapeSequence has no cooked value. Constructor arguments are
// untagged templates, so these escapes cannot form a pattern string.
return Err(diagnostics::invalid_input(Span::new(
self.options.span_offset + offset_start,
self.options.span_offset + self.offset(),
)));
}
if let Some(cp) = self.parse_line_terminator_sequence() {
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
@@ -229,115 +232,6 @@ impl Parser {
None
}
// ```
// NotEscapeSequence ::
// 0 DecimalDigit
// DecimalDigit but not 0
// x [lookahead ∉ HexDigit]
// x HexDigit [lookahead ∉ HexDigit]
// u [lookahead ∉ HexDigit] [lookahead ≠ {]
// u HexDigit [lookahead ∉ HexDigit]
// u HexDigit HexDigit [lookahead ∉ HexDigit]
// u HexDigit HexDigit HexDigit [lookahead ∉ HexDigit]
// u { [lookahead ∉ HexDigit]
// u { NotCodePoint [lookahead ∉ HexDigit]
// u { CodePoint [lookahead ∉ HexDigit] [lookahead ≠ }]
// ```
fn parse_not_escape_sequence(&mut self) -> Result<Option<u32>> {
let checkpoint = self.checkpoint();
// 0 DecimalDigit
if self.eat('0') {
if let Some(ch) = self.peek()
&& ch.is_ascii_digit()
{
self.advance();
return Ok(Some(ch as u32));
}
self.rewind(checkpoint);
}
// DecimalDigit but not 0
if let Some(ch) = self.peek()
&& ch.is_ascii_digit()
&& ch != '0'
{
self.advance();
return Ok(Some(ch as u32));
}
// x [lookahead ∉ HexDigit] or x HexDigit [lookahead ∉ HexDigit]
if self.eat('x') {
let offset_start = self.offset();
match self.consume_hex_digits(offset_start) {
Ok(Some(_)) => {
self.rewind(checkpoint);
return Ok(None);
}
Ok(None) => {
return Ok(Some('x' as u32));
}
Err(e) => {
return Err(e);
}
}
}
// u [lookahead ∉ HexDigit] [lookahead ≠ {]
// u HexDigit [lookahead ∉ HexDigit]
// u HexDigit HexDigit [lookahead ∉ HexDigit]
// u HexDigit HexDigit HexDigit [lookahead ∉ HexDigit]
// u { [lookahead ∉ HexDigit]
// u { NotCodePoint [lookahead ∉ HexDigit]
// u { CodePoint [lookahead ∉ HexDigit] [lookahead ≠ }]
if self.eat('u') {
let offset_start = self.offset();
if self.eat('{') {
match self.consume_hex_digits(offset_start) {
Ok(Some(_)) => {
if !self.eat('}') {
return Ok(Some('u' as u32));
}
self.rewind(checkpoint);
return Ok(None);
}
Ok(None) => {
return Ok(Some('u' as u32));
}
Err(e) => {
return Err(e);
}
}
}
let mut hex_count = 0;
for _ in 0..4 {
if let Some(ch) = self.peek() {
if ch.is_ascii_hexdigit() {
self.advance();
hex_count += 1;
} else {
break;
}
} else {
break;
}
}
if hex_count == 0 || hex_count < 4 {
return Ok(Some('u' as u32));
}
if let Some(ch) = self.peek()
&& ch.is_ascii_hexdigit()
{
self.rewind(checkpoint);
return Ok(None);
}
self.rewind(checkpoint);
return Ok(None);
}
Ok(None)
}
// ```
// HexEscapeSequence ::
// x HexDigit HexDigit
@@ -0,0 +1,37 @@
const tag = () => {};
tag`\1`;
tag`\2`;
tag`\3`;
tag`\4`;
tag`\5`;
tag`\6`;
tag`\7`;
tag`\8`;
tag`\9`;
tag`\00`;
tag`\01`;
tag`\02`;
tag`\03`;
tag`\04`;
tag`\05`;
tag`\06`;
tag`\07`;
tag`\08`;
tag`\09`;
tag`\x`;
tag`\x0`;
tag`\u`;
tag`\u000`;
tag`\u{}`;
tag`\u{110000}`;
new RegExp(`\0`);
new RegExp(`\x30`);
new RegExp(`\u0030`);
new RegExp(`\u{30}`);
new RegExp(`\z`);
new RegExp(`\$`);
new RegExp(`\``);
new RegExp(`\\`);
+2 -2
View File
@@ -1,3 +1,3 @@
codegen_misc Summary:
AST Parsed : 127/127 (100.00%)
Positive Passed: 127/127 (100.00%)
AST Parsed : 128/128 (100.00%)
Positive Passed: 128/128 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
formatter_misc Summary:
AST Parsed : 127/127 (100.00%)
Positive Passed: 127/127 (100.00%)
AST Parsed : 128/128 (100.00%)
Positive Passed: 128/128 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
lexer_misc Summary:
AST Parsed : 390/390 (100.00%)
Positive Passed: 390/390 (100.00%)
AST Parsed : 391/391 (100.00%)
Positive Passed: 391/391 (100.00%)
+2 -2
View File
@@ -1,6 +1,6 @@
parser_misc Summary:
AST Parsed : 127/127 (100.00%)
Positive Passed: 127/127 (100.00%)
AST Parsed : 128/128 (100.00%)
Positive Passed: 128/128 (100.00%)
Negative Passed: 265/265 (100.00%)
× TS(1275): 'accessor' modifier can only appear on a property declaration.
+2 -2
View File
@@ -1,6 +1,6 @@
semantic_misc Summary:
AST Parsed : 127/127 (100.00%)
Positive Passed: 121/127 (95.28%)
AST Parsed : 128/128 (100.00%)
Positive Passed: 122/128 (95.31%)
semantic Error: tasks/coverage/misc/pass/declare-let-private.ts
Bindings mismatch:
after transform: ScopeId(0): ["private"]
@@ -1,3 +1,3 @@
transformer_misc Summary:
AST Parsed : 127/127 (100.00%)
Positive Passed: 127/127 (100.00%)
AST Parsed : 128/128 (100.00%)
Positive Passed: 128/128 (100.00%)
+31 -6
View File
@@ -7,14 +7,14 @@ use oxc::{
allocator::Allocator,
ast::{
AstKind, Comment,
ast::{Program, RegExpLiteral},
ast::{Program, RegExpLiteral, TemplateLiteral},
},
ast_visit::{Visit, walk},
codegen::{CodegenOptions, CodegenReturn},
diagnostics::{Diagnostics, OxcDiagnostic},
minifier::CompressOptions,
parser::{ParseOptions, ParserReturn},
regular_expression::{LiteralParser, Options},
regular_expression::{ConstructorParser, LiteralParser, Options},
semantic::SemanticBuilderReturn,
span::{ContentEq, SourceType, Span},
syntax::node::NodeId,
@@ -82,7 +82,7 @@ impl CompilerInterface for Driver {
let ParserReturn { program, fatal_error, diagnostics, .. } = parser_return;
self.fatal_error = *fatal_error;
self.source_type = Some(program.source_type);
self.check_ast_nodes(program);
self.check_ast_nodes(program, diagnostics.is_empty() && !*fatal_error);
if self.check_comments(&program.comments) {
return ControlFlow::Break(());
}
@@ -165,8 +165,8 @@ impl Driver {
false
}
fn check_ast_nodes(&mut self, program: &Program<'_>) {
CheckASTNodes::new(self, program.source_text).check(program);
fn check_ast_nodes(&mut self, program: &Program<'_>, check_template_cooked_values: bool) {
CheckASTNodes::new(self, program.source_text, check_template_cooked_values).check(program);
}
}
@@ -175,15 +175,21 @@ struct CheckASTNodes<'a> {
source_text: &'a str,
allocator: Allocator,
node_ids: FxHashSet<NodeId>,
check_template_cooked_values: bool,
}
impl<'a> CheckASTNodes<'a> {
fn new(driver: &'a mut Driver, source_text: &'a str) -> Self {
fn new(
driver: &'a mut Driver,
source_text: &'a str,
check_template_cooked_values: bool,
) -> Self {
Self {
driver,
source_text,
allocator: Allocator::default(),
node_ids: FxHashSet::default(),
check_template_cooked_values,
}
}
@@ -193,6 +199,25 @@ impl<'a> CheckASTNodes<'a> {
}
impl<'a> Visit<'a> for CheckASTNodes<'a> {
fn visit_template_literal(&mut self, template: &TemplateLiteral<'a>) {
walk::walk_template_literal(self, template);
if self.check_template_cooked_values
&& template.expressions.is_empty()
&& let [quasi] = template.quasis.as_slice()
&& quasi.value.cooked.is_none()
{
// Tagged templates can contain NotEscapeSequence, but a standalone
// constructor argument needs a string value from an untagged template.
let raw = template.span.source_text(self.source_text);
assert!(
ConstructorParser::new(&self.allocator, raw, None, Options::default())
.parse()
.is_err(),
"RegExp constructor accepted a template without a cooked value: {raw:?}"
);
}
}
fn enter_node(&mut self, kind: AstKind<'a>) {
let id = kind.node_id();
// These are AST invariants, including for recovered syntax errors.