From 76a739ac6294e0f13969dd1c1367e286367f283c Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 16 Oct 2025 16:06:09 +0200 Subject: [PATCH 1/7] Fix usage of fully qualified table names --- tests/WP_SQLite_Driver_Tests.php | 187 ++++++++++- .../sqlite-ast/class-wp-sqlite-driver.php | 294 ++++++++++++------ ...s-wp-sqlite-information-schema-builder.php | 38 ++- 3 files changed, 419 insertions(+), 100 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index a5c106c6..63966be9 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -4195,12 +4195,22 @@ public function testInformationSchemaIsReadonly( string $query ): void { public function getInformationSchemaIsReadonlyTestData(): array { return array( array( 'INSERT INTO information_schema.tables (table_name) VALUES ("t")' ), + array( 'REPLACE INTO information_schema.tables (table_name) VALUES ("t")' ), array( 'UPDATE information_schema.tables SET table_name = "new_t" WHERE table_name = "t"' ), + array( 'UPDATE information_schema.tables, information_schema.columns SET table_name = "new_t" WHERE table_name = "t"' ), array( 'DELETE FROM information_schema.tables WHERE table_name = "t"' ), + array( 'DELETE it FROM t, information_schema.tables it WHERE table_name = "t"' ), + array( 'TRUNCATE information_schema.tables' ), array( 'CREATE TABLE information_schema.new_table (id INT)' ), array( 'ALTER TABLE information_schema.tables ADD COLUMN new_column INT' ), array( 'DROP TABLE information_schema.tables' ), - array( 'TRUNCATE information_schema.tables' ), + array( 'LOCK TABLES information_schema.tables READ' ), + array( 'CREATE INDEX idx_name ON information_schema.tables (name)' ), + array( 'DROP INDEX `PRIMARY` ON information_schema.tables' ), + array( 'ANALYZE TABLE information_schema.tables' ), + array( 'CHECK TABLE information_schema.tables' ), + array( 'OPTIMIZE TABLE information_schema.tables' ), + array( 'REPAIR TABLE information_schema.tables' ), ); } @@ -4218,12 +4228,22 @@ public function testInformationSchemaIsReadonlyWithUse( string $query ): void { public function getInformationSchemaIsReadonlyWithUseTestData(): array { return array( array( 'INSERT INTO tables (table_name) VALUES ("t")' ), + array( 'REPLACE INTOtables (table_name) VALUES ("t")' ), array( 'UPDATE tables SET table_name = "new_t" WHERE table_name = "t"' ), + array( 'UPDATE tables, columns SET table_name = "new_t" WHERE table_name = "t"' ), array( 'DELETE FROM tables WHERE table_name = "t"' ), + array( 'DELETE it FROM t, tables it WHERE table_name = "t"' ), + array( 'TRUNCATE tables' ), array( 'CREATE TABLE new_table (id INT)' ), array( 'ALTER TABLE tables ADD COLUMN new_column INT' ), array( 'DROP TABLE tables' ), - array( 'TRUNCATE tables' ), + array( 'LOCK TABLES tables READ' ), + array( 'CREATE INDEX idx_name ON tables (name)' ), + array( 'DROP INDEX `PRIMARY` ON tables' ), + array( 'ANALYZE TABLE tables' ), + array( 'CHECK TABLE tables' ), + array( 'OPTIMIZE TABLE tables' ), + array( 'REPAIR TABLE tables' ), ); } @@ -9439,4 +9459,167 @@ public function testCastExpression(): void { $result ); } + + public function testFullyQualifiedTableName(): void { + // Ensure "information_schema.tables" is empty. + $this->assertQuery( 'DROP TABLE _options, _dates' ); + $result = $this->assertQuery( 'SELECT * FROM information_schema.tables' ); + $this->assertCount( 0, $result ); + + // Switch to the "information_schema" database. + $this->assertQuery( 'USE information_schema' ); + + // CREATE TABLE + $this->assertQuery( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ); + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 1, $result ); + + // INSERT + $this->assertQuery( 'INSERT INTO wp.t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $result ); + + // SELECT + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $result ); + + // UPDATE + $this->assertQuery( 'UPDATE wp.t SET id = 2' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $result ); + + // DELETE + $this->assertQuery( 'DELETE FROM wp.t' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertCount( 0, $result ); + + // TRUNCATE TABLE + $this->assertQuery( 'INSERT INTO wp.t (id) VALUES (1)' ); + $this->assertQuery( 'TRUNCATE TABLE wp.t' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertCount( 0, $result ); + + // SHOW CREATE TABLE + $result = $this->assertQuery( 'SHOW CREATE TABLE wp.t' ); + $this->assertEquals( + array( + (object) array( + 'Create Table' => implode( + "\n", + array( + 'CREATE TABLE `t` (', + ' `id` int NOT NULL,', + ' PRIMARY KEY (`id`)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + ), + ), + $result + ); + + // SHOW COLUMNS + $result = $this->assertQuery( 'SHOW COLUMNS FROM wp.t' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => '', + ), + ), + $result + ); + + // SHOW COLUMNS with both qualified table name and "FROM database" clause. + // In case both are present, the "FROM database" clause takes precedence. + $result = $this->assertQuery( 'SHOW COLUMNS FROM information_schema.t FROM wp' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => '', + ), + ), + $result + ); + + // SHOW INDEXES + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // SHOW INDEXES with both qualified table name and "FROM database" clause. + // In case both are present, the "FROM database" clause takes precedence. + $result = $this->assertQuery( 'SHOW INDEXES FROM information_schema.t FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // DESCRIBE + $result = $this->assertQuery( 'DESCRIBE wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'id', $result[0]->Field ); + $this->assertEquals( 'int', $result[0]->Type ); + $this->assertEquals( 'NO', $result[0]->Null ); + $this->assertEquals( 'PRI', $result[0]->Key ); + $this->assertEquals( null, $result[0]->Default ); + $this->assertEquals( '', $result[0]->Extra ); + + // SHOW TABLES + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 't', $result[0]->Tables_in_wp ); + + // SHOW TABLE STATUS + $result = $this->assertQuery( 'SHOW TABLE STATUS FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 't', $result[0]->Name ); + + // ALTER TABLE + $this->assertQuery( 'ALTER TABLE wp.t ADD COLUMN name VARCHAR(255)' ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM wp.t' ); + $this->assertCount( 2, $result ); + + // CREATE INDEX + $this->assertQuery( 'CREATE INDEX idx_name ON wp.t (name)' ); + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 2, $result ); + $this->assertEquals( 'idx_name', $result[1]->Key_name ); + + // DROP INDEX + $this->assertQuery( 'DROP INDEX idx_name ON wp.t' ); + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // LOCK TABLE + $this->assertQuery( 'LOCK TABLES wp.t READ' ); + + // UNLOCK TABLE + $this->assertQuery( 'UNLOCK TABLES' ); + + // ANALYZE TABLE + $this->assertQuery( 'ANALYZE TABLE wp.t' ); + + // CHECK TABLE + $this->assertQuery( 'CHECK TABLE wp.t' ); + + // OPTIMIZE TABLE + $this->assertQuery( 'OPTIMIZE TABLE wp.t' ); + + // REPAIR TABLE + $this->assertQuery( 'REPAIR TABLE wp.t' ); + + // DROP TABLE + $this->assertQuery( 'DROP TABLE wp.t' ); + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 0, $result ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 23a45172..1480b82e 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -1351,9 +1351,13 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node // Check if the table(s) exists. $lock_items = $subnode->get_child_nodes( 'lockItem' ); foreach ( $lock_items as $lock_item ) { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $lock_item->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $lock_item->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + try { /* * Attempt to query the table directly rather than checking @@ -1529,6 +1533,13 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo $parts = array(); foreach ( $node->get_children() as $child ) { + if ( $child instanceof WP_Parser_Node && 'tableRef' === $child->rule_name ) { + $database = $this->get_database_name( $child ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + if ( $child instanceof WP_MySQL_Token && WP_MySQL_Lexer::IGNORE_SYMBOL === $child->id ) { // Translate "UPDATE IGNORE" to "UPDATE OR IGNORE". $parts[] = 'OR IGNORE'; @@ -1603,6 +1614,21 @@ private function execute_update_statement( WP_Parser_Node $node ): void { $node->get_first_child_node( 'tableReferenceList' ) ); + /* + * Deny UPDATE for information schema tables. + * + * This basic approach is rather restrictive, as it blocks the usage + * of information schema tables anywhere in the UPDATE statement. + * + * TODO: Implement support for UPDATE statements like: + * UPDATE t, information_schema.columns c SET t.column = c.column ... + */ + foreach ( $table_alias_map as $alias => $data ) { + if ( 'information_schema' === strtolower( $data['database'] ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + // Determine whether the UPDATE statement modifies multiple tables. $update_list_node = $node->get_first_child_node( 'updateList' ); $update_target = null; @@ -1823,14 +1849,24 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { $alias_map = array(); $table_ref_list = $node->get_first_child_node( 'tableReferenceList' ); foreach ( $table_ref_list->get_descendant_nodes( 'singleTable' ) as $single_table ) { - $alias = $this->unquote_sqlite_identifier( - $this->translate( $single_table->get_first_child_node( 'tableAlias' ) ) - ); - $ref = $this->unquote_sqlite_identifier( - $this->translate( $single_table->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $single_table->get_first_child_node( 'tableRef' ); + $alias_node = $single_table->get_first_child_node( 'tableAlias' ); + if ( $alias_node ) { + $alias = $this->unquote_sqlite_identifier( $this->translate( $alias_node ) ); + } else { + $alias = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + } + + // For an information schema table, check if is a DELETE target. + $database = $this->get_database_name( $table_ref ); + if ( + 'information_schema' === strtolower( $database ) + && in_array( $alias, $table_aliases, true ) + ) { + throw $this->new_access_denied_to_information_schema_exception(); + } - $alias_map[ $alias ] = $ref; + $alias_map[ $alias ] = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); } // 3. Compose the SELECT query to fetch ROWIDs to delete. @@ -1880,6 +1916,12 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { // @TODO: Translate DELETE with JOIN to use a subquery. + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $query = $this->translate( $node ); $this->execute_sqlite_query( $query ); $this->set_result_from_affected_rows(); @@ -1913,9 +1955,13 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { } // Get table name. - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $subnode->get_first_child_node( 'tableName' ) ) - ); + $table_name_node = $subnode->get_first_child_node( 'tableName' ); + $database = $this->get_database_name( $table_name_node ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_name_node ) ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } // Handle IF NOT EXISTS. if ( $subnode->has_child_node( 'ifNotExists' ) ) { @@ -1956,9 +2002,12 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_alter_table_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_descendant_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_descendant_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -1972,7 +2021,7 @@ private function execute_alter_table_statement( WP_Parser_Node $node ): void { FROM %s WHERE table_schema = ? AND table_name = ?', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // Track column renames and removals. @@ -2041,6 +2090,11 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void { $table_is_temporary = $child_node->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ); $queries = array(); foreach ( $table_refs as $table_ref ) { + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $parts = array(); foreach ( $child_node->get_children() as $child ) { $is_token = $child instanceof WP_MySQL_Token; @@ -2078,9 +2132,12 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_truncate_table_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } $this->execute_sqlite_query( sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) ) @@ -2104,14 +2161,18 @@ private function execute_truncate_table_statement( WP_Parser_Node $node ): void * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_create_index_statement( WP_Parser_Node $node ): void { - $this->information_schema_builder->record_create_index( $node ); - $create_index = $node->get_first_child_node( 'createIndex' ); $target = $create_index->get_first_child_node( 'createIndexTarget' ); + $table_ref = $target->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $this->information_schema_builder->record_create_index( $node ); - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $target->get_first_child_node( 'tableRef' ) ) - ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $create_index->get_first_child_node( 'indexName' ) ) ); @@ -2158,12 +2219,16 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_drop_index_statement( WP_Parser_Node $node ): void { + $drop_index = $node->get_first_child_node( 'dropIndex' ); + $table_ref = $drop_index->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $this->information_schema_builder->record_drop_index( $node ); - $drop_index = $node->get_first_child_node( 'dropIndex' ); - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $drop_index->get_first_child_node( 'tableRef' ) ) - ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $drop_index->get_first_child_node( 'indexRef' ) ) ); @@ -2212,9 +2277,18 @@ private function execute_show_statement( WP_Parser_Node $node ): void { return; case WP_MySQL_Lexer::CREATE_SYMBOL: if ( WP_MySQL_Lexer::TABLE_SYMBOL === $keyword2->id ) { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + // Refuse SHOW CREATE TABLE for information schema tables, + // as we don't have the table definitions at the moment. + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_driver_exception( + sprintf( "SHOW command denied to user 'sqlite'@'%%' for table '%s'", $table_name ), + '42000' + ); + } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2236,10 +2310,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { case WP_MySQL_Lexer::INDEX_SYMBOL: case WP_MySQL_Lexer::INDEXES_SYMBOL: case WP_MySQL_Lexer::KEYS_SYMBOL: - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); - $this->execute_show_index_statement( $table_name ); + $this->execute_show_index_statement( $node ); return; case WP_MySQL_Lexer::GRANTS_SYMBOL: $this->set_results_from_fetched_data( @@ -2331,10 +2402,21 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void /** * Translate and execute a MySQL SHOW INDEX statement in SQLite. * - * @param string $table_name The table name to show indexes for. + * @param WP_Parser_Node $node The "showStatement" AST node. */ - private function execute_show_index_statement( string $table_name ): void { - // TODO: FROM/IN (multiple) + private function execute_show_index_statement( WP_Parser_Node $node ): void { + $table_ref = $node->get_first_child_node( 'tableRef' ); + $in_db = $node->get_first_child_node( 'inDb' ); + + // Get database and table name. + if ( $in_db ) { + // FROM/IN database. + $database = $this->get_database_name( $in_db ); + } else { + $database = $this->get_database_name( $table_ref ); + } + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + // TODO: WHERE $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2388,7 +2470,7 @@ private function execute_show_index_statement( string $table_name ): void { ROWID, SEQ_IN_INDEX ", - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $index_info ); @@ -2532,20 +2614,17 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { */ private function execute_show_columns_statement( WP_Parser_Node $node ): void { // TODO: EXTENDED, FULL - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $in_db = $node->get_first_child_node( 'inDb' ); - // FROM/IN database. - $in_db = $node->get_first_child_node( 'inDb' ); - if ( null === $in_db ) { - $database = $this->db_name; + // Get database and table name. + if ( $in_db ) { + // FROM/IN database. + $database = $this->get_database_name( $in_db ); } else { - $database = $this->unquote_sqlite_identifier( - $this->translate( $in_db->get_first_child_node( 'identifier' ) ) - ); + $database = $this->get_database_name( $table_ref ); } - + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); // Check if the table exists. @@ -2610,9 +2689,9 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_describe_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2631,7 +2710,7 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { AND table_name = ? ORDER BY ordinal_position ', - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $column_info ); @@ -2647,10 +2726,9 @@ private function execute_use_statement( WP_Parser_Node $node ): void { $database_name = $this->unquote_sqlite_identifier( $this->translate( $node->get_first_child_node( 'identifier' ) ) ); + $database_name = strtolower( $database_name ); - if ( 'information_schema' === strtolower( $database_name ) ) { - $this->db_name = 'information_schema'; - } elseif ( $this->main_db_name === $database_name ) { + if ( $this->main_db_name === $database_name || 'information_schema' === $database_name ) { $this->db_name = $database_name; } else { throw $this->new_not_supported_exception( @@ -2903,6 +2981,11 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $table_ref_list = $node->get_first_child_node( 'tableRefList' ); $results = array(); foreach ( $table_ref_list->get_child_nodes( 'tableRef' ) as $table_ref ) { + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $quoted_table_name = $this->quote_sqlite_identifier( $table_name ); try { @@ -3442,24 +3525,6 @@ private function translate_qualified_identifier( } } - /* - * Make the 'information_schema' database read-only. - * - * This basic approach is rather restrictive, as it blocks the usage - * of information schema tables in all data-modifying statements. - * - * Some of these statements can be valid, when the schema is only read: - * DELETE t FROM t JOIN information_schema.columns c ON ... - * - * If needed, a more granular approach can be implemented in the future. - */ - if ( true === $is_information_schema && false === $this->is_readonly ) { - throw $this->new_driver_exception( - "Access denied for user 'sqlite'@'%' to database 'information_schema'", - '42000' - ); - } - // Database-level object name (table, view, procedure, trigger, etc.). if ( null !== $object_node ) { $parts[] = $this->translate( $object_node ); @@ -4058,6 +4123,10 @@ public function translate_table_ref( WP_Parser_Node $node ): string { array( $sqlite_table_name ) )->fetchAll( PDO::FETCH_COLUMN ); + if ( count( $columns ) === 0 ) { + return $this->translate_sequence( $node->get_children() ); + } + // List all columns in the table, replacing columns targeting database // name columns with the configured database name. static $information_schema_db_column_map = array( @@ -4652,6 +4721,7 @@ private function create_select_item_disambiguation_map( WP_Parser_Node $select_i * The returned array maps table aliases to table names and additional data: * - key: table alias, or name if no alias is used * - value: an array of table data + * - database: the database name of the table (null for derived tables) * - table_name: the real name of the table (null for derived tables) * - table_expr: the table expression for a derived table (null for regular tables) * - join_expr: the join expression used for the table (null when no join is used) @@ -4691,11 +4761,13 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { if ( 'singleTable' === $child->rule_name ) { // Extract data from the "singleTable" node. - $name = $this->translate( $child->get_first_child_node( 'tableRef' ) ); + $table_ref = $child->get_first_child_node( 'tableRef' ); + $name = $this->translate( $table_ref ); $alias_node = $child->get_first_child_node( 'tableAlias' ); $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array( + 'database' => $this->get_database_name( $table_ref ), 'table_name' => $this->unquote_sqlite_identifier( $name ), 'table_expr' => null, 'join_expr' => $this->translate( $join_expr ), @@ -4707,6 +4779,7 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; $table_map[ $this->unquote_sqlite_identifier( $alias ) ] = array( + 'database' => null, 'table_name' => null, 'table_expr' => $this->translate( $subquery ), 'join_expr' => $this->translate( $join_expr ), @@ -4804,6 +4877,31 @@ private function get_saved_db_name( ?string $db_name = null ): string { : $db_name; } + /** + * Get the database name from one of fully-qualified name AST nodes. + * + * @param WP_Parser_Node $node The AST node. One of "tableName", "tableRef", or "inDb". + * @return string The database name. + */ + private function get_database_name( WP_Parser_Node $node ): string { + if ( 'tableName' === $node->rule_name || 'tableRef' === $node->rule_name ) { + $parts = $node->get_descendant_nodes( 'identifier' ); + if ( count( $parts ) > 1 ) { + return $this->unquote_sqlite_identifier( $this->translate( $parts[0] ) ); + } else { + return $this->db_name; + } + } elseif ( 'inDb' === $node->rule_name ) { + return $this->unquote_sqlite_identifier( + $this->translate( $node->get_first_child_node( 'identifier' ) ) + ); + } + + throw $this->new_driver_exception( + sprintf( 'Could not get database name from node: %s', $node->rule_name ) + ); + } + /** * Generate a SQLite CREATE TABLE statement from information schema data. * @@ -4818,6 +4916,9 @@ private function get_sqlite_create_table_statement( string $table_name, ?string $new_table_name = null ): array { + // This method is always used with the main database. + $database = $this->get_saved_db_name( $this->main_db_name ); + // 1. Get table info. $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' ); $table_info = $this->execute_sqlite_query( @@ -4828,7 +4929,7 @@ private function get_sqlite_create_table_statement( AND table_schema = ? AND table_name = ? ", - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetch( PDO::FETCH_ASSOC ); if ( false === $table_info ) { @@ -4845,7 +4946,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 3. Get index info, grouped by index name. @@ -4868,7 +4969,7 @@ private function get_sqlite_create_table_statement( ", $this->quote_sqlite_identifier( $statistics_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $grouped_constraints = array(); @@ -4886,7 +4987,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name', $this->quote_sqlite_identifier( $referential_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -4898,7 +4999,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL', $this->quote_sqlite_identifier( $key_column_usage_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -4930,7 +5031,7 @@ private function get_sqlite_create_table_statement( $this->quote_sqlite_identifier( $table_constraints_table ), $this->quote_sqlite_identifier( $check_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 6. Generate CREATE TABLE statement columns. @@ -5142,6 +5243,9 @@ function ( $column ) { * @return string The CREATE TABLE statement. */ private function get_mysql_create_table_statement( bool $table_is_temporary, string $table_name ): ?string { + // This method is always used with the main database. + $database = $this->get_saved_db_name( $this->main_db_name ); + // 1. Get table info. $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' ); $table_info = $this->execute_sqlite_query( @@ -5152,7 +5256,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str AND table_schema = ? AND table_name = ? ", - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetch( PDO::FETCH_ASSOC ); if ( false === $table_info ) { @@ -5172,7 +5276,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 3. Get index info, grouped by index name. @@ -5195,7 +5299,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ", $this->quote_sqlite_identifier( $statistics_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $grouped_constraints = array(); @@ -5213,7 +5317,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name', $this->quote_sqlite_identifier( $referential_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -5225,7 +5329,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL', $this->quote_sqlite_identifier( $key_column_usage_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -5257,7 +5361,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str $this->quote_sqlite_identifier( $table_constraints_table ), $this->quote_sqlite_identifier( $check_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 6. Generate CREATE TABLE statement columns. @@ -5646,6 +5750,18 @@ private function new_not_supported_exception( string $cause ): WP_SQLite_Driver_ ); } + /** + * Create a new access denied exception for the information schema database. + * + * @return WP_SQLite_Driver_Exception + */ + private function new_access_denied_to_information_schema_exception(): WP_SQLite_Driver_Exception { + return $this->new_driver_exception( + "Access denied for user 'sqlite'@'%' to database 'information_schema'", + '42000' + ); + } + /** * Convert an information schema exception to a MySQL-like driver exception. * diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php index cf864597..f07bf65f 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php @@ -481,7 +481,8 @@ public function ensure_temporary_information_schema_tables(): void { * @param WP_Parser_Node $node The "createStatement" AST node with "createTable" child. */ public function record_create_table( WP_Parser_Node $node ): void { - $table_name = $this->get_value( $node->get_first_descendant_node( 'tableName' ) ); + $table_name_node = $node->get_first_descendant_node( 'tableName' ); + $table_name = $this->get_table_name_from_node( $table_name_node ); $table_engine = $this->get_table_engine( $node ); $table_row_format = 'MyISAM' === $table_engine ? 'Fixed' : 'Dynamic'; $table_collation = $this->get_table_collation( $node ); @@ -638,7 +639,8 @@ public function record_create_table( WP_Parser_Node $node ): void { * @param WP_Parser_Node $node The "alterStatement" AST node with "alterTable" child. */ public function record_alter_table( WP_Parser_Node $node ): void { - $table_name = $this->get_value( $node->get_first_descendant_node( 'tableRef' ) ); + $table_ref = $node->get_first_descendant_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $actions = $node->get_descendant_nodes( 'alterListItem' ); // Check if a temporary table with the given name exists. @@ -765,7 +767,7 @@ public function record_drop_table( WP_Parser_Node $node ): void { $table_refs = $child_node->get_first_child_node( 'tableRefList' )->get_child_nodes(); foreach ( $table_refs as $table_ref ) { - $table_name = $this->get_value( $table_ref ); + $table_name = $this->get_table_name_from_node( $table_ref ); $table_is_temporary = $has_temporary_keyword || $this->temporary_table_exists( $table_name ); $this->delete_values( @@ -809,7 +811,8 @@ public function record_drop_table( WP_Parser_Node $node ): void { public function record_create_index( WP_Parser_Node $node ): void { $create_index = $node->get_first_child_node( 'createIndex' ); $target = $create_index->get_first_child_node( 'createIndexTarget' ); - $table_name = $this->get_value( $target->get_first_child_node( 'tableRef' ) ); + $table_ref = $target->get_first_child_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $table_is_temporary = $this->temporary_table_exists( $table_name ); $this->record_add_index( $table_is_temporary, $table_name, $create_index ); @@ -822,7 +825,8 @@ public function record_create_index( WP_Parser_Node $node ): void { */ public function record_drop_index( WP_Parser_Node $node ): void { $drop_index = $node->get_first_child_node( 'dropIndex' ); - $table_name = $this->get_value( $drop_index->get_first_child_node( 'tableRef' ) ); + $table_ref = $drop_index->get_first_child_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $index_name = $this->get_value( $drop_index->get_first_child_node( 'indexRef' ) ); $table_is_temporary = $this->temporary_table_exists( $table_name ); $this->record_drop_index_data( $table_is_temporary, $table_name, $index_name ); @@ -1750,9 +1754,8 @@ private function extract_referential_constraint_data( WP_Parser_Node $node, stri } // Referenced table name. - $referenced_table = $references->get_first_child_node( 'tableRef' ); - $referenced_identifiers = $referenced_table->get_descendant_nodes( 'identifier' ); - $referenced_table_name = $this->get_value( end( $referenced_identifiers ) ); + $referenced_table = $references->get_first_child_node( 'tableRef' ); + $referenced_table_name = $this->get_table_name_from_node( $referenced_table ); // Referenced column names. $reference_parts = $references->get_first_child_node( 'identifierListWithParentheses' ) @@ -1843,7 +1846,7 @@ private function extract_key_column_usage_data( $referenced_table_schema = count( $referenced_identifiers ) > 1 ? $this->get_value( $referenced_identifiers[0] ) : self::SAVED_DATABASE_NAME; - $referenced_table_name = $this->get_value( end( $referenced_identifiers ) ); + $referenced_table_name = $this->get_table_name_from_node( $referenced_table ); $referenced_columns = $references->get_first_child_node( 'identifierListWithParentheses' ) ->get_first_child_node( 'identifierList' ) ->get_child_nodes( 'identifier' ); @@ -1964,6 +1967,23 @@ private function sync_column_key_info( bool $table_is_temporary, string $table_n ); } + /** + * Extract table name from one of fully-qualified name AST nodes. + * + * @param WP_Parser_Node $node The AST node. One of "tableName" or "tableRef". + * @return string The table name. + */ + private function get_table_name_from_node( WP_Parser_Node $node ): string { + if ( 'tableRef' === $node->rule_name || 'tableName' === $node->rule_name ) { + $parts = $node->get_descendant_nodes( 'identifier' ); + return $this->get_value( end( $parts ) ); + } + + throw new Exception( + sprintf( 'Could not get table name from node: %s', $node->rule_name ) + ); + } + /** * Extract table engine value from the "createStatement" AST node. * From 35211d7644fb340c6db4cb561e4f992da981674c Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 17 Oct 2025 16:11:52 +0200 Subject: [PATCH 2/7] Enable read usage of information schema tables in write queries --- tests/WP_SQLite_Driver_Tests.php | 89 +++++++++++++++++++ .../sqlite-ast/class-wp-sqlite-driver.php | 5 -- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 63966be9..fa161b5f 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -9622,4 +9622,93 @@ public function testFullyQualifiedTableName(): void { $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); $this->assertCount( 0, $result ); } + + public function testWriteWithUsageOfInformationSchemaTables(): void { + // Ensure "information_schema.tables" is empty. + $this->assertQuery( 'DROP TABLE _options, _dates' ); + $result = $this->assertQuery( 'SELECT * FROM information_schema.tables' ); + $this->assertCount( 0, $result ); + + // Create a table. + $this->assertQuery( 'CREATE TABLE t (id INT, value VARCHAR(255))' ); + + // INSERT with SELECT from information schema. + $this->assertQuery( 'INSERT INTO t (id, value) SELECT 1, table_name FROM information_schema.tables' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + ), + $result + ); + + // INSERT with subselect from information schema. + $this->assertQuery( 'INSERT INTO t (id, value) SELECT 2, table_name FROM (SELECT table_name FROM information_schema.tables)' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 2, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + ), + $result + ); + + // INSERT with JOIN on information schema. + $this->assertQuery( + 'INSERT INTO t (id, value) + SELECT 3, it.table_name + FROM information_schema.schemata s + JOIN information_schema.tables it ON s.schema_name = it.table_schema' + ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 3, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + (object) array( + 'id' => '3', + 'value' => 't', + ), + ), + $result + ); + + // TODO: UPDATE with JOIN on information schema is not supported yet. + + // DELETE with JOIN on information schema. + $this->assertQuery( 'UPDATE t SET value = "other" WHERE id > 1' ); + $this->assertQuery( 'DELETE t FROM t JOIN information_schema.tables it ON t.value = it.table_name' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'value' => 'other', + ), + (object) array( + 'id' => '3', + 'value' => 'other', + ), + ), + $result + ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index 1480b82e..d5a76f94 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -4083,11 +4083,6 @@ public function translate_select_item( WP_Parser_Node $node ): string { * @throws WP_SQLite_Driver_Exception When the translation fails. */ public function translate_table_ref( WP_Parser_Node $node ): string { - // Information schema is currently accessible only in read-only queries. - if ( ! $this->is_readonly ) { - return $this->translate_sequence( $node->get_children() ); - } - // The table reference is in "." or "
" format. $parts = $node->get_descendant_nodes( 'identifier' ); $table = array_pop( $parts ); From c0f28cc9fffdc17356ea7cb8d053898d94cd2f08 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 17 Oct 2025 17:13:01 +0200 Subject: [PATCH 3/7] Implement column metadata for SHOW, EXPLAIN/DESCRIBE, ANALYZE, CHECK, OPTIMIZE, REPAIR --- tests/WP_SQLite_Driver_Tests.php | 244 +++++++++++++ .../sqlite-ast/class-wp-sqlite-driver.php | 330 +++++++++++------- 2 files changed, 456 insertions(+), 118 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index fa161b5f..72a23527 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -9711,4 +9711,248 @@ public function testWriteWithUsageOfInformationSchemaTables(): void { $result ); } + + public function testNonEmptyColumnMeta(): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY)' ); + $this->assertQuery( 'INSERT INTO t VALUES (1)' ); + + // SELECT + $this->assertQuery( 'SELECT * FROM t' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'id', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW COLLATION + $this->assertQuery( 'SHOW COLLATION' ); + $this->assertSame( 7, $this->engine->get_last_column_count() ); + $this->assertSame( 'Collation', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Charset', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Id', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Default', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Compiled', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Sortlen', $this->engine->get_last_column_meta()[5]['name'] ); + $this->assertSame( 'Pad_attribute', $this->engine->get_last_column_meta()[6]['name'] ); + + // SHOW DATABASES + $this->assertQuery( 'SHOW DATABASES' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'Database', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW CREATE TABLE + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertSame( 2, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Create Table', $this->engine->get_last_column_meta()[1]['name'] ); + + // SHOW TABLE STATUS + $this->assertQuery( 'SHOW TABLE STATUS' ); + $this->assertSame( 18, $this->engine->get_last_column_count() ); + $this->assertSame( 'Name', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Engine', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Version', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Row_format', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Rows', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Avg_row_length', $this->engine->get_last_column_meta()[5]['name'] ); + $this->assertSame( 'Data_length', $this->engine->get_last_column_meta()[6]['name'] ); + $this->assertSame( 'Max_data_length', $this->engine->get_last_column_meta()[7]['name'] ); + $this->assertSame( 'Index_length', $this->engine->get_last_column_meta()[8]['name'] ); + $this->assertSame( 'Data_free', $this->engine->get_last_column_meta()[9]['name'] ); + $this->assertSame( 'Auto_increment', $this->engine->get_last_column_meta()[10]['name'] ); + $this->assertSame( 'Create_time', $this->engine->get_last_column_meta()[11]['name'] ); + $this->assertSame( 'Update_time', $this->engine->get_last_column_meta()[12]['name'] ); + $this->assertSame( 'Check_time', $this->engine->get_last_column_meta()[13]['name'] ); + $this->assertSame( 'Collation', $this->engine->get_last_column_meta()[14]['name'] ); + $this->assertSame( 'Checksum', $this->engine->get_last_column_meta()[15]['name'] ); + $this->assertSame( 'Create_options', $this->engine->get_last_column_meta()[16]['name'] ); + $this->assertSame( 'Comment', $this->engine->get_last_column_meta()[17]['name'] ); + + // SHOW TABLES + $this->assertQuery( 'SHOW TABLES' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'Tables_in_wp', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW FULL TABLES + $this->assertQuery( 'SHOW FULL TABLES' ); + $this->assertSame( 2, $this->engine->get_last_column_count() ); + $this->assertSame( 'Tables_in_wp', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Table_type', $this->engine->get_last_column_meta()[1]['name'] ); + + // SHOW COLUMNS + $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertSame( 6, $this->engine->get_last_column_count() ); + $this->assertSame( 'Field', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Type', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Null', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Key', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Default', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Extra', $this->engine->get_last_column_meta()[5]['name'] ); + + // SHOW INDEX + $this->assertQuery( 'SHOW INDEX FROM t' ); + $this->assertSame( 15, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Non_unique', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Key_name', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Seq_in_index', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Column_name', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Collation', $this->engine->get_last_column_meta()[5]['name'] ); + $this->assertSame( 'Cardinality', $this->engine->get_last_column_meta()[6]['name'] ); + $this->assertSame( 'Sub_part', $this->engine->get_last_column_meta()[7]['name'] ); + $this->assertSame( 'Packed', $this->engine->get_last_column_meta()[8]['name'] ); + $this->assertSame( 'Null', $this->engine->get_last_column_meta()[9]['name'] ); + $this->assertSame( 'Index_type', $this->engine->get_last_column_meta()[10]['name'] ); + $this->assertSame( 'Comment', $this->engine->get_last_column_meta()[11]['name'] ); + $this->assertSame( 'Index_comment', $this->engine->get_last_column_meta()[12]['name'] ); + $this->assertSame( 'Visible', $this->engine->get_last_column_meta()[13]['name'] ); + $this->assertSame( 'Expression', $this->engine->get_last_column_meta()[14]['name'] ); + + // SHOW GRANTS + $this->assertQuery( 'SHOW GRANTS' ); + $this->assertSame( 1, $this->engine->get_last_column_count() ); + $this->assertSame( 'Grants for root@localhost', $this->engine->get_last_column_meta()[0]['name'] ); + + // SHOW VARIABLES + $this->assertQuery( 'SHOW VARIABLES' ); + $this->assertSame( 2, $this->engine->get_last_column_count() ); + $this->assertSame( 'Variable_name', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Value', $this->engine->get_last_column_meta()[1]['name'] ); + + // DESCRIBE/EXPLAIN + $this->assertQuery( 'DESCRIBE t' ); + $this->assertSame( 6, $this->engine->get_last_column_count() ); + $this->assertSame( 'Field', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Type', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Null', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Key', $this->engine->get_last_column_meta()[3]['name'] ); + $this->assertSame( 'Default', $this->engine->get_last_column_meta()[4]['name'] ); + $this->assertSame( 'Extra', $this->engine->get_last_column_meta()[5]['name'] ); + + // ANALYZE TABLE + $this->assertQuery( 'ANALYZE TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + + // CHECK TABLE + $this->assertQuery( 'CHECK TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + + // OPTIMIZE TABLE + $this->assertQuery( 'OPTIMIZE TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + + // REPAIR TABLE + $this->assertQuery( 'REPAIR TABLE t' ); + $this->assertSame( 4, $this->engine->get_last_column_count() ); + $this->assertSame( 'Table', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Op', $this->engine->get_last_column_meta()[1]['name'] ); + $this->assertSame( 'Msg_type', $this->engine->get_last_column_meta()[2]['name'] ); + $this->assertSame( 'Msg_text', $this->engine->get_last_column_meta()[3]['name'] ); + } + + public function testEmptyColumnMeta(): void { + // CREATE TABLE + $this->assertQuery( 'CREATE TABLE t (id INT)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // INSERT + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // REPLACE + $this->assertQuery( 'UPDATE t SET id = 1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // DELETE + $this->assertQuery( 'DELETE FROM t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // TRUNCATE TABLE + $this->assertQuery( 'TRUNCATE TABLE t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // START TRANSACTION + $this->assertQuery( 'START TRANSACTION' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // COMMIT + $this->assertQuery( 'COMMIT' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // ROLLBACK + $this->assertQuery( 'ROLLBACK' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // SAVEPOINT + $this->assertQuery( 'SAVEPOINT s1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // ROLLBACK TO SAVEPOINT + $this->assertQuery( 'ROLLBACK TO SAVEPOINT s1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // RELEASE SAVEPOINT + $this->assertQuery( 'RELEASE SAVEPOINT s1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // LOCK TABLE + $this->assertQuery( 'LOCK TABLES t READ' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // UNLOCK TABLE + $this->assertQuery( 'UNLOCK TABLES' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // ALTER TABLE + $this->assertQuery( 'ALTER TABLE t ADD COLUMN name VARCHAR(255)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // CREATE INDEX + $this->assertQuery( 'CREATE INDEX idx_name ON t (name)' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // DROP INDEX + $this->assertQuery( 'DROP INDEX idx_name ON t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // DROP TABLE + $this->assertQuery( 'DROP TABLE t' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // USE + $this->assertQuery( 'USE wp' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + + // SET + $this->assertQuery( 'SET @my_var = 1' ); + $this->assertSame( 0, $this->engine->get_last_column_count() ); + $this->assertSame( array(), $this->engine->get_last_column_meta() ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index d5a76f94..e656b079 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -1487,32 +1487,7 @@ private function execute_select_statement( WP_Parser_Node $node ): void { // Store column meta info. This must be done before fetching data, which // seems to erase type information for expressions in the SELECT clause. - $this->last_column_meta = array(); - for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { - /* - * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3. - * See also: https://github.com/php/php-src/pull/5654 - */ - if ( PHP_VERSION_ID < 70300 ) { - try { - $this->last_column_meta[] = $stmt->getColumnMeta( $i ); - } catch ( Throwable $e ) { - $this->last_column_meta[] = array( - 'native_type' => 'null', - 'pdo_type' => PDO::PARAM_NULL, - 'flags' => array(), - 'table' => '', - 'name' => '', - 'len' => -1, - 'precision' => 0, - ); - } - continue; - } - - $this->last_column_meta[] = $stmt->getColumnMeta( $i ); - } - + $this->store_last_column_meta( $stmt ); $this->set_results_from_fetched_data( $stmt->fetchAll( $this->pdo_fetch_mode ) ); @@ -2304,6 +2279,27 @@ private function execute_show_statement( WP_Parser_Node $node ): void { ) ); } + + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => '', + 'name' => 'Table', + 'len' => 256, + 'precision' => 31, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => '', + 'name' => 'Create Table', + 'len' => strlen( $sql ), + 'precision' => 31, + ), + ); return; } break; @@ -2320,6 +2316,17 @@ private function execute_show_statement( WP_Parser_Node $node ): void { ), ) ); + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => '', + 'name' => 'Grants for root@localhost', + 'len' => 4096, + 'precision' => 31, + ), + ); return; case WP_MySQL_Lexer::TABLE_SYMBOL: $this->execute_show_table_status_statement( $node ); @@ -2328,7 +2335,27 @@ private function execute_show_statement( WP_Parser_Node $node ): void { $this->execute_show_tables_statement( $node ); return; case WP_MySQL_Lexer::VARIABLES_SYMBOL: - $this->last_result = true; + $this->last_result = true; + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array( 'not_null' ), + 'table' => 'session_variables', + 'name' => 'Variable_name', + 'len' => 256, + 'precision' => 0, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => 'session_variables', + 'name' => 'Value', + 'len' => 4096, + 'precision' => 0, + ), + ); return; } @@ -2350,21 +2377,22 @@ private function execute_show_collation_statement(): void { // TODO: LIKE and WHERE clauses. - $result = $this->execute_sqlite_query( $definition )->fetchAll( PDO::FETCH_ASSOC ); - - $collations = array(); - foreach ( $result as $row ) { - $collations[] = (object) array( - 'Collation' => $row['COLLATION_NAME'], - 'Charset' => $row['CHARACTER_SET_NAME'], - 'Id' => $row['ID'], - 'Default' => $row['IS_DEFAULT'], - 'Compiled' => $row['IS_COMPILED'], - 'Sortlen' => $row['SORTLEN'], - 'Pad_attribute' => $row['PAD_ATTRIBUTE'], - ); - } - $this->set_results_from_fetched_data( $collations ); + $stmt = $this->execute_sqlite_query( + sprintf( + 'SELECT + COLLATION_NAME AS `Collation`, + CHARACTER_SET_NAME AS `Charset`, + ID AS `Id`, + IS_DEFAULT AS `Default`, + IS_COMPILED AS `Compiled`, + SORTLEN AS `Sortlen`, + PAD_ATTRIBUTE AS `Pad_attribute` + FROM (%s)', + $definition + ) + ); + $this->store_last_column_meta( $stmt ); + $this->set_results_from_fetched_data( $stmt->fetchAll( PDO::FETCH_OBJ ) ); } /** @@ -2381,7 +2409,7 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'schema_name' ); } - $databases = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( 'SELECT SCHEMA_NAME AS Database FROM ( @@ -2394,8 +2422,10 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void $this->get_saved_db_name(), $this->main_db_name, ) - )->fetchAll( PDO::FETCH_OBJ ); + ); + $this->store_last_column_meta( $stmt ); + $databases = $stmt->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $databases ); } @@ -2440,7 +2470,7 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { */ $statistics_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'statistics' ); - $index_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( ' SELECT TABLE_NAME AS `Table`, @@ -2471,8 +2501,10 @@ private function execute_show_index_statement( WP_Parser_Node $node ): void { SEQ_IN_INDEX ", array( $this->get_saved_db_name( $database ), $table_name ) - )->fetchAll( PDO::FETCH_OBJ ); + ); + $this->store_last_column_meta( $stmt ); + $index_info = $stmt->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $index_info ); } @@ -2504,45 +2536,42 @@ private function execute_show_table_status_statement( WP_Parser_Node $node ): vo false, // SHOW TABLE STATUS lists only non-temporary tables. 'tables' ); - $table_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( - 'SELECT * FROM %s WHERE table_schema = ? %s ORDER BY table_name', + 'SELECT + table_name AS `Name`, + engine AS `Engine`, + version AS `Version`, + row_format AS `Row_format`, + table_rows AS `Rows`, + avg_row_length AS `Avg_row_length`, + data_length AS `Data_length`, + max_data_length AS `Max_data_length`, + index_length AS `Index_length`, + data_free AS `Data_free`, + auto_increment AS `Auto_increment`, + create_time AS `Create_time`, + update_time AS `Update_time`, + check_time AS `Check_time`, + table_collation AS `Collation`, + checksum AS `Checksum`, + create_options AS `Create_options`, + table_comment AS `Comment` + FROM %s + WHERE table_schema = ? %s + ORDER BY table_name', $this->quote_sqlite_identifier( $tables_tables ), $condition ?? '' ), array( $this->get_saved_db_name( $database ) ) - )->fetchAll( PDO::FETCH_ASSOC ); + ); + $this->store_last_column_meta( $stmt ); + $table_info = $stmt->fetchAll( PDO::FETCH_OBJ ); if ( false === $table_info ) { $this->set_results_from_fetched_data( array() ); } - - // Format the results. - $tables = array(); - foreach ( $table_info as $value ) { - $tables[] = (object) array( - 'Name' => $value['TABLE_NAME'], - 'Engine' => $value['ENGINE'], - 'Version' => $value['VERSION'], - 'Row_format' => $value['ROW_FORMAT'], - 'Rows' => $value['TABLE_ROWS'], - 'Avg_row_length' => $value['AVG_ROW_LENGTH'], - 'Data_length' => $value['DATA_LENGTH'], - 'Max_data_length' => $value['MAX_DATA_LENGTH'], - 'Index_length' => $value['INDEX_LENGTH'], - 'Data_free' => $value['DATA_FREE'], - 'Auto_increment' => $value['AUTO_INCREMENT'], - 'Create_time' => $value['CREATE_TIME'], - 'Update_time' => $value['UPDATE_TIME'], - 'Check_time' => $value['CHECK_TIME'], - 'Collation' => $value['TABLE_COLLATION'], - 'Checksum' => $value['CHECKSUM'], - 'Create_options' => $value['CREATE_OPTIONS'], - 'Comment' => $value['TABLE_COMMENT'], - ); - } - - $this->set_results_from_fetched_data( $tables ); + $this->set_results_from_fetched_data( $table_info ); } /** @@ -2568,41 +2597,33 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { $condition = $this->translate_show_like_or_where_condition( $like_or_where, 'table_name' ); } + // Handle the FULL keyword. + $command_type = $node->get_first_child_node( 'showCommandType' ); + $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL ); + // Fetch table information. $table_tables = $this->information_schema_builder->get_table_name( false, // SHOW TABLES lists only non-temporary tables. 'tables' ); - $table_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( - 'SELECT * FROM %s WHERE table_schema = ? %s ORDER BY table_name', + 'SELECT %s FROM %s WHERE table_schema = ? %s ORDER BY table_name', + $is_full + ? sprintf( 'table_name AS `Tables_in_%s`, table_type AS `Table_type`', $database ) + : sprintf( 'table_name AS `Tables_in_%s`', $database ), $this->quote_sqlite_identifier( $table_tables ), $condition ?? '' ), array( $this->get_saved_db_name( $database ) ) - )->fetchAll( PDO::FETCH_ASSOC ); + ); + $this->store_last_column_meta( $stmt ); + $table_info = $stmt->fetchAll( PDO::FETCH_OBJ ); if ( false === $table_info ) { $this->set_results_from_fetched_data( array() ); } - - // Handle the FULL keyword. - $command_type = $node->get_first_child_node( 'showCommandType' ); - $is_full = $command_type && $command_type->has_child_token( WP_MySQL_Lexer::FULL_SYMBOL ); - - // Format the results. - $tables = array(); - foreach ( $table_info as $value ) { - $table = array( - "Tables_in_$database" => $value['TABLE_NAME'], - ); - if ( true === $is_full ) { - $table['Table_type'] = $value['TABLE_TYPE']; - } - $tables[] = (object) $table; - } - - $this->set_results_from_fetched_data( $tables ); + $this->set_results_from_fetched_data( $table_info ); } /** @@ -2652,34 +2673,30 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { // Fetch column information. $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); - $column_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( sprintf( - 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? %s ORDER BY ordinal_position', + 'SELECT + column_name AS `Field`, + column_type AS `Type`, + is_nullable AS `Null`, + column_key AS `Key`, + column_default AS `Default`, + extra AS `Extra` + FROM %s + WHERE table_schema = ? AND table_name = ? %s + ORDER BY ordinal_position', $this->quote_sqlite_identifier( $columns_table ), $condition ?? '' ), array( $this->get_saved_db_name( $database ), $table_name ) - )->fetchAll( PDO::FETCH_ASSOC ); + ); + $this->store_last_column_meta( $stmt ); + $column_info = $stmt->fetchAll( PDO::FETCH_OBJ ); if ( false === $column_info ) { $this->set_results_from_fetched_data( array() ); } - - // Format the results. - $columns = array(); - foreach ( $column_info as $value ) { - $column = array( - 'Field' => $value['COLUMN_NAME'], - 'Type' => $value['COLUMN_TYPE'], - 'Null' => $value['IS_NULLABLE'], - 'Key' => $value['COLUMN_KEY'], - 'Default' => $value['COLUMN_DEFAULT'], - 'Extra' => $value['EXTRA'], - ); - $columns[] = (object) $column; - } - - $this->set_results_from_fetched_data( $columns ); + $this->set_results_from_fetched_data( $column_info ); } /** @@ -2696,7 +2713,7 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); $columns_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'columns' ); - $column_info = $this->execute_sqlite_query( + $stmt = $this->execute_sqlite_query( ' SELECT column_name AS `Field`, @@ -2711,8 +2728,10 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { ORDER BY ordinal_position ', array( $this->get_saved_db_name( $database ), $table_name ) - )->fetchAll( PDO::FETCH_OBJ ); + ); + $this->store_last_column_meta( $stmt ); + $column_info = $stmt->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $column_info ); } @@ -3048,6 +3067,45 @@ private function execute_administration_statement( WP_Parser_Node $node ): void 'Msg_text' => count( $errors ) > 0 ? 'Operation failed' : 'OK', ); } + + $this->last_column_meta = array( + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Table', + 'len' => 512, + 'precision' => 31, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Op', + 'len' => 40, + 'precision' => 31, + ), + array( + 'native_type' => 'STRING', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Msg_type', + 'len' => 40, + 'precision' => 31, + ), + array( + 'native_type' => 'TEXT', + 'pdo_type' => PDO::PARAM_STR, + 'flags' => array(), + 'table' => '', + 'name' => 'Msg_text', + 'len' => 1572864, + 'precision' => 31, + ), + ); $this->set_results_from_fetched_data( $results ); } @@ -4538,6 +4596,42 @@ private function translate_update_list_in_non_strict_mode( string $table_name, W return $fragment; } + /** + * Store column metadata for the last SQLite statement. + * + * This function stores the original SQLite column metadata as-is, without + * converting it into MySQL column metadata. That is done only when needed. + * + * @param PDOStatement $stmt The PDOStatement object containing the SQLite column metadata. + */ + private function store_last_column_meta( PDOStatement $stmt ): void { + $this->last_column_meta = array(); + for ( $i = 0; $i < $stmt->columnCount(); $i++ ) { + /* + * Workaround for PHP PDO SQLite bug (#79664) in PHP < 7.3. + * See also: https://github.com/php/php-src/pull/5654 + */ + if ( PHP_VERSION_ID < 70300 ) { + try { + $this->last_column_meta[] = $stmt->getColumnMeta( $i ); + } catch ( Throwable $e ) { + $this->last_column_meta[] = array( + 'native_type' => 'null', + 'pdo_type' => PDO::PARAM_NULL, + 'flags' => array(), + 'table' => '', + 'name' => '', + 'len' => -1, + 'precision' => 0, + ); + } + continue; + } + + $this->last_column_meta[] = $stmt->getColumnMeta( $i ); + } + } + /** * Unnest parenthesized MySQL expression node. * From 01a0126ddc0d62b0a56cb84f76be29ca662552a1 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 09:54:58 +0200 Subject: [PATCH 4/7] Unify database user name to "root" Using "root" as the user name makes sense considering that with the SQLite driver, the user gets all available permissions. --- tests/WP_SQLite_Driver_Tests.php | 8 ++++---- wp-includes/sqlite-ast/class-wp-sqlite-driver.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 72a23527..687d2aad 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2834,7 +2834,7 @@ public function testShowGrantsFor() { $result, array( (object) array( - 'Grants for root@localhost' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', + 'Grants for root@%' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', ), ) ); @@ -4188,7 +4188,7 @@ public function getReservedPrefixTestData(): array { public function testInformationSchemaIsReadonly( string $query ): void { $this->assertQuery( 'CREATE TABLE t1 (id INT)' ); $this->expectException( WP_SQLite_Driver_Exception::class ); - $this->expectExceptionMessage( "Access denied for user 'sqlite'@'%' to database 'information_schema'" ); + $this->expectExceptionMessage( "Access denied for user 'root'@'%' to database 'information_schema'" ); $this->assertQuery( $query ); } @@ -4220,7 +4220,7 @@ public function getInformationSchemaIsReadonlyTestData(): array { public function testInformationSchemaIsReadonlyWithUse( string $query ): void { $this->assertQuery( 'CREATE TABLE t1 (id INT)' ); $this->expectException( WP_SQLite_Driver_Exception::class ); - $this->expectExceptionMessage( "Access denied for user 'sqlite'@'%' to database 'information_schema'" ); + $this->expectExceptionMessage( "Access denied for user 'root'@'%' to database 'information_schema'" ); $this->assertQuery( 'USE information_schema' ); $this->assertQuery( $query ); } @@ -9808,7 +9808,7 @@ public function testNonEmptyColumnMeta(): void { // SHOW GRANTS $this->assertQuery( 'SHOW GRANTS' ); $this->assertSame( 1, $this->engine->get_last_column_count() ); - $this->assertSame( 'Grants for root@localhost', $this->engine->get_last_column_meta()[0]['name'] ); + $this->assertSame( 'Grants for root@%', $this->engine->get_last_column_meta()[0]['name'] ); // SHOW VARIABLES $this->assertQuery( 'SHOW VARIABLES' ); diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index e656b079..87742309 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -2312,7 +2312,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { $this->set_results_from_fetched_data( array( (object) array( - 'Grants for root@localhost' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', + 'Grants for root@%' => 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION', ), ) ); @@ -2322,7 +2322,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { 'pdo_type' => PDO::PARAM_STR, 'flags' => array( 'not_null' ), 'table' => '', - 'name' => 'Grants for root@localhost', + 'name' => 'Grants for root@%', 'len' => 4096, 'precision' => 31, ), @@ -5846,7 +5846,7 @@ private function new_not_supported_exception( string $cause ): WP_SQLite_Driver_ */ private function new_access_denied_to_information_schema_exception(): WP_SQLite_Driver_Exception { return $this->new_driver_exception( - "Access denied for user 'sqlite'@'%' to database 'information_schema'", + "Access denied for user 'root'@'%' to database 'information_schema'", '42000' ); } From 38a6a3515e715beaae2092ff552c32c8099cf471 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 15:17:48 +0200 Subject: [PATCH 5/7] Add MySQL protocol implementation from https://github.com/adamziel/mysql-sqlite-network-proxy --- .../src/handler-sqlite-translation.php | 102 ++ packages/wp-mysql-proxy/src/mysql-server.php | 954 ++++++++++++++++++ .../src/run-sqlite-translation.php | 18 + 3 files changed, 1074 insertions(+) create mode 100644 packages/wp-mysql-proxy/src/handler-sqlite-translation.php create mode 100644 packages/wp-mysql-proxy/src/mysql-server.php create mode 100644 packages/wp-mysql-proxy/src/run-sqlite-translation.php diff --git a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php new file mode 100644 index 00000000..19658a88 --- /dev/null +++ b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php @@ -0,0 +1,102 @@ +sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), + 'wordpress' + ); + } + + public function handleQuery(string $query): MySQLServerQueryResult { + try { + $rows = $this->sqlite_driver->query($query); + if ( $this->sqlite_driver->get_last_column_count() > 0 ) { + $columns = $this->computeColumnInfo(); + return new SelectQueryResult($columns, $rows); + } + return new OkayPacketResult( + $this->sqlite_driver->get_last_return_value() ?? 0, + $this->sqlite_driver->get_insert_id() ?? 0 + ); + } catch (Throwable $e) { + return new ErrorQueryResult($e->getMessage()); + } + } + + public function computeColumnInfo() { + $columns = []; + + $column_meta = $this->sqlite_driver->get_last_column_meta(); + + $types = [ + 'DECIMAL' => MySQLProtocol::FIELD_TYPE_DECIMAL, + 'TINY' => MySQLProtocol::FIELD_TYPE_TINY, + 'SHORT' => MySQLProtocol::FIELD_TYPE_SHORT, + 'LONG' => MySQLProtocol::FIELD_TYPE_LONG, + 'FLOAT' => MySQLProtocol::FIELD_TYPE_FLOAT, + 'DOUBLE' => MySQLProtocol::FIELD_TYPE_DOUBLE, + 'NULL' => MySQLProtocol::FIELD_TYPE_NULL, + 'TIMESTAMP' => MySQLProtocol::FIELD_TYPE_TIMESTAMP, + 'LONGLONG' => MySQLProtocol::FIELD_TYPE_LONGLONG, + 'INT24' => MySQLProtocol::FIELD_TYPE_INT24, + 'DATE' => MySQLProtocol::FIELD_TYPE_DATE, + 'TIME' => MySQLProtocol::FIELD_TYPE_TIME, + 'DATETIME' => MySQLProtocol::FIELD_TYPE_DATETIME, + 'YEAR' => MySQLProtocol::FIELD_TYPE_YEAR, + 'NEWDATE' => MySQLProtocol::FIELD_TYPE_NEWDATE, + 'VARCHAR' => MySQLProtocol::FIELD_TYPE_VARCHAR, + 'BIT' => MySQLProtocol::FIELD_TYPE_BIT, + 'NEWDECIMAL' => MySQLProtocol::FIELD_TYPE_NEWDECIMAL, + 'ENUM' => MySQLProtocol::FIELD_TYPE_ENUM, + 'SET' => MySQLProtocol::FIELD_TYPE_SET, + 'TINY_BLOB' => MySQLProtocol::FIELD_TYPE_TINY_BLOB, + 'MEDIUM_BLOB' => MySQLProtocol::FIELD_TYPE_MEDIUM_BLOB, + 'LONG_BLOB' => MySQLProtocol::FIELD_TYPE_LONG_BLOB, + 'BLOB' => MySQLProtocol::FIELD_TYPE_BLOB, + 'VAR_STRING' => MySQLProtocol::FIELD_TYPE_VAR_STRING, + 'STRING' => MySQLProtocol::FIELD_TYPE_STRING, + 'GEOMETRY' => MySQLProtocol::FIELD_TYPE_GEOMETRY, + ]; + + foreach ($column_meta as $column) { + $type = $types[$column['native_type']] ?? null; + if ( null === $type ) { + throw new Exception('Unknown column type: ' . $column['native_type']); + } + $columns[] = [ + 'name' => $column['name'], + 'length' => $column['len'], + 'type' => $type, + 'flags' => 129, + 'decimals' => $column['precision'] + ]; + } + return $columns; + } +} diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php new file mode 100644 index 00000000..95df3f1a --- /dev/null +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -0,0 +1,954 @@ + string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] + public array $rows; // Array of rows, each an array of values (strings, numbers, or null) + + public function __construct(array $columns = [], array $rows = []) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function toPackets(): string { + return MySQLProtocol::buildResultSetPackets($this); + } +} + +class OkayPacketResult implements MySQLServerQueryResult { + public int $affectedRows; + public int $lastInsertId; + + public function __construct(int $affectedRows, int $lastInsertId) { + $this->affectedRows = $affectedRows; + $this->lastInsertId = $lastInsertId; + } + + public function toPackets(): string { + $ok_packet = MySQLProtocol::buildOkPacket($this->affectedRows, $this->lastInsertId); + return MySQLProtocol::encodeInt24(strlen($ok_packet)) . MySQLProtocol::encodeInt8(1) . $ok_packet; + } +} + +class ErrorQueryResult implements MySQLServerQueryResult { + public string $code; + public string $sqlState; + public string $message; + + public function __construct(string $message = "Syntax error or unsupported query", string $sqlState = "42000", int $code = 0x04A7) { + $this->code = $code; + $this->sqlState = $sqlState; + $this->message = $message; + } + + public function toPackets(): string { + $err_packet = MySQLProtocol::buildErrPacket($this->code, $this->sqlState, $this->message); + return MySQLProtocol::encodeInt24(strlen($err_packet)) . MySQLProtocol::encodeInt8(1) . $err_packet; + } +} + +class MySQLProtocol { + // MySQL client/server capability flags (partial list) + const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags + const CLIENT_CONNECT_WITH_DB = 0x00000008; + const CLIENT_PROTOCOL_41 = 0x00000200; + const CLIENT_SECURE_CONNECTION = 0x00008000; + const CLIENT_MULTI_STATEMENTS = 0x00010000; + const CLIENT_MULTI_RESULTS = 0x00020000; + const CLIENT_PS_MULTI_RESULTS = 0x00040000; + const CLIENT_PLUGIN_AUTH = 0x00080000; + const CLIENT_CONNECT_ATTRS = 0x00100000; + const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; + const CLIENT_DEPRECATE_EOF = 0x01000000; + + // MySQL status flags + const SERVER_STATUS_AUTOCOMMIT = 0x0002; + + /** + * MySQL command types + * + * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html + */ + const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ + const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ + const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ + const COM_QUERY = 0x03; /** Tells the server to execute a query. */ + const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ + const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ + const COM_DROP_DB = 0x06; /** Currently refused by the server. */ + const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ + const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ + const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ + const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ + const COM_CONNECT = 0x0B; /** Currently refused by the server. */ + const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ + const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ + const COM_PING = 0x0E; /** Check if the server is alive. */ + const COM_TIME = 0x0F; /** Currently refused by the server. */ + const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ + const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ + const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ + const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ + const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ + const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ + const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ + const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ + const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ + const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ + const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ + const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ + const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ + const COM_DAEMON = 0x1D; /** Currently refused by the server. */ + const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ + const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ + const COM_CLONE = 0x20; /** Tells the server to clone a server. */ + + // Special packet markers + const OK_PACKET = 0x00; + const EOF_PACKET = 0xfe; + const ERR_PACKET = 0xff; + const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) + + // Auth specific markers for caching_sha2_password + const CACHING_SHA2_FAST_AUTH = 3; + const CACHING_SHA2_FULL_AUTH = 4; + const AUTH_PLUGIN_NAME = 'caching_sha2_password'; + + // Field types + const FIELD_TYPE_DECIMAL = 0x00; + const FIELD_TYPE_TINY = 0x01; + const FIELD_TYPE_SHORT = 0x02; + const FIELD_TYPE_LONG = 0x03; + const FIELD_TYPE_FLOAT = 0x04; + const FIELD_TYPE_DOUBLE = 0x05; + const FIELD_TYPE_NULL = 0x06; + const FIELD_TYPE_TIMESTAMP = 0x07; + const FIELD_TYPE_LONGLONG = 0x08; + const FIELD_TYPE_INT24 = 0x09; + const FIELD_TYPE_DATE = 0x0a; + const FIELD_TYPE_TIME = 0x0b; + const FIELD_TYPE_DATETIME = 0x0c; + const FIELD_TYPE_YEAR = 0x0d; + const FIELD_TYPE_NEWDATE = 0x0e; + const FIELD_TYPE_VARCHAR = 0x0f; + const FIELD_TYPE_BIT = 0x10; + const FIELD_TYPE_NEWDECIMAL = 0xf6; + const FIELD_TYPE_ENUM = 0xf7; + const FIELD_TYPE_SET = 0xf8; + const FIELD_TYPE_TINY_BLOB = 0xf9; + const FIELD_TYPE_MEDIUM_BLOB = 0xfa; + const FIELD_TYPE_LONG_BLOB = 0xfb; + const FIELD_TYPE_BLOB = 0xfc; + const FIELD_TYPE_VAR_STRING = 0xfd; + const FIELD_TYPE_STRING = 0xfe; + const FIELD_TYPE_GEOMETRY = 0xff; + + // Field flags + const NOT_NULL_FLAG = 0x1; + const PRI_KEY_FLAG = 0x2; + const UNIQUE_KEY_FLAG = 0x4; + const MULTIPLE_KEY_FLAG = 0x8; + const BLOB_FLAG = 0x10; + const UNSIGNED_FLAG = 0x20; + const ZEROFILL_FLAG = 0x40; + const BINARY_FLAG = 0x80; + const ENUM_FLAG = 0x100; + const AUTO_INCREMENT_FLAG = 0x200; + const TIMESTAMP_FLAG = 0x400; + const SET_FLAG = 0x800; + + // Character set and collation constants (using utf8mb4 general collation) + const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) + + // Max packet length constant + const MAX_PACKET_LENGTH = 0x00ffffff; + + private $current_db = ''; + + // Helper: Packets assembly and parsing + public static function encodeInt8(int $val): string { + return chr($val & 0xff); + } + public static function encodeInt16(int $val): string { + return pack('v', $val & 0xffff); + } + public static function encodeInt24(int $val): string { + // 3-byte little-endian integer + return substr(pack('V', $val & 0xffffff), 0, 3); + } + public static function encodeInt32(int $val): string { + return pack('V', $val); + } + public static function encodeLengthEncodedInt(int $val): string { + // Encodes an integer in MySQL's length-encoded format + if ($val < 0xfb) { + return chr($val); + } elseif ($val <= 0xffff) { + return "\xfc" . self::encodeInt16($val); + } elseif ($val <= 0xffffff) { + return "\xfd" . self::encodeInt24($val); + } else { + return "\xfe" . pack('P', $val); // 8-byte little-endian for 64-bit + } + } + public static function encodeLengthEncodedString(string $str): string { + return self::encodeLengthEncodedInt(strlen($str)) . $str; + } + + // Hashing for caching_sha2_password (fast auth algorithm) + public static function sha256Hash(string $password, string $salt): string { + $stage1 = hash('sha256', $password, true); + $stage2 = hash('sha256', $stage1, true); + $scramble = hash('sha256', $stage2 . substr($salt, 0, 20), true); + // XOR stage1 and scramble to get token + return $stage1 ^ $scramble; + } + + // Build initial handshake packet (server greeting) + public static function buildHandshakePacket(int $connId, string &$authPluginData): string { + $protocol_version = 0x0a; // Handshake protocol version (10) + $server_version = "5.7.30-php-mysql-server"; // Fake server version + // Generate random auth plugin data (20-byte salt) + $salt1 = random_bytes(8); + $salt2 = random_bytes(12); // total salt length = 8+12 = 20 bytes (with filler) + $authPluginData = $salt1 . $salt2; + // Lower 2 bytes of capability flags + $capFlagsLower = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) & 0xffff; + // Upper 2 bytes of capability flags + $capFlagsUpper = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) >> 16; + $charset = self::CHARSET_UTF8MB4; + $statusFlags = self::SERVER_STATUS_AUTOCOMMIT; + + // Assemble handshake packet payload + $payload = chr($protocol_version); + $payload .= $server_version . "\0"; + $payload .= self::encodeInt32($connId); + $payload .= $salt1; + $payload .= "\0"; // filler byte + $payload .= self::encodeInt16($capFlagsLower); + $payload .= chr($charset); + $payload .= self::encodeInt16($statusFlags); + $payload .= self::encodeInt16($capFlagsUpper); + $payload .= chr(strlen($authPluginData) + 1); // auth plugin data length (salt + \0) + $payload .= str_repeat("\0", 10); // 10-byte reserved filler + $payload .= $salt2; + $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 + $payload .= self::AUTH_PLUGIN_NAME . "\0"; + return $payload; + } + + // Build OK packet (after successful authentication or query execution) + public static function buildOkPacket(int $affectedRows = 0, int $lastInsertId = 0): string { + $payload = chr(self::OK_PACKET); + $payload .= self::encodeLengthEncodedInt($affectedRows); + $payload .= self::encodeLengthEncodedInt($lastInsertId); + $payload .= self::encodeInt16(self::SERVER_STATUS_AUTOCOMMIT); // server status + $payload .= self::encodeInt16(0); // no warning count + // No human-readable message for simplicity + return $payload; + } + + // Build ERR packet (for errors) + public static function buildErrPacket(int $errorCode, string $sqlState, string $message): string { + $payload = chr(self::ERR_PACKET); + $payload .= self::encodeInt16($errorCode); + $payload .= "#" . strtoupper($sqlState); + $payload .= $message; + return $payload; + } + + // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) + public static function buildResultSetPackets(SelectQueryResult $result): string { + $sequenceId = 1; // Sequence starts at 1 for resultset (after COM_QUERY) + $packetStream = ''; + + // 1. Column count packet (length-encoded integer for number of columns) + $colCount = count($result->columns); + $colCountPayload = self::encodeLengthEncodedInt($colCount); + $packetStream .= self::wrapPacket($colCountPayload, $sequenceId++); + + // 2. Column definition packets for each column + foreach ($result->columns as $col) { + // Protocol::ColumnDefinition41 format:] + $colPayload = self::encodeLengthEncodedString($col['catalog'] ?? 'sqlite'); + $colPayload .= self::encodeLengthEncodedString($col['schema'] ?? ''); + + // Table alias + $colPayload .= self::encodeLengthEncodedString($col['table'] ?? ''); + + // Original table name + $colPayload .= self::encodeLengthEncodedString($col['orgTable'] ?? ''); + + // Column alias + $colPayload .= self::encodeLengthEncodedString($col['name']); + + // Original column name + $colPayload .= self::encodeLengthEncodedString($col['orgName'] ?? $col['name']); + + // Length of the remaining fixed fields. @TODO: What does that mean? + $colPayload .= self::encodeLengthEncodedInt($col['fixedLen'] ?? 0x0c); + $colPayload .= self::encodeInt16($col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4); + $colPayload .= self::encodeInt32($col['length']); + $colPayload .= self::encodeInt8($col['type']); + $colPayload .= self::encodeInt16($col['flags']); + $colPayload .= self::encodeInt8($col['decimals']); + $colPayload .= "\x00"; // filler (1 byte, reserved) + + $packetStream .= self::wrapPacket($colPayload, $sequenceId++); + } + // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) + $eofPayload = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); + $packetStream .= self::wrapPacket($eofPayload, $sequenceId++); + + // 4. Row data packets (each row is a series of length-encoded values) + foreach ($result->rows as $row) { + $rowPayload = ""; + // Iterate through columns in the defined order to match column definitions + foreach ($result->columns as $col) { + $columnName = $col['name']; + $val = $row->{$columnName} ?? null; + + if ($val === null) { + // NULL is represented by 0xfb (NULL_VALUE) + $rowPayload .= "\xfb"; + } else { + $valStr = (string)$val; + $rowPayload .= self::encodeLengthEncodedString($valStr); + } + } + $packetStream .= self::wrapPacket($rowPayload, $sequenceId++); + } + + // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) + $eofPayload2 = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); + $packetStream .= self::wrapPacket($eofPayload2, $sequenceId++); + + return $packetStream; + } + + // Helper to wrap a payload into a packet with length and sequence id + public static function wrapPacket(string $payload, int $sequenceId): string { + $length = strlen($payload); + $header = self::encodeInt24($length) . self::encodeInt8($sequenceId); + return $header . $payload; + } +} + +class IncompleteInputException extends MySQLServerException { + public function __construct(string $message = "Incomplete input data, more bytes needed") { + parent::__construct($message); + } +} + +class MySQLGateway { + private $query_handler; + private $connection_id; + private $auth_plugin_data; + private $sequence_id; + private $authenticated = false; + private $buffer = ''; + + public function __construct(MySQLQueryHandler $query_handler) { + $this->query_handler = $query_handler; + $this->connection_id = random_int(1, 1000); + $this->auth_plugin_data = ""; + $this->sequence_id = 0; + } + + /** + * Get the initial handshake packet to send to the client + * + * @return string Binary packet data to send to client + */ + public function getInitialHandshake(): string { + $handshakePayload = MySQLProtocol::buildHandshakePacket($this->connection_id, $this->auth_plugin_data); + return MySQLProtocol::encodeInt24(strlen($handshakePayload)) . + MySQLProtocol::encodeInt8($this->sequence_id++) . + $handshakePayload; + } + + /** + * Process bytes received from the client + * + * @param string $data Binary data received from client + * @return string|null Response to send back to client, or null if no response needed + * @throws IncompleteInputException When more data is needed to complete a packet + */ + public function receiveBytes(string $data): ?string { + // Append new data to existing buffer + $this->buffer .= $data; + + // Check if we have enough data for a header + if (strlen($this->buffer) < 4) { + throw new IncompleteInputException("Incomplete packet header, need more bytes"); + } + + // Parse packet header + $packetLength = unpack('V', substr($this->buffer, 0, 3) . "\x00")[1]; + $receivedSequenceId = ord($this->buffer[3]); + + // Check if we have the complete packet + $totalPacketLength = 4 + $packetLength; + if (strlen($this->buffer) < $totalPacketLength) { + throw new IncompleteInputException( + "Incomplete packet payload, have " . strlen($this->buffer) . + " bytes, need " . $totalPacketLength . " bytes" + ); + } + + // Extract the complete packet + $packet = substr($this->buffer, 0, $totalPacketLength); + + // Remove the processed packet from the buffer + $this->buffer = substr($this->buffer, $totalPacketLength); + + // Process the packet + $payload = substr($packet, 4, $packetLength); + + // If not authenticated yet, process authentication + if (!$this->authenticated) { + return $this->processAuthentication($payload); + } + + // Otherwise, process as a command + $command = ord($payload[0]); + if ($command === MySQLProtocol::COM_QUERY) { + $query = substr($payload, 1); + return $this->processQuery($query); + } elseif ($command === MySQLProtocol::COM_INIT_DB) { + return $this->processQuery('USE ' . substr($payload, 1)); + } elseif ($command === MySQLProtocol::COM_QUIT) { + return ''; + } else { + // Unsupported command + $errPacket = MySQLProtocol::buildErrPacket(0x04D2, "HY000", "Unsupported command"); + return MySQLProtocol::encodeInt24(strlen($errPacket)) . + MySQLProtocol::encodeInt8(1) . + $errPacket; + } + } + + /** + * Process authentication packet from client + * + * @param string $payload Authentication packet payload + * @return string Response packet to send back + */ + private function processAuthentication(string $payload): string { + $offset = 0; + $payloadLength = strlen($payload); + + $capabilityFlags = $this->readUnsignedIntLittleEndian($payload, $offset, 4); + $offset += 4; + + $clientMaxPacketSize = $this->readUnsignedIntLittleEndian($payload, $offset, 4); + $offset += 4; + + $clientCharacterSet = 0; + if ($offset < $payloadLength) { + $clientCharacterSet = ord($payload[$offset]); + } + $offset += 1; + + // Skip reserved bytes (always zero) + $offset = min($payloadLength, $offset + 23); + + $username = $this->readNullTerminatedString($payload, $offset); + + $authResponse = ''; + if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) { + $authResponseLength = $this->readLengthEncodedInt($payload, $offset); + $authResponse = substr($payload, $offset, $authResponseLength); + $offset = min($payloadLength, $offset + $authResponseLength); + } elseif ($capabilityFlags & MySQLProtocol::CLIENT_SECURE_CONNECTION) { + $authResponseLength = 0; + if ($offset < $payloadLength) { + $authResponseLength = ord($payload[$offset]); + } + $offset += 1; + $authResponse = substr($payload, $offset, $authResponseLength); + $offset = min($payloadLength, $offset + $authResponseLength); + } else { + $authResponse = $this->readNullTerminatedString($payload, $offset); + } + + $database = ''; + if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_WITH_DB) { + $database = $this->readNullTerminatedString($payload, $offset); + } + + $authPluginName = ''; + if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH) { + $authPluginName = $this->readNullTerminatedString($payload, $offset); + } + + if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_ATTRS) { + $attrsLength = $this->readLengthEncodedInt($payload, $offset); + $offset = min($payloadLength, $offset + $attrsLength); + } + + $this->authenticated = true; + $this->sequence_id = 2; + + $responsePackets = ''; + + if ($authPluginName === MySQLProtocol::AUTH_PLUGIN_NAME) { + $fastAuthPayload = chr(MySQLProtocol::AUTH_MORE_DATA) . chr(MySQLProtocol::CACHING_SHA2_FAST_AUTH); + $responsePackets .= MySQLProtocol::encodeInt24(strlen($fastAuthPayload)); + $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); + $responsePackets .= $fastAuthPayload; + } + + $okPacket = MySQLProtocol::buildOkPacket(); + $responsePackets .= MySQLProtocol::encodeInt24(strlen($okPacket)); + $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); + $responsePackets .= $okPacket; + + return $responsePackets; + } + + private function readUnsignedIntLittleEndian(string $payload, int $offset, int $length): int { + $slice = substr($payload, $offset, $length); + if ($slice === '' || $length <= 0) { + return 0; + } + + switch ($length) { + case 1: + return ord($slice[0]); + case 2: + $padded = str_pad($slice, 2, "\x00", STR_PAD_RIGHT); + $unpacked = unpack('v', $padded); + return $unpacked[1] ?? 0; + case 3: + case 4: + default: + $padded = str_pad($slice, 4, "\x00", STR_PAD_RIGHT); + $unpacked = unpack('V', $padded); + return $unpacked[1] ?? 0; + } + } + + private function readNullTerminatedString(string $payload, int &$offset): string { + $nullPosition = strpos($payload, "\0", $offset); + if ($nullPosition === false) { + $result = substr($payload, $offset); + $offset = strlen($payload); + return $result; + } + + $result = substr($payload, $offset, $nullPosition - $offset); + $offset = $nullPosition + 1; + return $result; + } + + private function readLengthEncodedInt(string $payload, int &$offset): int { + if ($offset >= strlen($payload)) { + return 0; + } + + $first = ord($payload[$offset]); + $offset += 1; + + if ($first < 0xfb) { + return $first; + } + + if ($first === 0xfb) { + return 0; + } + + if ($first === 0xfc) { + $value = $this->readUnsignedIntLittleEndian($payload, $offset, 2); + $offset += 2; + return $value; + } + + if ($first === 0xfd) { + $value = $this->readUnsignedIntLittleEndian($payload, $offset, 3); + $offset += 3; + return $value; + } + + // 0xfe indicates an 8-byte integer + $value = 0; + $slice = substr($payload, $offset, 8); + if ($slice !== '') { + $slice = str_pad($slice, 8, "\x00"); + $value = unpack('P', $slice)[1]; + } + $offset += 8; + return (int) $value; + } + + /** + * Process a query from the client + * + * @param string $query SQL query to process + * @return string Response packet to send back + */ + private function processQuery(string $query): string { + $query = trim($query); + + try { + $result = $this->query_handler->handleQuery($query); + return $result->toPackets(); + } catch (MySQLServerException $e) { + $errPacket = MySQLProtocol::buildErrPacket(0x04A7, "42000", "Syntax error or unsupported query: " . $e->getMessage()); + return MySQLProtocol::encodeInt24(strlen($errPacket)) . + MySQLProtocol::encodeInt8(1) . + $errPacket; + } + } + + /** + * Reset the server state for a new connection + */ + public function reset(): void { + $this->connection_id = random_int(1, 1000); + $this->auth_plugin_data = ""; + $this->sequence_id = 0; + $this->authenticated = false; + $this->buffer = ''; + } + + /** + * Check if there's any buffered data that hasn't been processed yet + * + * @return bool True if there's data in the buffer + */ + public function hasBufferedData(): bool { + return !empty($this->buffer); + } + + /** + * Get the number of bytes currently in the buffer + * + * @return int Number of bytes in buffer + */ + public function getBufferSize(): int { + return strlen($this->buffer); + } +} + +class SingleUseMySQLSocketServer { + private $server; + private $socket; + private $port; + + public function __construct(MySQLQueryHandler $query_handler, $options = []) { + $this->server = new MySQLGateway($query_handler); + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_bind($this->socket, '0.0.0.0', $this->port); + socket_listen($this->socket); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + + // Accept a single client for simplicity + $client = socket_accept($this->socket); + if (!$client) { + exit("Failed to accept connection\n"); + } + $this->handleClient($client); + socket_close($client); + socket_close($this->socket); + } + + private function handleClient($client) { + // Send initial handshake + $handshake = $this->server->getInitialHandshake(); + socket_write($client, $handshake); + + while (true) { + // Read available data (up to 4096 bytes at a time) + $data = @socket_read($client, 4096); + if ($data === false || $data === '') { + break; // connection closed + } + + try { + // Process the data + $response = $this->server->receiveBytes($data); + if ($response) { + socket_write($client, $response); + } + + // If there's still data in the buffer, process it immediately + while ($this->server->hasBufferedData()) { + try { + // Try to process more complete packets from the buffer + $response = $this->server->receiveBytes(''); + if ($response) { + socket_write($client, $response); + } + } catch (IncompleteInputException $e) { + // Not enough data to complete another packet, wait for more + break; + } + } + } catch (IncompleteInputException $e) { + // Not enough data yet, continue reading + continue; + } + } + + echo "Client disconnected, terminating the server.\n"; + $this->server->reset(); + } +} + +if(!function_exists('post_message_to_js')) { + function post_message_to_js(string $message) { + echo 'The "post_message_to_js" function is only available in WordPress Playground but you are running it in a standalone PHP environment.' . PHP_EOL; + echo 'The message was: ' . $message . PHP_EOL; + } +} + +class MySQLSocketServer { + private $query_handler; + private $socket; + private $port; + private $clients = []; + private $clientServers = []; + + public function __construct(MySQLQueryHandler $query_handler, $options = []) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_bind($this->socket, '0.0.0.0', $this->port); + socket_listen($this->socket); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + while (true) { + // Prepare arrays for socket_select() + $read = array_merge([$this->socket], $this->clients); + $write = null; + $except = null; + + // Wait for activity on any socket + $select_result = socket_select($read, $write, $except, null); + if($select_result === false || $select_result <= 0) { + continue; + } + + // Check if there's a new connection + if (in_array($this->socket, $read)) { + $client = socket_accept($this->socket); + if ($client) { + echo "New client connected.\n"; + $this->clients[] = $client; + $clientId = spl_object_id($client); + $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); + + // Send initial handshake + echo "Pre handshake\n"; + $handshake = $this->clientServers[$clientId]->getInitialHandshake(); + echo "Post handshake\n"; + socket_write($client, $handshake); + } + // Remove server socket from read array + unset($read[array_search($this->socket, $read)]); + } + + // Handle client activity + echo "Waiting for client activity\n"; + foreach ($read as $client) { + echo "calling socket_read\n"; + $data = @socket_read($client, 4096); + echo "socket_read returned\n"; + $display = ''; + for ($i = 0; $i < strlen($data); $i++) { + $byte = ord($data[$i]); + if ($byte >= 32 && $byte <= 126) { + // Printable ASCII character + $display .= $data[$i]; + } else { + // Non-printable, show as hex + $display .= sprintf('%02x ', $byte); + } + } + echo rtrim($display) . "\n"; + + if ($data === false || $data === '') { + // Client disconnected + echo "Client disconnected.\n"; + $clientId = spl_object_id($client); + $this->clientServers[$clientId]->reset(); + unset($this->clientServers[$clientId]); + socket_close($client); + unset($this->clients[array_search($client, $this->clients)]); + continue; + } + + try { + // Process the data + $clientId = spl_object_id($client); + echo "Receiving bytes\n"; + $response = $this->clientServers[$clientId]->receiveBytes($data); + if ($response) { + echo "Writing response\n"; + echo $response; + socket_write($client, $response); + } + echo "Response written\n"; + + // Process any buffered data + while ($this->clientServers[$clientId]->hasBufferedData()) { + echo "Processing buffered data\n"; + try { + $response = $this->clientServers[$clientId]->receiveBytes(''); + if ($response) { + socket_write($client, $response); + } + } catch (IncompleteInputException $e) { + break; + } + } + echo "After the while loop\n"; + } catch (IncompleteInputException $e) { + echo "Incomplete input exception\n"; + continue; + } + } + echo "restarting the while() loop!\n"; + } + } +} + + +class MySQLPlaygroundYieldServer { + private $query_handler; + private $clients = []; + private $clientServers = []; + private $port; + + public function __construct(MySQLQueryHandler $query_handler, $options = []) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; + + // Main event loop + while (true) { + // Wait for a message from JS + $message = post_message_to_js(json_encode([ + 'type' => 'ready_for_event' + ])); + + $command = json_decode($message, true); + var_dump('decoded event', $command); + if (!$command || !isset($command['type'])) { + continue; + } + + switch ($command['type']) { + case 'new_connection': + $this->handleNewConnection($command['clientId']); + break; + + case 'data_received': + $this->handleDataReceived($command['clientId'], $command['data']); + break; + + case 'client_disconnected': + $this->handleClientDisconnected($command['clientId']); + break; + } + } + } + + private function handleNewConnection($clientId) { + echo "New client connected (ID: $clientId).\n"; + $this->clients[] = $clientId; + $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); + + // Send initial handshake + $handshake = $this->clientServers[$clientId]->getInitialHandshake(); + $this->sendResponse($clientId, $handshake); + } + + private function handleDataReceived($clientId, $encodedData) { + if (!isset($this->clientServers[$clientId])) { + throw new IncompleteInputException('No client server found'); + return; + } + + $data = base64_decode($encodedData); + + try { + // Process the data + $response = $this->clientServers[$clientId]->receiveBytes($data); + if ($response) { + $this->sendResponse($clientId, $response); + } else { + throw new IncompleteInputException('No response from client'); + } + + // Process any buffered data + while ($this->clientServers[$clientId]->hasBufferedData()) { + try { + $response = $this->clientServers[$clientId]->receiveBytes(''); + if ($response) { + $this->sendResponse($clientId, $response); + } + } catch (IncompleteInputException $e) { + throw $e; + break; + } + } + } catch (IncompleteInputException $e) { + // Not enough data yet, wait for mo + throw $e; + } + } + + private function handleClientDisconnected($clientId) { + echo "Client disconnected (ID: $clientId).\n"; + if (isset($this->clientServers[$clientId])) { + $this->clientServers[$clientId]->reset(); + unset($this->clientServers[$clientId]); + } + + $index = array_search($clientId, $this->clients); + if ($index !== false) { + unset($this->clients[$index]); + } + } + + private function sendResponse($clientId, $data) { + var_dump('sending response'); + $response = json_encode([ + 'type' => 'response_from_php', + 'clientId' => $clientId, + 'data' => base64_encode($data) + ]); + post_message_to_js($response); + } +} diff --git a/packages/wp-mysql-proxy/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php new file mode 100644 index 00000000..3680c699 --- /dev/null +++ b/packages/wp-mysql-proxy/src/run-sqlite-translation.php @@ -0,0 +1,18 @@ +SQLite proxy that parses MySQL queries and transforms them into SQLite operations. + * + * Most queries works, and the upcoming translation driver should bring the parity much + * closer to 100%: https://github.com/WordPress/sqlite-database-integration/pull/157 + */ + +require_once __DIR__ . '/mysql-server.php'; +require_once __DIR__ . '/handler-sqlite-translation.php'; + +define('WP_SQLITE_AST_DRIVER', true); + +$server = new MySQLSocketServer( + new SQLiteTranslationHandler(__DIR__ . '/database/test.db'), + ['port' => 3306] +); +$server->start(); From 90c045f7ffa88c468e51810a0f7a8b4736d02c4b Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 15:58:12 +0200 Subject: [PATCH 6/7] Use WordPress coding styles --- .../src/handler-sqlite-translation.php | 36 +- packages/wp-mysql-proxy/src/mysql-server.php | 1713 +++++++++-------- .../src/run-sqlite-translation.php | 6 +- 3 files changed, 883 insertions(+), 872 deletions(-) diff --git a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php index 19658a88..482ac9f4 100644 --- a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php +++ b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php @@ -23,38 +23,38 @@ class SQLiteTranslationHandler implements MySQLQueryHandler { /** @var WP_SQLite_Driver */ private $sqlite_driver; - public function __construct($sqlite_database_path) { - define('FQDB', $sqlite_database_path); - define('FQDBDIR', dirname(FQDB) . '/'); + public function __construct( $sqlite_database_path ) { + define( 'FQDB', $sqlite_database_path ); + define( 'FQDBDIR', dirname( FQDB ) . '/' ); $this->sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), - 'wordpress' + 'sqlite_database' ); } - public function handleQuery(string $query): MySQLServerQueryResult { + public function handle_query( string $query ): MySQLServerQueryResult { try { - $rows = $this->sqlite_driver->query($query); + $rows = $this->sqlite_driver->query( $query ); if ( $this->sqlite_driver->get_last_column_count() > 0 ) { $columns = $this->computeColumnInfo(); - return new SelectQueryResult($columns, $rows); + return new SelectQueryResult( $columns, $rows ); } return new OkayPacketResult( $this->sqlite_driver->get_last_return_value() ?? 0, $this->sqlite_driver->get_insert_id() ?? 0 ); - } catch (Throwable $e) { - return new ErrorQueryResult($e->getMessage()); + } catch ( Throwable $e ) { + return new ErrorQueryResult( $e->getMessage() ); } } public function computeColumnInfo() { - $columns = []; + $columns = array(); $column_meta = $this->sqlite_driver->get_last_column_meta(); - $types = [ + $types = array( 'DECIMAL' => MySQLProtocol::FIELD_TYPE_DECIMAL, 'TINY' => MySQLProtocol::FIELD_TYPE_TINY, 'SHORT' => MySQLProtocol::FIELD_TYPE_SHORT, @@ -82,20 +82,20 @@ public function computeColumnInfo() { 'VAR_STRING' => MySQLProtocol::FIELD_TYPE_VAR_STRING, 'STRING' => MySQLProtocol::FIELD_TYPE_STRING, 'GEOMETRY' => MySQLProtocol::FIELD_TYPE_GEOMETRY, - ]; + ); - foreach ($column_meta as $column) { - $type = $types[$column['native_type']] ?? null; + foreach ( $column_meta as $column ) { + $type = $types[ $column['native_type'] ] ?? null; if ( null === $type ) { - throw new Exception('Unknown column type: ' . $column['native_type']); + throw new Exception( 'Unknown column type: ' . $column['native_type'] ); } - $columns[] = [ + $columns[] = array( 'name' => $column['name'], 'length' => $column['len'], 'type' => $type, 'flags' => 129, - 'decimals' => $column['precision'] - ]; + 'decimals' => $column['precision'], + ); } return $columns; } diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php index 95df3f1a..3d021665 100644 --- a/packages/wp-mysql-proxy/src/mysql-server.php +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -1,954 +1,965 @@ - string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] - public array $rows; // Array of rows, each an array of values (strings, numbers, or null) + public $columns; // Each column: ['name' => string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] + public $rows; // Array of rows, each an array of values (strings, numbers, or null) - public function __construct(array $columns = [], array $rows = []) { - $this->columns = $columns; - $this->rows = $rows; - } + public function __construct( array $columns = array(), array $rows = array() ) { + $this->columns = $columns; + $this->rows = $rows; + } - public function toPackets(): string { - return MySQLProtocol::buildResultSetPackets($this); + public function to_packets(): string { + return MySQLProtocol::build_result_set_packets( $this ); } } class OkayPacketResult implements MySQLServerQueryResult { - public int $affectedRows; - public int $lastInsertId; + public $affected_rows; + public $last_insert_id; - public function __construct(int $affectedRows, int $lastInsertId) { - $this->affectedRows = $affectedRows; - $this->lastInsertId = $lastInsertId; + public function __construct( int $affected_rows, int $last_insert_id ) { + $this->affected_rows = $affected_rows; + $this->last_insert_id = $last_insert_id; } - public function toPackets(): string { - $ok_packet = MySQLProtocol::buildOkPacket($this->affectedRows, $this->lastInsertId); - return MySQLProtocol::encodeInt24(strlen($ok_packet)) . MySQLProtocol::encodeInt8(1) . $ok_packet; + public function to_packets(): string { + $ok_packet = MySQLProtocol::build_ok_packet( $this->affected_rows, $this->last_insert_id ); + return MySQLProtocol::encode_int_24( strlen( $ok_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $ok_packet; } } class ErrorQueryResult implements MySQLServerQueryResult { - public string $code; - public string $sqlState; - public string $message; - - public function __construct(string $message = "Syntax error or unsupported query", string $sqlState = "42000", int $code = 0x04A7) { - $this->code = $code; - $this->sqlState = $sqlState; - $this->message = $message; + public $code; + public $sql_state; + public $message; + + public function __construct( string $message = 'Syntax error or unsupported query', string $sql_state = '42000', int $code = 0x04A7 ) { + $this->code = $code; + $this->sql_state = $sql_state; + $this->message = $message; } - public function toPackets(): string { - $err_packet = MySQLProtocol::buildErrPacket($this->code, $this->sqlState, $this->message); - return MySQLProtocol::encodeInt24(strlen($err_packet)) . MySQLProtocol::encodeInt8(1) . $err_packet; + public function to_packets(): string { + $err_packet = MySQLProtocol::build_err_packet( $this->code, $this->sql_state, $this->message ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $err_packet; } } class MySQLProtocol { - // MySQL client/server capability flags (partial list) - const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags - const CLIENT_CONNECT_WITH_DB = 0x00000008; - const CLIENT_PROTOCOL_41 = 0x00000200; - const CLIENT_SECURE_CONNECTION = 0x00008000; - const CLIENT_MULTI_STATEMENTS = 0x00010000; - const CLIENT_MULTI_RESULTS = 0x00020000; - const CLIENT_PS_MULTI_RESULTS = 0x00040000; - const CLIENT_PLUGIN_AUTH = 0x00080000; - const CLIENT_CONNECT_ATTRS = 0x00100000; - const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; - const CLIENT_DEPRECATE_EOF = 0x01000000; - - // MySQL status flags - const SERVER_STATUS_AUTOCOMMIT = 0x0002; + // MySQL client/server capability flags (partial list) + const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags + const CLIENT_CONNECT_WITH_DB = 0x00000008; + const CLIENT_PROTOCOL_41 = 0x00000200; + const CLIENT_SECURE_CONNECTION = 0x00008000; + const CLIENT_MULTI_STATEMENTS = 0x00010000; + const CLIENT_MULTI_RESULTS = 0x00020000; + const CLIENT_PS_MULTI_RESULTS = 0x00040000; + const CLIENT_PLUGIN_AUTH = 0x00080000; + const CLIENT_CONNECT_ATTRS = 0x00100000; + const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; + const CLIENT_DEPRECATE_EOF = 0x01000000; + + // MySQL status flags + const SERVER_STATUS_AUTOCOMMIT = 0x0002; /** * MySQL command types * * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html */ - const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ + const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ - const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ - const COM_QUERY = 0x03; /** Tells the server to execute a query. */ - const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ - const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ - const COM_DROP_DB = 0x06; /** Currently refused by the server. */ - const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ - const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ - const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ - const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ - const COM_CONNECT = 0x0B; /** Currently refused by the server. */ - const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ - const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ - const COM_PING = 0x0E; /** Check if the server is alive. */ - const COM_TIME = 0x0F; /** Currently refused by the server. */ - const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ - const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ - const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ - const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ - const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ - const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ - const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ - const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ - const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ - const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ - const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ - const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ - const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ - const COM_DAEMON = 0x1D; /** Currently refused by the server. */ - const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ - const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ - const COM_CLONE = 0x20; /** Tells the server to clone a server. */ - - // Special packet markers - const OK_PACKET = 0x00; - const EOF_PACKET = 0xfe; - const ERR_PACKET = 0xff; - const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) - - // Auth specific markers for caching_sha2_password - const CACHING_SHA2_FAST_AUTH = 3; - const CACHING_SHA2_FULL_AUTH = 4; - const AUTH_PLUGIN_NAME = 'caching_sha2_password'; - - // Field types - const FIELD_TYPE_DECIMAL = 0x00; - const FIELD_TYPE_TINY = 0x01; - const FIELD_TYPE_SHORT = 0x02; - const FIELD_TYPE_LONG = 0x03; - const FIELD_TYPE_FLOAT = 0x04; - const FIELD_TYPE_DOUBLE = 0x05; - const FIELD_TYPE_NULL = 0x06; - const FIELD_TYPE_TIMESTAMP = 0x07; - const FIELD_TYPE_LONGLONG = 0x08; - const FIELD_TYPE_INT24 = 0x09; - const FIELD_TYPE_DATE = 0x0a; - const FIELD_TYPE_TIME = 0x0b; - const FIELD_TYPE_DATETIME = 0x0c; - const FIELD_TYPE_YEAR = 0x0d; - const FIELD_TYPE_NEWDATE = 0x0e; - const FIELD_TYPE_VARCHAR = 0x0f; - const FIELD_TYPE_BIT = 0x10; - const FIELD_TYPE_NEWDECIMAL = 0xf6; - const FIELD_TYPE_ENUM = 0xf7; - const FIELD_TYPE_SET = 0xf8; - const FIELD_TYPE_TINY_BLOB = 0xf9; - const FIELD_TYPE_MEDIUM_BLOB = 0xfa; - const FIELD_TYPE_LONG_BLOB = 0xfb; - const FIELD_TYPE_BLOB = 0xfc; - const FIELD_TYPE_VAR_STRING = 0xfd; - const FIELD_TYPE_STRING = 0xfe; - const FIELD_TYPE_GEOMETRY = 0xff; - - // Field flags - const NOT_NULL_FLAG = 0x1; - const PRI_KEY_FLAG = 0x2; - const UNIQUE_KEY_FLAG = 0x4; - const MULTIPLE_KEY_FLAG = 0x8; - const BLOB_FLAG = 0x10; - const UNSIGNED_FLAG = 0x20; - const ZEROFILL_FLAG = 0x40; - const BINARY_FLAG = 0x80; - const ENUM_FLAG = 0x100; - const AUTO_INCREMENT_FLAG = 0x200; - const TIMESTAMP_FLAG = 0x400; - const SET_FLAG = 0x800; - - // Character set and collation constants (using utf8mb4 general collation) - const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) - - // Max packet length constant - const MAX_PACKET_LENGTH = 0x00ffffff; - - private $current_db = ''; - - // Helper: Packets assembly and parsing - public static function encodeInt8(int $val): string { - return chr($val & 0xff); - } - public static function encodeInt16(int $val): string { - return pack('v', $val & 0xffff); - } - public static function encodeInt24(int $val): string { - // 3-byte little-endian integer - return substr(pack('V', $val & 0xffffff), 0, 3); - } - public static function encodeInt32(int $val): string { - return pack('V', $val); - } - public static function encodeLengthEncodedInt(int $val): string { - // Encodes an integer in MySQL's length-encoded format - if ($val < 0xfb) { - return chr($val); - } elseif ($val <= 0xffff) { - return "\xfc" . self::encodeInt16($val); - } elseif ($val <= 0xffffff) { - return "\xfd" . self::encodeInt24($val); - } else { - return "\xfe" . pack('P', $val); // 8-byte little-endian for 64-bit - } - } - public static function encodeLengthEncodedString(string $str): string { - return self::encodeLengthEncodedInt(strlen($str)) . $str; - } - - // Hashing for caching_sha2_password (fast auth algorithm) - public static function sha256Hash(string $password, string $salt): string { - $stage1 = hash('sha256', $password, true); - $stage2 = hash('sha256', $stage1, true); - $scramble = hash('sha256', $stage2 . substr($salt, 0, 20), true); - // XOR stage1 and scramble to get token - return $stage1 ^ $scramble; - } - - // Build initial handshake packet (server greeting) - public static function buildHandshakePacket(int $connId, string &$authPluginData): string { - $protocol_version = 0x0a; // Handshake protocol version (10) - $server_version = "5.7.30-php-mysql-server"; // Fake server version - // Generate random auth plugin data (20-byte salt) - $salt1 = random_bytes(8); - $salt2 = random_bytes(12); // total salt length = 8+12 = 20 bytes (with filler) - $authPluginData = $salt1 . $salt2; - // Lower 2 bytes of capability flags - $capFlagsLower = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) & 0xffff; - // Upper 2 bytes of capability flags - $capFlagsUpper = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) >> 16; - $charset = self::CHARSET_UTF8MB4; - $statusFlags = self::SERVER_STATUS_AUTOCOMMIT; - - // Assemble handshake packet payload - $payload = chr($protocol_version); - $payload .= $server_version . "\0"; - $payload .= self::encodeInt32($connId); - $payload .= $salt1; - $payload .= "\0"; // filler byte - $payload .= self::encodeInt16($capFlagsLower); - $payload .= chr($charset); - $payload .= self::encodeInt16($statusFlags); - $payload .= self::encodeInt16($capFlagsUpper); - $payload .= chr(strlen($authPluginData) + 1); // auth plugin data length (salt + \0) - $payload .= str_repeat("\0", 10); // 10-byte reserved filler - $payload .= $salt2; - $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 - $payload .= self::AUTH_PLUGIN_NAME . "\0"; - return $payload; - } - - // Build OK packet (after successful authentication or query execution) - public static function buildOkPacket(int $affectedRows = 0, int $lastInsertId = 0): string { - $payload = chr(self::OK_PACKET); - $payload .= self::encodeLengthEncodedInt($affectedRows); - $payload .= self::encodeLengthEncodedInt($lastInsertId); - $payload .= self::encodeInt16(self::SERVER_STATUS_AUTOCOMMIT); // server status - $payload .= self::encodeInt16(0); // no warning count - // No human-readable message for simplicity - return $payload; - } - - // Build ERR packet (for errors) - public static function buildErrPacket(int $errorCode, string $sqlState, string $message): string { - $payload = chr(self::ERR_PACKET); - $payload .= self::encodeInt16($errorCode); - $payload .= "#" . strtoupper($sqlState); - $payload .= $message; - return $payload; - } - - // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) - public static function buildResultSetPackets(SelectQueryResult $result): string { - $sequenceId = 1; // Sequence starts at 1 for resultset (after COM_QUERY) - $packetStream = ''; - - // 1. Column count packet (length-encoded integer for number of columns) - $colCount = count($result->columns); - $colCountPayload = self::encodeLengthEncodedInt($colCount); - $packetStream .= self::wrapPacket($colCountPayload, $sequenceId++); - - // 2. Column definition packets for each column - foreach ($result->columns as $col) { - // Protocol::ColumnDefinition41 format:] - $colPayload = self::encodeLengthEncodedString($col['catalog'] ?? 'sqlite'); - $colPayload .= self::encodeLengthEncodedString($col['schema'] ?? ''); + const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ + const COM_QUERY = 0x03; /** Tells the server to execute a query. */ + const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ + const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ + const COM_DROP_DB = 0x06; /** Currently refused by the server. */ + const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ + const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ + const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ + const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ + const COM_CONNECT = 0x0B; /** Currently refused by the server. */ + const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ + const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ + const COM_PING = 0x0E; /** Check if the server is alive. */ + const COM_TIME = 0x0F; /** Currently refused by the server. */ + const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ + const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ + const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ + const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ + const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ + const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ + const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ + const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ + const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ + const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ + const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ + const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ + const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ + const COM_DAEMON = 0x1D; /** Currently refused by the server. */ + const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ + const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ + const COM_CLONE = 0x20; /** Tells the server to clone a server. */ + + // Special packet markers + const OK_PACKET = 0x00; + const EOF_PACKET = 0xfe; + const ERR_PACKET = 0xff; + const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) + + // Auth specific markers for caching_sha2_password + const CACHING_SHA2_FAST_AUTH = 3; + const CACHING_SHA2_FULL_AUTH = 4; + const AUTH_PLUGIN_NAME = 'caching_sha2_password'; + + // Field types + const FIELD_TYPE_DECIMAL = 0x00; + const FIELD_TYPE_TINY = 0x01; + const FIELD_TYPE_SHORT = 0x02; + const FIELD_TYPE_LONG = 0x03; + const FIELD_TYPE_FLOAT = 0x04; + const FIELD_TYPE_DOUBLE = 0x05; + const FIELD_TYPE_NULL = 0x06; + const FIELD_TYPE_TIMESTAMP = 0x07; + const FIELD_TYPE_LONGLONG = 0x08; + const FIELD_TYPE_INT24 = 0x09; + const FIELD_TYPE_DATE = 0x0a; + const FIELD_TYPE_TIME = 0x0b; + const FIELD_TYPE_DATETIME = 0x0c; + const FIELD_TYPE_YEAR = 0x0d; + const FIELD_TYPE_NEWDATE = 0x0e; + const FIELD_TYPE_VARCHAR = 0x0f; + const FIELD_TYPE_BIT = 0x10; + const FIELD_TYPE_NEWDECIMAL = 0xf6; + const FIELD_TYPE_ENUM = 0xf7; + const FIELD_TYPE_SET = 0xf8; + const FIELD_TYPE_TINY_BLOB = 0xf9; + const FIELD_TYPE_MEDIUM_BLOB = 0xfa; + const FIELD_TYPE_LONG_BLOB = 0xfb; + const FIELD_TYPE_BLOB = 0xfc; + const FIELD_TYPE_VAR_STRING = 0xfd; + const FIELD_TYPE_STRING = 0xfe; + const FIELD_TYPE_GEOMETRY = 0xff; + + // Field flags + const NOT_NULL_FLAG = 0x1; + const PRI_KEY_FLAG = 0x2; + const UNIQUE_KEY_FLAG = 0x4; + const MULTIPLE_KEY_FLAG = 0x8; + const BLOB_FLAG = 0x10; + const UNSIGNED_FLAG = 0x20; + const ZEROFILL_FLAG = 0x40; + const BINARY_FLAG = 0x80; + const ENUM_FLAG = 0x100; + const AUTO_INCREMENT_FLAG = 0x200; + const TIMESTAMP_FLAG = 0x400; + const SET_FLAG = 0x800; + + // Character set and collation constants (using utf8mb4 general collation) + const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) + + // Max packet length constant + const MAX_PACKET_LENGTH = 0x00ffffff; + + private $current_db = ''; + + // Helper: Packets assembly and parsing + public static function encode_int_8( int $val ): string { + return chr( $val & 0xff ); + } + + public static function encode_int_16( int $val ): string { + return pack( 'v', $val & 0xffff ); + } + + public static function encode_int_24( int $val ): string { + // 3-byte little-endian integer + return substr( pack( 'V', $val & 0xffffff ), 0, 3 ); + } + + public static function encode_int_32( int $val ): string { + return pack( 'V', $val ); + } + + public static function encode_length_encoded_int( int $val ): string { + // Encodes an integer in MySQL's length-encoded format + if ( $val < 0xfb ) { + return chr( $val ); + } elseif ( $val <= 0xffff ) { + return "\xfc" . self::encode_int_16( $val ); + } elseif ( $val <= 0xffffff ) { + return "\xfd" . self::encode_int_24( $val ); + } else { + return "\xfe" . pack( 'P', $val ); // 8-byte little-endian for 64-bit + } + } + + public static function encode_length_encoded_string( string $str ): string { + return self::encode_length_encoded_int( strlen( $str ) ) . $str; + } + + // Hashing for caching_sha2_password (fast auth algorithm) + public static function sha_256_hash( string $password, string $salt ): string { + $stage1 = hash( 'sha256', $password, true ); + $stage2 = hash( 'sha256', $stage1, true ); + $scramble = hash( 'sha256', $stage2 . substr( $salt, 0, 20 ), true ); + // XOR stage1 and scramble to get token + return $stage1 ^ $scramble; + } + + // Build initial handshake packet (server greeting) + public static function build_handshake_packet( int $conn_id, string &$auth_plugin_data ): string { + $protocol_version = 0x0a; // Handshake protocol version (10) + $server_version = '5.7.30-php-mysql-server'; // Fake server version + // Generate random auth plugin data (20-byte salt) + $salt1 = random_bytes( 8 ); + $salt2 = random_bytes( 12 ); // total salt length = 8+12 = 20 bytes (with filler) + $auth_plugin_data = $salt1 . $salt2; + // Lower 2 bytes of capability flags + $cap_flags_lower = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) & 0xffff; + // Upper 2 bytes of capability flags + $cap_flags_upper = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) >> 16; + $charset = self::CHARSET_UTF8MB4; + $status_flags = self::SERVER_STATUS_AUTOCOMMIT; + + // Assemble handshake packet payload + $payload = chr( $protocol_version ); + $payload .= $server_version . "\0"; + $payload .= self::encode_int_32( $conn_id ); + $payload .= $salt1; + $payload .= "\0"; // filler byte + $payload .= self::encode_int_16( $cap_flags_lower ); + $payload .= chr( $charset ); + $payload .= self::encode_int_16( $status_flags ); + $payload .= self::encode_int_16( $cap_flags_upper ); + $payload .= chr( strlen( $auth_plugin_data ) + 1 ); // auth plugin data length (salt + \0) + $payload .= str_repeat( "\0", 10 ); // 10-byte reserved filler + $payload .= $salt2; + $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 + $payload .= self::AUTH_PLUGIN_NAME . "\0"; + return $payload; + } + + // Build OK packet (after successful authentication or query execution) + public static function build_ok_packet( int $affected_rows = 0, int $last_insert_id = 0 ): string { + $payload = chr( self::OK_PACKET ); + $payload .= self::encode_length_encoded_int( $affected_rows ); + $payload .= self::encode_length_encoded_int( $last_insert_id ); + $payload .= self::encode_int_16( self::SERVER_STATUS_AUTOCOMMIT ); // server status + $payload .= self::encode_int_16( 0 ); // no warning count + // No human-readable message for simplicity + return $payload; + } + + // Build ERR packet (for errors) + public static function build_err_packet( int $error_code, string $sql_state, string $message ): string { + $payload = chr( self::ERR_PACKET ); + $payload .= self::encode_int_16( $error_code ); + $payload .= '#' . strtoupper( $sql_state ); + $payload .= $message; + return $payload; + } + + // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) + public static function build_result_set_packets( SelectQueryResult $result ): string { + $sequence_id = 1; // Sequence starts at 1 for resultset (after COM_QUERY) + $packet_stream = ''; + + // 1. Column count packet (length-encoded integer for number of columns) + $col_count = count( $result->columns ); + $col_count_payload = self::encode_length_encoded_int( $col_count ); + $packet_stream .= self::wrap_packet( $col_count_payload, $sequence_id++ ); + + // 2. Column definition packets for each column + foreach ( $result->columns as $col ) { + // Protocol::ColumnDefinition41 format:] + $col_payload = self::encode_length_encoded_string( $col['catalog'] ?? 'sqlite' ); + $col_payload .= self::encode_length_encoded_string( $col['schema'] ?? '' ); // Table alias - $colPayload .= self::encodeLengthEncodedString($col['table'] ?? ''); + $col_payload .= self::encode_length_encoded_string( $col['table'] ?? '' ); // Original table name - $colPayload .= self::encodeLengthEncodedString($col['orgTable'] ?? ''); + $col_payload .= self::encode_length_encoded_string( $col['orgTable'] ?? '' ); // Column alias - $colPayload .= self::encodeLengthEncodedString($col['name']); + $col_payload .= self::encode_length_encoded_string( $col['name'] ); // Original column name - $colPayload .= self::encodeLengthEncodedString($col['orgName'] ?? $col['name']); + $col_payload .= self::encode_length_encoded_string( $col['orgName'] ?? $col['name'] ); // Length of the remaining fixed fields. @TODO: What does that mean? - $colPayload .= self::encodeLengthEncodedInt($col['fixedLen'] ?? 0x0c); - $colPayload .= self::encodeInt16($col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4); - $colPayload .= self::encodeInt32($col['length']); - $colPayload .= self::encodeInt8($col['type']); - $colPayload .= self::encodeInt16($col['flags']); - $colPayload .= self::encodeInt8($col['decimals']); - $colPayload .= "\x00"; // filler (1 byte, reserved) - - $packetStream .= self::wrapPacket($colPayload, $sequenceId++); - } - // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) - $eofPayload = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); - $packetStream .= self::wrapPacket($eofPayload, $sequenceId++); - - // 4. Row data packets (each row is a series of length-encoded values) - foreach ($result->rows as $row) { - $rowPayload = ""; - // Iterate through columns in the defined order to match column definitions - foreach ($result->columns as $col) { - $columnName = $col['name']; - $val = $row->{$columnName} ?? null; - - if ($val === null) { - // NULL is represented by 0xfb (NULL_VALUE) - $rowPayload .= "\xfb"; - } else { - $valStr = (string)$val; - $rowPayload .= self::encodeLengthEncodedString($valStr); - } - } - $packetStream .= self::wrapPacket($rowPayload, $sequenceId++); - } - - // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) - $eofPayload2 = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); - $packetStream .= self::wrapPacket($eofPayload2, $sequenceId++); - - return $packetStream; - } - - // Helper to wrap a payload into a packet with length and sequence id - public static function wrapPacket(string $payload, int $sequenceId): string { - $length = strlen($payload); - $header = self::encodeInt24($length) . self::encodeInt8($sequenceId); - return $header . $payload; - } + $col_payload .= self::encode_length_encoded_int( $col['fixedLen'] ?? 0x0c ); + $col_payload .= self::encode_int_16( $col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4 ); + $col_payload .= self::encode_int_32( $col['length'] ); + $col_payload .= self::encode_int_8( $col['type'] ); + $col_payload .= self::encode_int_16( $col['flags'] ); + $col_payload .= self::encode_int_8( $col['decimals'] ); + $col_payload .= "\x00"; // filler (1 byte, reserved) + + $packet_stream .= self::wrap_packet( $col_payload, $sequence_id++ ); + } + // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) + $eof_payload = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); + $packet_stream .= self::wrap_packet( $eof_payload, $sequence_id++ ); + + // 4. Row data packets (each row is a series of length-encoded values) + foreach ( $result->rows as $row ) { + $row_payload = ''; + // Iterate through columns in the defined order to match column definitions + foreach ( $result->columns as $col ) { + $column_name = $col['name']; + $val = $row->{$column_name} ?? null; + + if ( null === $val ) { + // NULL is represented by 0xfb (NULL_VALUE) + $row_payload .= "\xfb"; + } else { + $val_str = (string) $val; + $row_payload .= self::encode_length_encoded_string( $val_str ); + } + } + $packet_stream .= self::wrap_packet( $row_payload, $sequence_id++ ); + } + + // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) + $eof_payload_2 = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); + $packet_stream .= self::wrap_packet( $eof_payload_2, $sequence_id++ ); + + return $packet_stream; + } + + // Helper to wrap a payload into a packet with length and sequence id + public static function wrap_packet( string $payload, int $sequence_id ): string { + $length = strlen( $payload ); + $header = self::encode_int_24( $length ) . self::encode_int_8( $sequence_id ); + return $header . $payload; + } } class IncompleteInputException extends MySQLServerException { - public function __construct(string $message = "Incomplete input data, more bytes needed") { - parent::__construct($message); - } + public function __construct( string $message = 'Incomplete input data, more bytes needed' ) { + parent::__construct( $message ); + } } class MySQLGateway { - private $query_handler; - private $connection_id; - private $auth_plugin_data; - private $sequence_id; - private $authenticated = false; - private $buffer = ''; - - public function __construct(MySQLQueryHandler $query_handler) { - $this->query_handler = $query_handler; - $this->connection_id = random_int(1, 1000); - $this->auth_plugin_data = ""; - $this->sequence_id = 0; - } - - /** - * Get the initial handshake packet to send to the client - * - * @return string Binary packet data to send to client - */ - public function getInitialHandshake(): string { - $handshakePayload = MySQLProtocol::buildHandshakePacket($this->connection_id, $this->auth_plugin_data); - return MySQLProtocol::encodeInt24(strlen($handshakePayload)) . - MySQLProtocol::encodeInt8($this->sequence_id++) . - $handshakePayload; - } - - /** - * Process bytes received from the client - * - * @param string $data Binary data received from client - * @return string|null Response to send back to client, or null if no response needed - * @throws IncompleteInputException When more data is needed to complete a packet - */ - public function receiveBytes(string $data): ?string { - // Append new data to existing buffer - $this->buffer .= $data; - - // Check if we have enough data for a header - if (strlen($this->buffer) < 4) { - throw new IncompleteInputException("Incomplete packet header, need more bytes"); - } - - // Parse packet header - $packetLength = unpack('V', substr($this->buffer, 0, 3) . "\x00")[1]; - $receivedSequenceId = ord($this->buffer[3]); - - // Check if we have the complete packet - $totalPacketLength = 4 + $packetLength; - if (strlen($this->buffer) < $totalPacketLength) { - throw new IncompleteInputException( - "Incomplete packet payload, have " . strlen($this->buffer) . - " bytes, need " . $totalPacketLength . " bytes" - ); - } - - // Extract the complete packet - $packet = substr($this->buffer, 0, $totalPacketLength); - - // Remove the processed packet from the buffer - $this->buffer = substr($this->buffer, $totalPacketLength); - - // Process the packet - $payload = substr($packet, 4, $packetLength); - - // If not authenticated yet, process authentication - if (!$this->authenticated) { - return $this->processAuthentication($payload); - } - - // Otherwise, process as a command - $command = ord($payload[0]); - if ($command === MySQLProtocol::COM_QUERY) { - $query = substr($payload, 1); - return $this->processQuery($query); - } elseif ($command === MySQLProtocol::COM_INIT_DB) { - return $this->processQuery('USE ' . substr($payload, 1)); - } elseif ($command === MySQLProtocol::COM_QUIT) { - return ''; - } else { - // Unsupported command - $errPacket = MySQLProtocol::buildErrPacket(0x04D2, "HY000", "Unsupported command"); - return MySQLProtocol::encodeInt24(strlen($errPacket)) . - MySQLProtocol::encodeInt8(1) . - $errPacket; - } - } - - /** - * Process authentication packet from client - * - * @param string $payload Authentication packet payload - * @return string Response packet to send back - */ - private function processAuthentication(string $payload): string { - $offset = 0; - $payloadLength = strlen($payload); - - $capabilityFlags = $this->readUnsignedIntLittleEndian($payload, $offset, 4); - $offset += 4; - - $clientMaxPacketSize = $this->readUnsignedIntLittleEndian($payload, $offset, 4); - $offset += 4; - - $clientCharacterSet = 0; - if ($offset < $payloadLength) { - $clientCharacterSet = ord($payload[$offset]); - } - $offset += 1; - - // Skip reserved bytes (always zero) - $offset = min($payloadLength, $offset + 23); - - $username = $this->readNullTerminatedString($payload, $offset); - - $authResponse = ''; - if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) { - $authResponseLength = $this->readLengthEncodedInt($payload, $offset); - $authResponse = substr($payload, $offset, $authResponseLength); - $offset = min($payloadLength, $offset + $authResponseLength); - } elseif ($capabilityFlags & MySQLProtocol::CLIENT_SECURE_CONNECTION) { - $authResponseLength = 0; - if ($offset < $payloadLength) { - $authResponseLength = ord($payload[$offset]); - } - $offset += 1; - $authResponse = substr($payload, $offset, $authResponseLength); - $offset = min($payloadLength, $offset + $authResponseLength); - } else { - $authResponse = $this->readNullTerminatedString($payload, $offset); - } - - $database = ''; - if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_WITH_DB) { - $database = $this->readNullTerminatedString($payload, $offset); - } - - $authPluginName = ''; - if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH) { - $authPluginName = $this->readNullTerminatedString($payload, $offset); - } - - if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_ATTRS) { - $attrsLength = $this->readLengthEncodedInt($payload, $offset); - $offset = min($payloadLength, $offset + $attrsLength); - } - - $this->authenticated = true; - $this->sequence_id = 2; - - $responsePackets = ''; - - if ($authPluginName === MySQLProtocol::AUTH_PLUGIN_NAME) { - $fastAuthPayload = chr(MySQLProtocol::AUTH_MORE_DATA) . chr(MySQLProtocol::CACHING_SHA2_FAST_AUTH); - $responsePackets .= MySQLProtocol::encodeInt24(strlen($fastAuthPayload)); - $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); - $responsePackets .= $fastAuthPayload; - } - - $okPacket = MySQLProtocol::buildOkPacket(); - $responsePackets .= MySQLProtocol::encodeInt24(strlen($okPacket)); - $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); - $responsePackets .= $okPacket; - - return $responsePackets; - } - - private function readUnsignedIntLittleEndian(string $payload, int $offset, int $length): int { - $slice = substr($payload, $offset, $length); - if ($slice === '' || $length <= 0) { - return 0; - } - - switch ($length) { - case 1: - return ord($slice[0]); - case 2: - $padded = str_pad($slice, 2, "\x00", STR_PAD_RIGHT); - $unpacked = unpack('v', $padded); - return $unpacked[1] ?? 0; - case 3: - case 4: - default: - $padded = str_pad($slice, 4, "\x00", STR_PAD_RIGHT); - $unpacked = unpack('V', $padded); - return $unpacked[1] ?? 0; - } - } - - private function readNullTerminatedString(string $payload, int &$offset): string { - $nullPosition = strpos($payload, "\0", $offset); - if ($nullPosition === false) { - $result = substr($payload, $offset); - $offset = strlen($payload); - return $result; - } - - $result = substr($payload, $offset, $nullPosition - $offset); - $offset = $nullPosition + 1; - return $result; - } - - private function readLengthEncodedInt(string $payload, int &$offset): int { - if ($offset >= strlen($payload)) { - return 0; - } - - $first = ord($payload[$offset]); - $offset += 1; - - if ($first < 0xfb) { - return $first; - } - - if ($first === 0xfb) { - return 0; - } - - if ($first === 0xfc) { - $value = $this->readUnsignedIntLittleEndian($payload, $offset, 2); - $offset += 2; - return $value; - } - - if ($first === 0xfd) { - $value = $this->readUnsignedIntLittleEndian($payload, $offset, 3); - $offset += 3; - return $value; - } - - // 0xfe indicates an 8-byte integer - $value = 0; - $slice = substr($payload, $offset, 8); - if ($slice !== '') { - $slice = str_pad($slice, 8, "\x00"); - $value = unpack('P', $slice)[1]; - } - $offset += 8; - return (int) $value; - } - - /** - * Process a query from the client - * - * @param string $query SQL query to process - * @return string Response packet to send back - */ - private function processQuery(string $query): string { - $query = trim($query); - - try { - $result = $this->query_handler->handleQuery($query); - return $result->toPackets(); - } catch (MySQLServerException $e) { - $errPacket = MySQLProtocol::buildErrPacket(0x04A7, "42000", "Syntax error or unsupported query: " . $e->getMessage()); - return MySQLProtocol::encodeInt24(strlen($errPacket)) . - MySQLProtocol::encodeInt8(1) . - $errPacket; - } - } - - /** - * Reset the server state for a new connection - */ - public function reset(): void { - $this->connection_id = random_int(1, 1000); - $this->auth_plugin_data = ""; - $this->sequence_id = 0; - $this->authenticated = false; - $this->buffer = ''; - } - - /** - * Check if there's any buffered data that hasn't been processed yet - * - * @return bool True if there's data in the buffer - */ - public function hasBufferedData(): bool { - return !empty($this->buffer); - } - - /** - * Get the number of bytes currently in the buffer - * - * @return int Number of bytes in buffer - */ - public function getBufferSize(): int { - return strlen($this->buffer); - } + private $query_handler; + private $connection_id; + private $auth_plugin_data; + private $sequence_id; + private $authenticated = false; + private $buffer = ''; + + public function __construct( MySQLQueryHandler $query_handler ) { + $this->query_handler = $query_handler; + $this->connection_id = random_int( 1, 1000 ); + $this->auth_plugin_data = ''; + $this->sequence_id = 0; + } + + /** + * Get the initial handshake packet to send to the client + * + * @return string Binary packet data to send to client + */ + public function get_initial_handshake(): string { + $handshake_payload = MySQLProtocol::build_handshake_packet( $this->connection_id, $this->auth_plugin_data ); + return MySQLProtocol::encode_int_24( strlen( $handshake_payload ) ) . + MySQLProtocol::encode_int_8( $this->sequence_id++ ) . + $handshake_payload; + } + + /** + * Process bytes received from the client + * + * @param string $data Binary data received from client + * @return string|null Response to send back to client, or null if no response needed + * @throws IncompleteInputException When more data is needed to complete a packet + */ + public function receive_bytes( string $data ): ?string { + // Append new data to existing buffer + $this->buffer .= $data; + + // Check if we have enough data for a header + if ( strlen( $this->buffer ) < 4 ) { + throw new IncompleteInputException( 'Incomplete packet header, need more bytes' ); + } + + // Parse packet header + $packet_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; + $received_sequence_id = ord( $this->buffer[3] ); + + // Check if we have the complete packet + $total_packet_length = 4 + $packet_length; + if ( strlen( $this->buffer ) < $total_packet_length ) { + throw new IncompleteInputException( + 'Incomplete packet payload, have ' . strlen( $this->buffer ) . + ' bytes, need ' . $total_packet_length . ' bytes' + ); + } + + // Extract the complete packet + $packet = substr( $this->buffer, 0, $total_packet_length ); + + // Remove the processed packet from the buffer + $this->buffer = substr( $this->buffer, $total_packet_length ); + + // Process the packet + $payload = substr( $packet, 4, $packet_length ); + + // If not authenticated yet, process authentication + if ( ! $this->authenticated ) { + return $this->process_authentication( $payload ); + } + + // Otherwise, process as a command + $command = ord( $payload[0] ); + if ( MySQLProtocol::COM_QUERY === $command ) { + $query = substr( $payload, 1 ); + return $this->process_query( $query ); + } elseif ( MySQLProtocol::COM_INIT_DB === $command ) { + return $this->process_query( 'USE ' . substr( $payload, 1 ) ); + } elseif ( MySQLProtocol::COM_QUIT === $command ) { + return ''; + } else { + // Unsupported command + $err_packet = MySQLProtocol::build_err_packet( 0x04D2, 'HY000', 'Unsupported command' ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . + MySQLProtocol::encode_int_8( 1 ) . + $err_packet; + } + } + + /** + * Process authentication packet from client + * + * @param string $payload Authentication packet payload + * @return string Response packet to send back + */ + private function process_authentication( string $payload ): string { + $offset = 0; + $payload_length = strlen( $payload ); + + $capability_flags = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); + $offset += 4; + + $client_max_packet_size = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); + $offset += 4; + + $client_character_set = 0; + if ( $offset < $payload_length ) { + $client_character_set = ord( $payload[ $offset ] ); + } + $offset += 1; + + // Skip reserved bytes (always zero) + $offset = min( $payload_length, $offset + 23 ); + + $username = $this->read_null_terminated_string( $payload, $offset ); + + $auth_response = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response_length = $this->read_length_encoded_int( $payload, $offset ); + $auth_response = substr( $payload, $offset, $auth_response_length ); + $offset = min( $payload_length, $offset + $auth_response_length ); + } elseif ( $capability_flags & MySQLProtocol::CLIENT_SECURE_CONNECTION ) { + $auth_response_length = 0; + if ( $offset < $payload_length ) { + $auth_response_length = ord( $payload[ $offset ] ); + } + $offset += 1; + $auth_response = substr( $payload, $offset, $auth_response_length ); + $offset = min( $payload_length, $offset + $auth_response_length ); + } else { + $auth_response = $this->read_null_terminated_string( $payload, $offset ); + } + + $database = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_WITH_DB ) { + $database = $this->read_null_terminated_string( $payload, $offset ); + } + + $auth_plugin_name = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_name = $this->read_null_terminated_string( $payload, $offset ); + } + + if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_ATTRS ) { + $attrs_length = $this->read_length_encoded_int( $payload, $offset ); + $offset = min( $payload_length, $offset + $attrs_length ); + } + + $this->authenticated = true; + $this->sequence_id = 2; + + $response_packets = ''; + + if ( MySQLProtocol::AUTH_PLUGIN_NAME === $auth_plugin_name ) { + $fast_auth_payload = chr( MySQLProtocol::AUTH_MORE_DATA ) . chr( MySQLProtocol::CACHING_SHA2_FAST_AUTH ); + $response_packets .= MySQLProtocol::encode_int_24( strlen( $fast_auth_payload ) ); + $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $fast_auth_payload; + } + + $ok_packet = MySQLProtocol::build_ok_packet(); + $response_packets .= MySQLProtocol::encode_int_24( strlen( $ok_packet ) ); + $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $ok_packet; + + return $response_packets; + } + + private function read_unsigned_int_little_endian( string $payload, int $offset, int $length ): int { + $slice = substr( $payload, $offset, $length ); + if ( '' === $slice || $length <= 0 ) { + return 0; + } + + switch ( $length ) { + case 1: + return ord( $slice[0] ); + case 2: + $padded = str_pad( $slice, 2, "\x00", STR_PAD_RIGHT ); + $unpacked = unpack( 'v', $padded ); + return $unpacked[1] ?? 0; + case 3: + case 4: + default: + $padded = str_pad( $slice, 4, "\x00", STR_PAD_RIGHT ); + $unpacked = unpack( 'V', $padded ); + return $unpacked[1] ?? 0; + } + } + + private function read_null_terminated_string( string $payload, int &$offset ): string { + $null_position = strpos( $payload, "\0", $offset ); + if ( false === $null_position ) { + $result = substr( $payload, $offset ); + $offset = strlen( $payload ); + return $result; + } + + $result = substr( $payload, $offset, $null_position - $offset ); + $offset = $null_position + 1; + return $result; + } + + private function read_length_encoded_int( string $payload, int &$offset ): int { + if ( $offset >= strlen( $payload ) ) { + return 0; + } + + $first = ord( $payload[ $offset ] ); + $offset += 1; + + if ( $first < 0xfb ) { + return $first; + } + + if ( 0xfb === $first ) { + return 0; + } + + if ( 0xfc === $first ) { + $value = $this->read_unsigned_int_little_endian( $payload, $offset, 2 ); + $offset += 2; + return $value; + } + + if ( 0xfd === $first ) { + $value = $this->read_unsigned_int_little_endian( $payload, $offset, 3 ); + $offset += 3; + return $value; + } + + // 0xfe indicates an 8-byte integer + $value = 0; + $slice = substr( $payload, $offset, 8 ); + if ( '' !== $slice ) { + $slice = str_pad( $slice, 8, "\x00" ); + $value = unpack( 'P', $slice )[1]; + } + $offset += 8; + return (int) $value; + } + + /** + * Process a query from the client + * + * @param string $query SQL query to process + * @return string Response packet to send back + */ + private function process_query( string $query ): string { + $query = trim( $query ); + + try { + $result = $this->query_handler->handle_query( $query ); + return $result->to_packets(); + } catch ( MySQLServerException $e ) { + $err_packet = MySQLProtocol::build_err_packet( 0x04A7, '42000', 'Syntax error or unsupported query: ' . $e->getMessage() ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . + MySQLProtocol::encode_int_8( 1 ) . + $err_packet; + } + } + + /** + * Reset the server state for a new connection + */ + public function reset(): void { + $this->connection_id = random_int( 1, 1000 ); + $this->auth_plugin_data = ''; + $this->sequence_id = 0; + $this->authenticated = false; + $this->buffer = ''; + } + + /** + * Check if there's any buffered data that hasn't been processed yet + * + * @return bool True if there's data in the buffer + */ + public function has_buffered_data(): bool { + return ! empty( $this->buffer ); + } + + /** + * Get the number of bytes currently in the buffer + * + * @return int Number of bytes in buffer + */ + public function get_buffer_size(): int { + return strlen( $this->buffer ); + } } class SingleUseMySQLSocketServer { - private $server; - private $socket; - private $port; - - public function __construct(MySQLQueryHandler $query_handler, $options = []) { - $this->server = new MySQLGateway($query_handler); - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - socket_bind($this->socket, '0.0.0.0', $this->port); - socket_listen($this->socket); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - - // Accept a single client for simplicity - $client = socket_accept($this->socket); - if (!$client) { - exit("Failed to accept connection\n"); - } - $this->handleClient($client); - socket_close($client); - socket_close($this->socket); - } - - private function handleClient($client) { - // Send initial handshake - $handshake = $this->server->getInitialHandshake(); - socket_write($client, $handshake); - - while (true) { - // Read available data (up to 4096 bytes at a time) - $data = @socket_read($client, 4096); - if ($data === false || $data === '') { - break; // connection closed - } - - try { - // Process the data - $response = $this->server->receiveBytes($data); - if ($response) { - socket_write($client, $response); - } - - // If there's still data in the buffer, process it immediately - while ($this->server->hasBufferedData()) { - try { - // Try to process more complete packets from the buffer - $response = $this->server->receiveBytes(''); - if ($response) { - socket_write($client, $response); - } - } catch (IncompleteInputException $e) { - // Not enough data to complete another packet, wait for more - break; - } - } - } catch (IncompleteInputException $e) { - // Not enough data yet, continue reading - continue; - } - } - - echo "Client disconnected, terminating the server.\n"; - $this->server->reset(); - } + private $server; + private $socket; + private $port; + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->server = new MySQLGateway( $query_handler ); + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $this->socket, '0.0.0.0', $this->port ); + socket_listen( $this->socket ); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + + // Accept a single client for simplicity + $client = socket_accept( $this->socket ); + if ( ! $client ) { + exit( "Failed to accept connection\n" ); + } + $this->handle_client( $client ); + socket_close( $client ); + socket_close( $this->socket ); + } + + private function handle_client( $client ) { + // Send initial handshake + $handshake = $this->server->get_initial_handshake(); + socket_write( $client, $handshake ); + + while ( true ) { + // Read available data (up to 4096 bytes at a time) + $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( false === $data || '' === $data ) { + break; // connection closed + } + + try { + // Process the data + $response = $this->server->receive_bytes( $data ); + if ( $response ) { + socket_write( $client, $response ); + } + + // If there's still data in the buffer, process it immediately + while ( $this->server->has_buffered_data() ) { + try { + // Try to process more complete packets from the buffer + $response = $this->server->receive_bytes( '' ); + if ( $response ) { + socket_write( $client, $response ); + } + } catch ( IncompleteInputException $e ) { + // Not enough data to complete another packet, wait for more + break; + } + } + } catch ( IncompleteInputException $e ) { + // Not enough data yet, continue reading + continue; + } + } + + echo "Client disconnected, terminating the server.\n"; + $this->server->reset(); + } } -if(!function_exists('post_message_to_js')) { - function post_message_to_js(string $message) { +if ( ! function_exists( 'post_message_to_js' ) ) { + function post_message_to_js( string $message ) { echo 'The "post_message_to_js" function is only available in WordPress Playground but you are running it in a standalone PHP environment.' . PHP_EOL; echo 'The message was: ' . $message . PHP_EOL; } } class MySQLSocketServer { - private $query_handler; - private $socket; - private $port; - private $clients = []; - private $clientServers = []; - - public function __construct(MySQLQueryHandler $query_handler, $options = []) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - socket_bind($this->socket, '0.0.0.0', $this->port); - socket_listen($this->socket); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - while (true) { - // Prepare arrays for socket_select() - $read = array_merge([$this->socket], $this->clients); - $write = null; - $except = null; - - // Wait for activity on any socket - $select_result = socket_select($read, $write, $except, null); - if($select_result === false || $select_result <= 0) { + private $query_handler; + private $socket; + private $port; + private $clients = array(); + private $client_servers = array(); + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $this->socket, '0.0.0.0', $this->port ); + socket_listen( $this->socket ); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + while ( true ) { + // Prepare arrays for socket_select() + $read = array_merge( array( $this->socket ), $this->clients ); + $write = null; + $except = null; + + // Wait for activity on any socket + $select_result = socket_select( $read, $write, $except, null ); + if ( false === $select_result || $select_result <= 0 ) { continue; } // Check if there's a new connection - if (in_array($this->socket, $read)) { - $client = socket_accept($this->socket); - if ($client) { + if ( in_array( $this->socket, $read, true ) ) { + $client = socket_accept( $this->socket ); + if ( $client ) { echo "New client connected.\n"; - $this->clients[] = $client; - $clientId = spl_object_id($client); - $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); + $this->clients[] = $client; + $client_id = spl_object_id( $client ); + $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); // Send initial handshake - echo "Pre handshake\n"; - $handshake = $this->clientServers[$clientId]->getInitialHandshake(); - echo "Post handshake\n"; - socket_write($client, $handshake); + echo "Pre handshake\n"; + $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); + echo "Post handshake\n"; + socket_write( $client, $handshake ); } // Remove server socket from read array - unset($read[array_search($this->socket, $read)]); + unset( $read[ array_search( $this->socket, $read, true ) ] ); } // Handle client activity - echo "Waiting for client activity\n"; - foreach ($read as $client) { - echo "calling socket_read\n"; - $data = @socket_read($client, 4096); - echo "socket_read returned\n"; - $display = ''; - for ($i = 0; $i < strlen($data); $i++) { - $byte = ord($data[$i]); - if ($byte >= 32 && $byte <= 126) { - // Printable ASCII character - $display .= $data[$i]; - } else { - // Non-printable, show as hex - $display .= sprintf('%02x ', $byte); - } - } - echo rtrim($display) . "\n"; - - if ($data === false || $data === '') { + echo "Waiting for client activity\n"; + foreach ( $read as $client ) { + echo "calling socket_read\n"; + $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + echo "socket_read returned\n"; + $display = ''; + for ( $i = 0; $i < strlen( $data ); $i++ ) { + $byte = ord( $data[ $i ] ); + if ( $byte >= 32 && $byte <= 126 ) { + // Printable ASCII character + $display .= $data[ $i ]; + } else { + // Non-printable, show as hex + $display .= sprintf( '%02x ', $byte ); + } + } + echo rtrim( $display ) . "\n"; + + if ( false === $data || '' === $data ) { // Client disconnected echo "Client disconnected.\n"; - $clientId = spl_object_id($client); - $this->clientServers[$clientId]->reset(); - unset($this->clientServers[$clientId]); - socket_close($client); - unset($this->clients[array_search($client, $this->clients)]); + $client_id = spl_object_id( $client ); + $this->client_servers[ $client_id ]->reset(); + unset( $this->client_servers[ $client_id ] ); + socket_close( $client ); + unset( $this->clients[ array_search( $client, $this->clients, true ) ] ); continue; } try { // Process the data - $clientId = spl_object_id($client); - echo "Receiving bytes\n"; - $response = $this->clientServers[$clientId]->receiveBytes($data); - if ($response) { + $client_id = spl_object_id( $client ); + echo "Receiving bytes\n"; + $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); + if ( $response ) { echo "Writing response\n"; echo $response; - socket_write($client, $response); + socket_write( $client, $response ); } - echo "Response written\n"; + echo "Response written\n"; // Process any buffered data - while ($this->clientServers[$clientId]->hasBufferedData()) { - echo "Processing buffered data\n"; + while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { + echo "Processing buffered data\n"; try { - $response = $this->clientServers[$clientId]->receiveBytes(''); - if ($response) { - socket_write($client, $response); + $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); + if ( $response ) { + socket_write( $client, $response ); } - } catch (IncompleteInputException $e) { + } catch ( IncompleteInputException $e ) { break; } } - echo "After the while loop\n"; - } catch (IncompleteInputException $e) { - echo "Incomplete input exception\n"; + echo "After the while loop\n"; + } catch ( IncompleteInputException $e ) { + echo "Incomplete input exception\n"; continue; } } - echo "restarting the while() loop!\n"; - } - } + echo "restarting the while() loop!\n"; + } + } } class MySQLPlaygroundYieldServer { - private $query_handler; - private $clients = []; - private $clientServers = []; - private $port; - - public function __construct(MySQLQueryHandler $query_handler, $options = []) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; - - // Main event loop - while (true) { - // Wait for a message from JS - $message = post_message_to_js(json_encode([ - 'type' => 'ready_for_event' - ])); - - $command = json_decode($message, true); - var_dump('decoded event', $command); - if (!$command || !isset($command['type'])) { - continue; - } - - switch ($command['type']) { - case 'new_connection': - $this->handleNewConnection($command['clientId']); - break; - - case 'data_received': - $this->handleDataReceived($command['clientId'], $command['data']); - break; - - case 'client_disconnected': - $this->handleClientDisconnected($command['clientId']); - break; - } - } - } - - private function handleNewConnection($clientId) { - echo "New client connected (ID: $clientId).\n"; - $this->clients[] = $clientId; - $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); - - // Send initial handshake - $handshake = $this->clientServers[$clientId]->getInitialHandshake(); - $this->sendResponse($clientId, $handshake); - } - - private function handleDataReceived($clientId, $encodedData) { - if (!isset($this->clientServers[$clientId])) { - throw new IncompleteInputException('No client server found'); - return; - } - - $data = base64_decode($encodedData); - - try { - // Process the data - $response = $this->clientServers[$clientId]->receiveBytes($data); - if ($response) { - $this->sendResponse($clientId, $response); - } else { - throw new IncompleteInputException('No response from client'); - } - - // Process any buffered data - while ($this->clientServers[$clientId]->hasBufferedData()) { - try { - $response = $this->clientServers[$clientId]->receiveBytes(''); - if ($response) { - $this->sendResponse($clientId, $response); - } - } catch (IncompleteInputException $e) { + private $query_handler; + private $clients = array(); + private $client_servers = array(); + private $port; + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; + + // Main event loop + while ( true ) { + // Wait for a message from JS + $message = post_message_to_js( + json_encode( + array( + 'type' => 'ready_for_event', + ) + ) + ); + + $command = json_decode( $message, true ); + var_dump( 'decoded event', $command ); + if ( ! $command || ! isset( $command['type'] ) ) { + continue; + } + + switch ( $command['type'] ) { + case 'new_connection': + $this->handle_new_connection( $command['clientId'] ); + break; + + case 'data_received': + $this->handle_data_received( $command['clientId'], $command['data'] ); + break; + + case 'client_disconnected': + $this->handle_client_disconnected( $command['clientId'] ); + break; + } + } + } + + private function handle_new_connection( $client_id ) { + echo "New client connected (ID: $client_id).\n"; + $this->clients[] = $client_id; + $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); + + // Send initial handshake + $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); + $this->send_response( $client_id, $handshake ); + } + + private function handle_data_received( $client_id, $encoded_data ) { + if ( ! isset( $this->client_servers[ $client_id ] ) ) { + throw new IncompleteInputException( 'No client server found' ); + } + + $data = base64_decode( $encoded_data ); + + try { + // Process the data + $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); + if ( $response ) { + $this->send_response( $client_id, $response ); + } else { + throw new IncompleteInputException( 'No response from client' ); + } + + // Process any buffered data + while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { + try { + $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); + if ( $response ) { + $this->send_response( $client_id, $response ); + } + } catch ( IncompleteInputException $e ) { throw $e; - break; - } - } - } catch (IncompleteInputException $e) { - // Not enough data yet, wait for mo + } + } + } catch ( IncompleteInputException $e ) { + // Not enough data yet, wait for mo throw $e; - } - } - - private function handleClientDisconnected($clientId) { - echo "Client disconnected (ID: $clientId).\n"; - if (isset($this->clientServers[$clientId])) { - $this->clientServers[$clientId]->reset(); - unset($this->clientServers[$clientId]); - } - - $index = array_search($clientId, $this->clients); - if ($index !== false) { - unset($this->clients[$index]); - } - } - - private function sendResponse($clientId, $data) { - var_dump('sending response'); - $response = json_encode([ - 'type' => 'response_from_php', - 'clientId' => $clientId, - 'data' => base64_encode($data) - ]); - post_message_to_js($response); - } + } + } + + private function handle_client_disconnected( $client_id ) { + echo "Client disconnected (ID: $client_id).\n"; + if ( isset( $this->client_servers[ $client_id ] ) ) { + $this->client_servers[ $client_id ]->reset(); + unset( $this->client_servers[ $client_id ] ); + } + + $index = array_search( $client_id, $this->clients, true ); + if ( false !== $index ) { + unset( $this->clients[ $index ] ); + } + } + + private function send_response( $client_id, $data ) { + var_dump( 'sending response' ); + $response = json_encode( + array( + 'type' => 'response_from_php', + 'clientId' => $client_id, + 'data' => base64_encode( $data ), + ) + ); + post_message_to_js( $response ); + } } diff --git a/packages/wp-mysql-proxy/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php index 3680c699..1aa5acf3 100644 --- a/packages/wp-mysql-proxy/src/run-sqlite-translation.php +++ b/packages/wp-mysql-proxy/src/run-sqlite-translation.php @@ -9,10 +9,10 @@ require_once __DIR__ . '/mysql-server.php'; require_once __DIR__ . '/handler-sqlite-translation.php'; -define('WP_SQLITE_AST_DRIVER', true); +define( 'WP_SQLITE_AST_DRIVER', true ); $server = new MySQLSocketServer( - new SQLiteTranslationHandler(__DIR__ . '/database/test.db'), - ['port' => 3306] + new SQLiteTranslationHandler( __DIR__ . '/../database/test.db' ), + array( 'port' => 3306 ) ); $server->start(); From aaa498e59c60e16744bde81177d827af3a3f5a2b Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 16:02:27 +0200 Subject: [PATCH 7/7] Remove unused and Playground-specific code --- packages/wp-mysql-proxy/src/mysql-server.php | 193 ------------------- 1 file changed, 193 deletions(-) diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php index 3d021665..b2517033 100644 --- a/packages/wp-mysql-proxy/src/mysql-server.php +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -658,82 +658,6 @@ public function get_buffer_size(): int { } } -class SingleUseMySQLSocketServer { - private $server; - private $socket; - private $port; - - public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { - $this->server = new MySQLGateway( $query_handler ); - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); - socket_bind( $this->socket, '0.0.0.0', $this->port ); - socket_listen( $this->socket ); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - - // Accept a single client for simplicity - $client = socket_accept( $this->socket ); - if ( ! $client ) { - exit( "Failed to accept connection\n" ); - } - $this->handle_client( $client ); - socket_close( $client ); - socket_close( $this->socket ); - } - - private function handle_client( $client ) { - // Send initial handshake - $handshake = $this->server->get_initial_handshake(); - socket_write( $client, $handshake ); - - while ( true ) { - // Read available data (up to 4096 bytes at a time) - $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - if ( false === $data || '' === $data ) { - break; // connection closed - } - - try { - // Process the data - $response = $this->server->receive_bytes( $data ); - if ( $response ) { - socket_write( $client, $response ); - } - - // If there's still data in the buffer, process it immediately - while ( $this->server->has_buffered_data() ) { - try { - // Try to process more complete packets from the buffer - $response = $this->server->receive_bytes( '' ); - if ( $response ) { - socket_write( $client, $response ); - } - } catch ( IncompleteInputException $e ) { - // Not enough data to complete another packet, wait for more - break; - } - } - } catch ( IncompleteInputException $e ) { - // Not enough data yet, continue reading - continue; - } - } - - echo "Client disconnected, terminating the server.\n"; - $this->server->reset(); - } -} - -if ( ! function_exists( 'post_message_to_js' ) ) { - function post_message_to_js( string $message ) { - echo 'The "post_message_to_js" function is only available in WordPress Playground but you are running it in a standalone PHP environment.' . PHP_EOL; - echo 'The message was: ' . $message . PHP_EOL; - } -} - class MySQLSocketServer { private $query_handler; private $socket; @@ -846,120 +770,3 @@ public function start() { } } } - - -class MySQLPlaygroundYieldServer { - private $query_handler; - private $clients = array(); - private $client_servers = array(); - private $port; - - public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; - - // Main event loop - while ( true ) { - // Wait for a message from JS - $message = post_message_to_js( - json_encode( - array( - 'type' => 'ready_for_event', - ) - ) - ); - - $command = json_decode( $message, true ); - var_dump( 'decoded event', $command ); - if ( ! $command || ! isset( $command['type'] ) ) { - continue; - } - - switch ( $command['type'] ) { - case 'new_connection': - $this->handle_new_connection( $command['clientId'] ); - break; - - case 'data_received': - $this->handle_data_received( $command['clientId'], $command['data'] ); - break; - - case 'client_disconnected': - $this->handle_client_disconnected( $command['clientId'] ); - break; - } - } - } - - private function handle_new_connection( $client_id ) { - echo "New client connected (ID: $client_id).\n"; - $this->clients[] = $client_id; - $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); - - // Send initial handshake - $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); - $this->send_response( $client_id, $handshake ); - } - - private function handle_data_received( $client_id, $encoded_data ) { - if ( ! isset( $this->client_servers[ $client_id ] ) ) { - throw new IncompleteInputException( 'No client server found' ); - } - - $data = base64_decode( $encoded_data ); - - try { - // Process the data - $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); - if ( $response ) { - $this->send_response( $client_id, $response ); - } else { - throw new IncompleteInputException( 'No response from client' ); - } - - // Process any buffered data - while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { - try { - $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); - if ( $response ) { - $this->send_response( $client_id, $response ); - } - } catch ( IncompleteInputException $e ) { - throw $e; - } - } - } catch ( IncompleteInputException $e ) { - // Not enough data yet, wait for mo - throw $e; - } - } - - private function handle_client_disconnected( $client_id ) { - echo "Client disconnected (ID: $client_id).\n"; - if ( isset( $this->client_servers[ $client_id ] ) ) { - $this->client_servers[ $client_id ]->reset(); - unset( $this->client_servers[ $client_id ] ); - } - - $index = array_search( $client_id, $this->clients, true ); - if ( false !== $index ) { - unset( $this->clients[ $index ] ); - } - } - - private function send_response( $client_id, $data ) { - var_dump( 'sending response' ); - $response = json_encode( - array( - 'type' => 'response_from_php', - 'clientId' => $client_id, - 'data' => base64_encode( $data ), - ) - ); - post_message_to_js( $response ); - } -}