@@ -258,6 +258,73 @@ public function data_identifier_or_number(): array {
258258 );
259259 }
260260
261+ /**
262+ * Test that unclosed quoted strings with trailing backslashes do not
263+ * cause out-of-bounds string access in read_quoted_text().
264+ *
265+ * The backslash-counting loop walks backward from the position returned
266+ * by strcspn(). When the closing quote is missing, strcspn() returns
267+ * the remaining string length. If the last byte is a backslash, the
268+ * loop treats the absent quote as escaped and advances past the end
269+ * of the string. On the next iteration, the loop accesses an invalid
270+ * string offset, triggering "Uninitialized string offset" warnings.
271+ *
272+ * @dataProvider data_unclosed_strings_with_backslashes
273+ */
274+ public function test_unclosed_string_with_trailing_backslash ( string $ sql ): void {
275+ set_error_handler (
276+ function ( $ severity , $ message , $ file , $ line ) {
277+ throw new \ErrorException ( $ message , 0 , $ severity , $ file , $ line );
278+ },
279+ E_WARNING | E_NOTICE
280+ );
281+
282+ try {
283+ $ lexer = new WP_MySQL_Lexer ( $ sql );
284+ while ( $ lexer ->next_token () ) {
285+ // Consume all tokens.
286+ }
287+ } finally {
288+ restore_error_handler ();
289+ }
290+
291+ // If we reach here without an ErrorException, no OOB access occurred.
292+ $ this ->assertNull ( $ lexer ->get_token () );
293+ }
294+
295+ public function data_unclosed_strings_with_backslashes (): array {
296+ return array (
297+ 'single-quoted trailing backslash ' => array ( "SELECT ' \\" ),
298+ 'double-quoted trailing backslash ' => array ( 'SELECT " \\' ),
299+ 'even trailing backslashes ' => array ( "SELECT ' \\\\" ),
300+ 'odd trailing backslashes ' => array ( "SELECT ' \\\\\\" ),
301+ 'backslash-only single-quoted ' => array ( "' \\" ),
302+ 'backslash-only double-quoted ' => array ( '" \\' ),
303+ );
304+ }
305+
306+ /**
307+ * Regression: valid strings with escapes must still tokenize correctly.
308+ *
309+ * @dataProvider data_valid_escaped_strings
310+ */
311+ public function test_valid_escaped_string ( string $ sql , int $ expected_token_id ): void {
312+ $ lexer = new WP_MySQL_Lexer ( $ sql );
313+ $ this ->assertTrue ( $ lexer ->next_token () );
314+ $ this ->assertSame ( $ expected_token_id , $ lexer ->get_token ()->id );
315+ }
316+
317+ public function data_valid_escaped_strings (): array {
318+ return array (
319+ 'escaped single quote ' => array ( "'it \\'s' " , WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
320+ 'trailing escaped backslash ' => array ( "'path \\\\' " , WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
321+ 'doubled single quote ' => array ( "'it''s' " , WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
322+ 'empty single-quoted string ' => array ( "'' " , WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
323+ 'escaped double quote ' => array ( '"col \\"name" ' , WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT ),
324+ 'backtick identifier ' => array ( '`my_column` ' , WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ),
325+ );
326+ }
327+
261328 private function get_token_names ( array $ token_types ): array {
262329 return array_map (
263330 function ( $ token_type ) {
0 commit comments