diff --git a/.gitattributes b/.gitattributes index ccd2fb12..693ea968 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ phpunit.xml.dist export-ignore wp-setup.sh export-ignore /.github export-ignore /grammar-tools export-ignore +/packages export-ignore /tests export-ignore /wp-includes/sqlite/class-wp-sqlite-crosscheck-db.php export-ignore /wordpress export-ignore diff --git a/.github/workflows/mysql-proxy-tests.yml b/.github/workflows/mysql-proxy-tests.yml new file mode 100644 index 00000000..c3808cf1 --- /dev/null +++ b/.github/workflows/mysql-proxy-tests.yml @@ -0,0 +1,33 @@ +name: MySQL Proxy Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: MySQL Proxy Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + ignore-cache: "yes" + composer-options: "--optimize-autoloader" + working-directory: packages/wp-mysql-proxy + + - name: Run MySQL Proxy tests + run: composer run test + working-directory: packages/wp-mysql-proxy diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 56248151..041341fb 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -17,7 +17,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest ] - php: [ '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ] + php: [ '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] with: os: ${{ matrix.os }} diff --git a/load.php b/load.php index 6a66f4b1..8ff220e0 100644 --- a/load.php +++ b/load.php @@ -3,7 +3,7 @@ * Plugin Name: SQLite Database Integration * Description: SQLite database driver drop-in. * Author: The WordPress Team - * Version: 2.2.14 + * Version: 2.2.15 * Requires PHP: 7.2 * Textdomain: sqlite-database-integration * diff --git a/packages/wp-mysql-proxy/README.md b/packages/wp-mysql-proxy/README.md new file mode 100644 index 00000000..b50f6a2c --- /dev/null +++ b/packages/wp-mysql-proxy/README.md @@ -0,0 +1,38 @@ +# WP MySQL Proxy +A MySQL proxy that bridges the MySQL wire protocol to a PDO-like interface. + +This is a zero-dependency, pure PHP implementation of a MySQL proxy that acts as +a MySQL server, accepts MySQL-native commands, and executes them using a configurable +PDO-like driver. This allows MySQL-compatible clients to connect and run queries +against alternative database backends over the MySQL wire protocol. + +Combined with the **WP SQLite Driver**, this allows MySQL-based projects to run +on SQLite. + +## Usage + +### CLI: + +```bash +$ php bin/wp-mysql-proxy.php [--port ] [--database ] [--log-level ] + +Options: + -h, --help Show this help message and exit. + -p, --port= The port to listen on. Default: 3306 + -d, --database= The path to the SQLite database file. Default: :memory: + -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info +``` + +### PHP: +```php +use WP_MySQL_Proxy\MySQL_Proxy; +use WP_MySQL_Proxy\Adapter\SQLite_Adapter; + +require_once __DIR__ . '/vendor/autoload.php'; + +$proxy = new MySQL_Proxy( + new SQLite_Adapter( $db_path ), + array( 'port' => $port, 'log_level' => $log_level ) +); +$proxy->start(); +``` diff --git a/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php new file mode 100644 index 00000000..d23984a6 --- /dev/null +++ b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php @@ -0,0 +1,58 @@ +] [--database ] [--log-level ] + +Options: + -h, --help Show this help message and exit. + -p, --port= The port to listen on. Default: 3306 + -d, --database= The path to the SQLite database file. Default: :memory: + -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info + +USAGE; + +// Help. +if ( isset( $opts['h'] ) || isset( $opts['help'] ) ) { + fwrite( STDERR, $help ); + exit( 0 ); +} + +// Database path. +$db_path = $opts['d'] ?? $opts['database'] ?? ':memory:'; + +// Port. +$port = (int) ( $opts['p'] ?? $opts['port'] ?? 3306 ); +if ( $port < 1 || $port > 65535 ) { + fwrite( STDERR, "Error: --port must be an integer between 1 and 65535. Use --help for more information.\n" ); + exit( 1 ); +} + +// Log level. +$log_level = $opts['l'] ?? $opts['log-level'] ?? 'info'; +if ( ! in_array( $log_level, Logger::LEVELS, true ) ) { + fwrite( STDERR, 'Error: --log-level must be one of: ' . implode( ', ', Logger::LEVELS ) . ". Use --help for more information.\n" ); + exit( 1 ); +} + +// Start the MySQL proxy. +$proxy = new MySQL_Proxy( + new SQLite_Adapter( $db_path ), + array( + 'port' => $port, + 'log_level' => $log_level, + ) +); +$proxy->start(); diff --git a/packages/wp-mysql-proxy/composer.json b/packages/wp-mysql-proxy/composer.json new file mode 100644 index 00000000..65079872 --- /dev/null +++ b/packages/wp-mysql-proxy/composer.json @@ -0,0 +1,22 @@ +{ + "name": "wordpress/wp-mysql-proxy", + "type": "library", + "bin": [ + "bin/wp-mysql-proxy.php" + ], + "scripts": { + "test": "phpunit" + }, + "require-dev": { + "phpunit/phpunit": "^8.5", + "symfony/process": "^5.4" + }, + "autoload": { + "classmap": [ + "src/" + ], + "files": [ + "../../php-polyfills.php" + ] + } +} diff --git a/packages/wp-mysql-proxy/phpunit.xml b/packages/wp-mysql-proxy/phpunit.xml new file mode 100644 index 00000000..d74e0e88 --- /dev/null +++ b/packages/wp-mysql-proxy/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests/ + + + diff --git a/packages/wp-mysql-proxy/src/Adapter/class-adapter.php b/packages/wp-mysql-proxy/src/Adapter/class-adapter.php new file mode 100644 index 00000000..d389fe46 --- /dev/null +++ b/packages/wp-mysql-proxy/src/Adapter/class-adapter.php @@ -0,0 +1,9 @@ +sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), + 'sqlite_database' + ); + } + + public function handle_query( string $query ): MySQL_Result { + $affected_rows = 0; + $last_insert_id = null; + $columns = array(); + $rows = array(); + + try { + $return_value = $this->sqlite_driver->query( $query ); + $last_insert_id = $this->sqlite_driver->get_insert_id() ?? null; + if ( is_numeric( $return_value ) ) { + $affected_rows = (int) $return_value; + } elseif ( is_array( $return_value ) ) { + $rows = $return_value; + } + if ( $this->sqlite_driver->get_last_column_count() > 0 ) { + $columns = $this->computeColumnInfo(); + } + return MySQL_Result::from_data( $affected_rows, $last_insert_id, $columns, $rows ?? array() ); + } catch ( Throwable $e ) { + $error_info = $e->errorInfo ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + if ( $e instanceof PDOException && $error_info ) { + return MySQL_Result::from_error( $error_info[0], $error_info[1], $error_info[2] ); + } + return MySQL_Result::from_error( 'HY000', 1105, $e->getMessage() ?? 'Unknown error' ); + } + } + + public function computeColumnInfo() { + $columns = array(); + + $column_meta = $this->sqlite_driver->get_last_column_meta(); + + $types = array( + 'DECIMAL' => MySQL_Protocol::FIELD_TYPE_DECIMAL, + 'TINY' => MySQL_Protocol::FIELD_TYPE_TINY, + 'SHORT' => MySQL_Protocol::FIELD_TYPE_SHORT, + 'LONG' => MySQL_Protocol::FIELD_TYPE_LONG, + 'FLOAT' => MySQL_Protocol::FIELD_TYPE_FLOAT, + 'DOUBLE' => MySQL_Protocol::FIELD_TYPE_DOUBLE, + 'NULL' => MySQL_Protocol::FIELD_TYPE_NULL, + 'TIMESTAMP' => MySQL_Protocol::FIELD_TYPE_TIMESTAMP, + 'LONGLONG' => MySQL_Protocol::FIELD_TYPE_LONGLONG, + 'INT24' => MySQL_Protocol::FIELD_TYPE_INT24, + 'DATE' => MySQL_Protocol::FIELD_TYPE_DATE, + 'TIME' => MySQL_Protocol::FIELD_TYPE_TIME, + 'DATETIME' => MySQL_Protocol::FIELD_TYPE_DATETIME, + 'YEAR' => MySQL_Protocol::FIELD_TYPE_YEAR, + 'NEWDATE' => MySQL_Protocol::FIELD_TYPE_NEWDATE, + 'VARCHAR' => MySQL_Protocol::FIELD_TYPE_VARCHAR, + 'BIT' => MySQL_Protocol::FIELD_TYPE_BIT, + 'NEWDECIMAL' => MySQL_Protocol::FIELD_TYPE_NEWDECIMAL, + 'ENUM' => MySQL_Protocol::FIELD_TYPE_ENUM, + 'SET' => MySQL_Protocol::FIELD_TYPE_SET, + 'TINY_BLOB' => MySQL_Protocol::FIELD_TYPE_TINY_BLOB, + 'MEDIUM_BLOB' => MySQL_Protocol::FIELD_TYPE_MEDIUM_BLOB, + 'LONG_BLOB' => MySQL_Protocol::FIELD_TYPE_LONG_BLOB, + 'BLOB' => MySQL_Protocol::FIELD_TYPE_BLOB, + 'VAR_STRING' => MySQL_Protocol::FIELD_TYPE_VAR_STRING, + 'STRING' => MySQL_Protocol::FIELD_TYPE_STRING, + 'GEOMETRY' => MySQL_Protocol::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[] = array( + 'name' => $column['name'], + 'length' => $column['len'], + 'type' => $type, + 'flags' => 129, + 'decimals' => $column['precision'], + ); + } + return $columns; + } +} diff --git a/packages/wp-mysql-proxy/src/class-logger.php b/packages/wp-mysql-proxy/src/class-logger.php new file mode 100644 index 00000000..e1e98126 --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-logger.php @@ -0,0 +1,143 @@ +set_log_level( $log_level ); + } + + /** + * Get the current log level. + * + * @return string + */ + public function get_log_level(): string { + return $this->log_level; + } + + /** + * Set the current log level. + * + * @param string $level The log level to use. + */ + public function set_log_level( string $level ): void { + if ( ! in_array( $level, self::LEVELS, true ) ) { + throw new InvalidArgumentException( 'Invalid log level: ' . $level ); + } + $this->log_level = $level; + } + + /** + * Check if a log level is enabled. + * + * @param string $level The log level to check. + * @return bool + */ + public function is_log_level_enabled( string $level ): bool { + $level_index = array_search( $level, self::LEVELS, true ); + if ( false === $level_index ) { + return false; + } + return $level_index <= array_search( $this->log_level, self::LEVELS, true ); + } + + /** + * Log a message. + * + * @param string $level The log level. + * @param string $message The message to log. + * @param array $context The context to log. + */ + public function log( string $level, string $message, array $context = array() ): void { + // Check log level. + if ( ! $this->is_log_level_enabled( $level ) ) { + return; + } + + // Handle PSR-3 placeholder syntax. + $replacements = array(); + foreach ( $context as $key => $value ) { + $replacements[ '{' . $key . '}' ] = $value; + } + $message = str_replace( array_keys( $replacements ), $replacements, $message ); + + // Format and log the message. + fprintf( STDERR, '%s [%s] %s' . PHP_EOL, gmdate( 'Y-m-d H:i:s' ), $level, $message ); + } + + /** + * Log an error message. + * + * @param string $message The message to log. + * @param array $context The context to log. + */ + public function error( string $message, array $context = array() ): void { + $this->log( self::LEVEL_ERROR, $message, $context ); + } + + /** + * Log a warning message. + * + * @param string $message The message to log. + * @param array $context The context to log. + */ + public function warning( string $message, array $context = array() ): void { + $this->log( self::LEVEL_WARNING, $message, $context ); + } + + /** + * Log an info message. + * + * @param string $message The message to log. + * @param array $context The context to log. + */ + public function info( string $message, array $context = array() ): void { + $this->log( self::LEVEL_INFO, $message, $context ); + } + + /** + * Log a debug message. + * + * @param string $message The message to log. + * @param array $context The context to log. + */ + public function debug( string $message, array $context = array() ): void { + $this->log( self::LEVEL_DEBUG, $message, $context ); + } +} diff --git a/packages/wp-mysql-proxy/src/class-mysql-protocol.php b/packages/wp-mysql-proxy/src/class-mysql-protocol.php new file mode 100644 index 00000000..8423394a --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-protocol.php @@ -0,0 +1,634 @@ += 8.0. + const AUTH_PLUGIN_SHA256_PASSWORD = 'sha256_password'; // [DEPRECATED] Basic SHA-256 authentication. + const AUTH_PLUGIN_NO_LOGIN = 'no_login_plugin'; // Disable client connection for specific accounts. + const AUTH_PLUGIN_SOCKET = 'auth_socket'; // Authenticate local Unix socket connections. + + // Auth specific markers for caching_sha2_password + const AUTH_MORE_DATA_HEADER = 0x01; // followed by 1 byte (caching_sha2_password specific) + const CACHING_SHA2_FAST_AUTH = 3; + const CACHING_SHA2_FULL_AUTH = 4; + + // Character set and collation constants + const CHARSET_UTF8MB4 = 0xff; + + // Max packet length constant + const MAX_PACKET_LENGTH = 0x00ffffff; + + /** + * Build the OK packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_ok_packet.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param int $server_status The status flags representing the server state. + * @param int $affected_rows Number of rows affected by the query. + * @param int $last_insert_id The last insert ID. + * @param int $warning_count The warning count. + * @param int $packet_header The packet header, indicating an OK or EOF semantic. + * @return string The OK packet. + */ + public static function build_ok_packet( + int $sequence_id, + int $server_status, + int $affected_rows = 0, + int $last_insert_id = 0, + int $warning_count = 0, + int $packet_header = self::OK_PACKET_HEADER + ): string { + /** + * Assemble the OK packet payload. + * + * Use a single pack() function call for maximum efficiency. + * + * C = 8-bit unsigned integer + * v = 16-bit unsigned integer (little-endian byte order) + * a* = string + * + * @see https://www.php.net/manual/en/function.pack.php + */ + $payload = pack( + 'Ca*a*vv', + $packet_header, // (C) OK packet header. + self::encode_length_encoded_int( $affected_rows ), // (a*) Affected rows. + self::encode_length_encoded_int( $last_insert_id ), // (a*) Last insert ID. + $server_status, // (v) Server status flags. + $warning_count, // (v) Server status flags. + ); + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build the OK packet with an EOF header. + * + * When the CLIENT_DEPRECATE_EOF capability is supported, an OK packet with + * an EOF header is used to mark EOF, instead of the deprecated EOF packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_ok_packet.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param int $server_status The status flags representing the server state. + * @param int $affected_rows Number of rows affected by the query. + * @param int $last_insert_id The last insert ID. + * @param int $warning_count The warning count. + * @return string The OK packet. + */ + public static function build_ok_packet_as_eof( + int $sequence_id, + int $server_status, + int $affected_rows = 0, + int $last_insert_id = 0, + int $warning_count = 0 + ): string { + return self::build_ok_packet( + $sequence_id, + $server_status, + $affected_rows, + $last_insert_id, + $warning_count, + self::EOF_PACKET_HEADER + ); + } + + /** + * Build the ERR packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_err_packet.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param int $error_code The error code. + * @param string $sql_state The SQLSTATE value. + * @param string $message The error message. + * @return string The ERR packet. + */ + public static function build_err_packet( + int $sequence_id, + int $error_code, + string $sql_state, + string $message + ): string { + /** + * Assemble the ERR packet payload. + * + * Use a single pack() function call for maximum efficiency. + * + * C = 8-bit unsigned integer + * v = 16-bit unsigned integer (little-endian byte order) + * a* = string + * + * @see https://www.php.net/manual/en/function.pack.php + */ + $payload = pack( + 'Cva*a*', + self::ERR_PACKET_HEADER, // (C) ERR packet header. + $error_code, // (v) Error code. + '#' . strtoupper( $sql_state ), // (a*) SQL state. + $message, // (a*) Message. + ); + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build the EOF packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_eof_packet.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param int $server_status The status flags representing the server state. + * @param int $warning_count The warning count. + * @return string The EOF packet. + */ + public static function build_eof_packet( + int $sequence_id, + int $server_status, + int $warning_count = 0 + ): string { + /** + * Assemble the EOF packet payload. + * + * Use a single pack() function call for maximum efficiency. + * + * C = 8-bit unsigned integer + * v = 16-bit unsigned integer (little-endian byte order) + * a* = string + * + * @see https://www.php.net/manual/en/function.pack.php + */ + $payload = pack( + 'Cvv', + self::EOF_PACKET_HEADER, // (C) EOF packet header. + $warning_count, // (v) Warning count. + $server_status, // (v) Status flags. + ); + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build a handshake packet for the initial handshake. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_handshake_v10.html + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html#sect_protocol_connection_phase_initial_handshake + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param string $server_version The version of the MySQL server. + * @param int $charset The character set that is used by the server. + * @param int $connection_id The connection ID. + * @param string $auth_plugin_data The authentication plugin data (scramble). + * @param int $capabilities The capabilities that are supported by the server. + * @param int $status_flags The status flags representing the server state. + * @return string The handshake packet. + */ + public static function build_handshake_packet( + int $sequence_id, + string $server_version, + int $charset, + int $connection_id, + string $auth_plugin_data, + int $capabilities, + int $status_flags + ): string { + $cap_flags_lower = $capabilities & 0xffff; + $cap_flags_upper = $capabilities >> 16; + $scramble1 = substr( $auth_plugin_data, 0, 8 ); + $scramble2 = substr( $auth_plugin_data, 8 ); + + if ( $capabilities & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_data_length = strlen( $auth_plugin_data ) + 1; + $auth_plugin_name = self::DEFAULT_AUTH_PLUGIN . "\0"; + } else { + $auth_plugin_data_length = 0; + $auth_plugin_name = ''; + } + + /** + * Assemble the handshake packet payload. + * + * Use a single pack() function call for maximum efficiency. + * + * C = 8-bit unsigned integer + * v = 16-bit unsigned integer (little-endian byte order) + * V = 32-bit unsigned integer (little-endian byte order) + * a* = string + * Z* = NULL-terminated string + * + * @see https://www.php.net/manual/en/function.pack.php + */ + $payload = pack( + 'CZ*Va*CvCvvCa*a*Ca*', + self::PROTOCOL_VERSION, // (C) Protocol version. + $server_version, // (Z*) MySQL server version. + $connection_id, // (V) Connection ID. + $scramble1, // (a*) First 8 bytes of auth plugin data (scramble). + 0, // (C) Filler. Always 0x00. + $cap_flags_lower, // (v) Lower 2 bytes of capability flags. + $charset, // (C) Default server character set. + $status_flags, // (v) Server status flags. + $cap_flags_upper, // (v) Upper 2 bytes of capability flags. + $auth_plugin_data_length, // (C) Auth plugin data length. + str_repeat( "\0", 10 ), // (a*) Filler. 10 bytes of 0x00. + $scramble2, // (a*) Remainder of auth plugin data (scramble). + 0, // (C) Filler. Always 0x00. + $auth_plugin_name, // (a*) Auth plugin name. + ); + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build the column count packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param int $column_count The number of columns. + * @return string The column count packet. + */ + public static function build_column_count_packet( int $sequence_id, int $column_count ): string { + $payload = self::encode_length_encoded_int( $column_count ); + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build the column definition packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset_column_definition.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param array $column The column definition. + * @return string The column definition packet. + */ + public static function build_column_definition_packet( int $sequence_id, array $column ): string { + $payload = pack( + 'a*a*a*a*a*a*a*vVCvCC', + self::encode_length_encoded_string( $column['catalog'] ?? 'def' ), + self::encode_length_encoded_string( $column['schema'] ?? '' ), + self::encode_length_encoded_string( $column['table'] ?? '' ), + self::encode_length_encoded_string( $column['orgTable'] ?? '' ), + self::encode_length_encoded_string( $column['name'] ?? '' ), + self::encode_length_encoded_string( $column['orgName'] ?? '' ), + self::encode_length_encoded_int( $column['fixedLen'] ?? 0x0c ), + $column['charset'] ?? MySQL_Protocol::CHARSET_UTF8MB4, // (v) Character set. + $column['length'], // (V) Length. + $column['type'], // (C) Type. + $column['flags'], // (v) Flags. + $column['decimals'], // (C) Decimals. + 0, // (C) Filler. Always 0x00. + ); + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build the row packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset_row.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param array $columns The columns. + * @param object $row The row. + * @return string The row packet. + */ + public static function build_row_packet( int $sequence_id, array $columns, object $row ): string { + $payload = ''; + foreach ( $columns as $column ) { + $value = $row->{$column['name']} ?? null; + if ( null === $value ) { + $payload .= "\xfb"; // NULL value + } else { + $payload .= self::encode_length_encoded_string( (string) $value ); + } + } + return self::build_packet( $sequence_id, $payload ); + } + + /** + * Build a MySQL packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_packets.html + * + * @param int $sequence_id The sequence ID of the packet. + * @param string $payload The payload of the packet. + * @return string The packet data. + */ + public static function build_packet( int $sequence_id, string $payload ): string { + /** + * Assemble the packet. + * + * Use a single pack() function call for maximum efficiency. + * + * C = 8-bit unsigned integer + * VX = 24-bit unsigned integer (little-endian byte order) + * (V = 32-bit little-endian, X drops the last byte, making it 24-bit) + * a* = string + */ + return pack( + 'VXCa*', + strlen( $payload ), // (VX) Payload length. + $sequence_id, // (C) Sequence ID. + $payload, // (a*) Payload. + ); + } + + /** + * Encode an integer in MySQL's length-encoded format. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_integers.html + * + * @param int $value The value to encode. + * @return string The encoded value. + */ + public static function encode_length_encoded_int( int $value ): string { + if ( $value < 0xfb ) { + return chr( $value ); + } elseif ( $value <= 0xffff ) { + return "\xfc" . pack( 'v', $value ); + } elseif ( $value <= 0xffffff ) { + return "\xfd" . pack( 'VX', $value ); + } else { + return "\xfe" . pack( 'P', $value ); + } + } + + /** + * Encode a string in MySQL's length-encoded format. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_strings.html + * + * @param string $value The value to encode. + * @return string The encoded value. + */ + public static function encode_length_encoded_string( string $value ): string { + return self::encode_length_encoded_int( strlen( $value ) ) . $value; + } + + /** + * Read MySQL length-encoded integer from a payload and advance the offset. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_integers.html + * + * @param string $payload A payload of bytes to read from. + * @param int $offset And offset to start reading from within the payload. + * The value will be advanced by the number of bytes read. + * @return int The decoded integer value. + */ + public static function read_length_encoded_int( string $payload, int &$offset ): int { + $first_byte = ord( $payload[ $offset ] ?? "\0" ); + $offset += 1; + + if ( $first_byte < 0xfb ) { + $value = $first_byte; + } elseif ( 0xfb === $first_byte ) { + $value = 0; + } elseif ( 0xfc === $first_byte ) { + $value = unpack( 'v', $payload, $offset )[1]; + $offset += 2; + } elseif ( 0xfd === $first_byte ) { + $value = unpack( 'VX', $payload, $offset )[1]; + $offset += 3; + } else { + $value = unpack( 'P', $payload, $offset )[1]; + $offset += 8; + } + return $value; + } + + /** + * Read MySQL length-encoded string from a payload and advance the offset. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_strings.html + * + * @param string $payload A payload of bytes to read from. + * @param int $offset And offset to start reading from within the payload. + * The value will be advanced by the number of bytes read. + * @return string The decoded string value. + */ + public static function read_length_encoded_string( string $payload, int &$offset ): string { + $length = self::read_length_encoded_int( $payload, $offset ); + $value = substr( $payload, $offset, $length ); + $offset += $length; + return $value; + } + + /** + * Read MySQL null-terminated string from a payload and advance the offset. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_strings.html + * + * @param string $payload A payload of bytes to read from. + * @param int $offset And offset to start reading from within the payload. + * The value will be advanced by the number of bytes read. + * @return string The decoded string value. + */ + public static function read_null_terminated_string( string $payload, int &$offset ): string { + $value = unpack( 'Z*', $payload, $offset )[1]; + $offset += strlen( $value ) + 1; + return $value; + } +} diff --git a/packages/wp-mysql-proxy/src/class-mysql-proxy.php b/packages/wp-mysql-proxy/src/class-mysql-proxy.php new file mode 100644 index 00000000..dc2ba606 --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-proxy.php @@ -0,0 +1,294 @@ + + */ + private $clients = array(); + + /** + * Logger instance. + * + * @var Logger + */ + private $logger; + + /** + * Constructor. + * + * @param Adapter $adapter The adapter to use to execute MySQL queries. + * @param array $options { + * Optional. An associative array of options. Default empty array. + * + * @type int $port The port to listen on. Default: 3306 + * @type string $log_level The log level to use. One of 'error', 'warning', 'info', 'debug'. + * Default: 'warning' + * } + */ + public function __construct( Adapter $adapter, $options = array() ) { + $this->adapter = $adapter; + $this->port = $options['port'] ?? 3306; + $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); + } + + /** + * Start the MySQL proxy. + * + * This method creates a socket, binds it to a port, and handles connections. + */ + public function start(): void { + // Create a socket. + $socket = @socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + if ( false === $socket ) { + throw $this->new_socket_error(); + } + $this->socket = $socket; + + // Set socket options. + if ( false === @socket_set_option( $this->socket, SOL_SOCKET, SO_REUSEADDR, 1 ) ) { + throw $this->new_socket_error(); + } + + // Bind the socket to a port. + if ( false === @socket_bind( $this->socket, '0.0.0.0', $this->port ) ) { + throw $this->new_socket_error(); + } + + // Listen for connections. + if ( false === @socket_listen( $this->socket ) ) { + throw $this->new_socket_error(); + } + + $this->logger->info( 'MySQL proxy listening on port {port}', array( 'port' => $this->port ) ); + + // Start the main proxy loop. + while ( true ) { + try { + // Wait for activity on any socket. + $read = array_merge( array( $this->socket ), array_column( $this->clients, 'socket' ) ); + $write = null; + $except = null; + $activity = @socket_select( $read, $write, $except, null ); + if ( false === $activity ) { + throw $this->new_socket_error(); + } + + // No activity on any socket. + if ( $activity <= 0 ) { + continue; + } + + // New client connection. + if ( in_array( $this->socket, $read, true ) ) { + $this->handle_new_client(); + unset( $read[ array_search( $this->socket, $read, true ) ] ); + } + + // Handle client activity. + foreach ( $read as $socket ) { + $this->handle_client_activity( $this->get_client_id( $socket ) ); + } + } catch ( Throwable $e ) { + $this->logger->error( $e->getMessage() ); + } + } + } + + /** + * Handle a new MySQL client connection. + */ + private function handle_new_client(): void { + $this->logger->info( 'Connecting a new client' ); + + // Accept the new client connection. + $socket = @socket_accept( $this->socket ); + if ( false === $socket ) { + throw $this->new_socket_error(); + } + + // Create a new session for the client. + $client_id = $this->get_client_id( $socket ); + $session = new MySQL_Session( $this->adapter, $client_id ); + $this->clients[ $client_id ] = array( + 'socket' => $socket, + 'session' => $session, + ); + $this->logger->info( 'Client [{client_id}]: connected', array( 'client_id' => $client_id ) ); + + // Handle the initial handshake. + $this->logger->info( 'Client [{client_id}]: initial handshake', array( 'client_id' => $client_id ) ); + $handshake = $session->get_initial_handshake(); + if ( false === @socket_write( $socket, $handshake ) ) { + throw $this->new_socket_error(); + } + } + + /** + * Handle client activity. + * + * @param int $client_id The numeric ID of the client. + */ + private function handle_client_activity( int $client_id ): void { + $this->logger->info( 'Client [{client_id}]: reading data from client', array( 'client_id' => $client_id ) ); + + // Read data from the client. + $socket = $this->clients[ $client_id ]['socket']; + $data = @socket_read( $socket, 4096 ); + if ( false === $data ) { + throw $this->new_socket_error(); + } + + // When debugging, display the data in a readable format. + if ( $this->logger->is_log_level_enabled( Logger::LEVEL_DEBUG ) ) { + $this->logger->debug( + 'Client [{client_id}] request data: {data}', + array( + 'client_id' => $client_id, + 'data' => $this->format_data( $data ), + ) + ); + } + + // Handle client disconnection. + if ( false === $data || '' === $data ) { + $this->logger->info( 'Client [{client_id}]: disconnected', array( 'client_id' => $client_id ) ); + unset( $this->clients[ $client_id ] ); + @socket_close( $socket ); + return; + } + + // Process client data. + $this->logger->info( 'Client [{client_id}]: processing data', array( 'client_id' => $client_id ) ); + $session = $this->clients[ $client_id ]['session']; + $response = $session->receive_bytes( $data ); + if ( $response ) { + $this->logger->info( 'Client [{client_id}]: writing response', array( 'client_id' => $client_id ) ); + if ( $this->logger->is_log_level_enabled( Logger::LEVEL_DEBUG ) ) { + $this->logger->debug( + 'Client [{client_id}] response data: {response}', + array( + 'client_id' => $client_id, + 'response' => $this->format_data( $response ), + ) + ); + } + if ( false === @socket_write( $socket, $response ) ) { + throw $this->new_socket_error(); + } + } + + // Process buffered data. + while ( $session->has_buffered_data() ) { + $this->logger->info( 'Client [{client_id}]: processing buffered data', array( 'client_id' => $client_id ) ); + try { + $response = $session->receive_bytes( '' ); + if ( $response ) { + $this->logger->info( 'Client [{client_id}]: writing response', array( 'client_id' => $client_id ) ); + if ( $this->logger->is_log_level_enabled( Logger::LEVEL_DEBUG ) ) { + $this->logger->debug( + 'Client [{client_id}] response data: {response}', + array( + 'client_id' => $client_id, + 'response' => $this->format_data( $response ), + ) + ); + if ( false === @socket_write( $socket, $response ) ) { + throw $this->new_socket_error(); + } + } + } + } catch ( Incomplete_Input_Exception $e ) { + break; + } + } + } + + /** + * Get a numeric ID for a client connected to the proxy. + * + * @param resource|object $socket The client Socket object or resource. + * @return int The numeric ID of the client. + */ + private function get_client_id( $socket ): int { + if ( is_resource( $socket ) ) { + return get_resource_id( $socket ); + } else { + return spl_object_id( $socket ); + } + } + + /** + * Create a new MySQL proxy exception for the last socket error. + * + * @return MySQL_Proxy_Exception + */ + private function new_socket_error(): MySQL_Proxy_Exception { + $error_code = socket_last_error(); + $error_message = socket_strerror( $error_code ); + @socket_clear_error(); + return new MySQL_Proxy_Exception( sprintf( 'Socket error: %s', $error_message ) ); + } + + /** + * Format MySQL protocol data for display in debug logs. + * + * @param string $data The binary data to format. + * @return string The formatted data. + */ + private function format_data( string $data ): string { + $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 ); + } + } + return $display; + } +} diff --git a/packages/wp-mysql-proxy/src/class-mysql-result.php b/packages/wp-mysql-proxy/src/class-mysql-result.php new file mode 100644 index 00000000..181ec1c9 --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-result.php @@ -0,0 +1,27 @@ +affected_rows = $affected_rows; + $result->last_insert_id = $last_insert_id; + $result->columns = $columns; + $result->rows = $rows; + return $result; + } + + public static function from_error( string $sql_state, int $code, string $message ): self { + $result = new self(); + $result->error_info = array( $sql_state, $code, $message ); + return $result; + } +} diff --git a/packages/wp-mysql-proxy/src/class-mysql-session.php b/packages/wp-mysql-proxy/src/class-mysql-session.php new file mode 100644 index 00000000..70d5a18b --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -0,0 +1,401 @@ +adapter = $adapter; + $this->connection_id = $connection_id; + $this->auth_plugin_data = ''; + $this->packet_id = 0; + + // Generate random auth plugin data (20-byte salt) + $this->auth_plugin_data = random_bytes( 20 ); + } + + /** + * 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 strlen( $this->buffer ) > 0; + } + + /** + * 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 ); + } + + /** + * Get the initial handshake packet to send to the client. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html#sect_protocol_connection_phase_initial_handshake + * + * @return string The initial handshake packet. + */ + public function get_initial_handshake(): string { + return MySQL_Protocol::build_handshake_packet( + 0, + $this->server_version, + $this->character_set, + $this->connection_id, + $this->auth_plugin_data, + self::CAPABILITIES, + $this->status_flags + ); + } + + /** + * 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 Incomplete_Input_Exception When more data is needed to complete a packet. + */ + public function receive_bytes( string $data ): ?string { + // Append new data to the existing buffer. + $this->buffer .= $data; + + // Check if we have enough data for a packet header. + if ( strlen( $this->buffer ) < 4 ) { + throw new Incomplete_Input_Exception( 'Incomplete packet header, need more bytes' ); + } + + // Parse packet header. + $payload_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; + $received_sequence_id = ord( $this->buffer[3] ); + $this->packet_id = $received_sequence_id + 1; + + // Check if we have the complete packet. + $packet_length = 4 + $payload_length; + if ( strlen( $this->buffer ) < $packet_length ) { + throw new Incomplete_Input_Exception( + sprintf( + 'Incomplete packet payload, have %d bytes, but need %d bytes', + strlen( $this->buffer ), + $packet_length + ) + ); + } + + // Extract the packet payload. + $payload = substr( $this->buffer, 4, $payload_length ); + + // Remove the whole packet from the buffer. + $this->buffer = substr( $this->buffer, $packet_length ); + + /* + * Process the packet. + * + * Depending on the lifecycle phase, handle authentication or a command. + * + * @see: https://dev.mysql.com/doc/dev/mysql-server/9.5.0/page_protocol_connection_lifecycle.html + */ + + // Authentication phase. + if ( ! $this->is_authenticated ) { + return $this->process_authentication( $payload ); + } + + // Command phase. + $command = ord( $payload[0] ); + switch ( $command ) { + case MySQL_Protocol::COM_QUIT: + return ''; + case MySQL_Protocol::COM_INIT_DB: + return $this->process_query( 'USE ' . substr( $payload, 1 ) ); + case MySQL_Protocol::COM_QUERY: + return $this->process_query( substr( $payload, 1 ) ); + case MySQL_Protocol::COM_PING: + return MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + default: + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 0x04D2, + 'HY000', + sprintf( 'Unsupported command: %d', $command ) + ); + } + } + + /** + * Process authentication payload from the client. + * + * @param string $payload The authentication payload. + * @return string The authentication response packet. + */ + private function process_authentication( string $payload ): string { + $payload_length = strlen( $payload ); + + // Decode the first 5 fields. + $data = unpack( + 'Vclient_flags/Vmax_packet_size/Ccharacter_set/x23filler/Z*username', + $payload + ); + + // Calculate the offset of the authentication response. + $offset = 32 + strlen( $data['username'] ) + 1; + + $client_flags = $data['client_flags']; + $this->client_capabilities = $client_flags; + + // Decode the authentication response. + $auth_response = ''; + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response = MySQL_Protocol::read_length_encoded_string( $payload, $offset ); + } else { + $length = ord( $payload[ $offset++ ] ); + $auth_response = substr( $payload, $offset, $length ); + $offset += $length; + } + + // Get the database name. + if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { + $database = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + if ( '' !== $database ) { + $result = $this->adapter->handle_query( 'USE ' . $database ); + if ( $result->error_info ) { + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1049, + '42000', + sprintf( "Unknown database: '%s'", $database ) + ); + } + } + } + + // Get the authentication plugin name. + $auth_plugin_name = ''; + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_name = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + } + + // Get the connection attributes. + if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_ATTRS ) { + $attrs_length = MySQL_Protocol::read_length_encoded_int( $payload, $offset ); + $offset = min( $payload_length, $offset + $attrs_length ); + // TODO: Process connection attributes. + } + + /** + * Authentication flow. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.6/page_caching_sha2_authentication_exchanges.html + */ + if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $auth_plugin_name ) { + // TODO: Implement authentication. + $this->is_authenticated = true; + if ( "\0" === $auth_response || '' === $auth_response ) { + /* + * Fast path for empty password. + * + * With the "caching_sha2_password" and "sha256_password" plugins, + * an empty password is represented as a single "\0" character. + * + * @see https://github.com/mysql/mysql-server/blob/aa461240270d809bcac336483b886b3d1789d4d9/sql/auth/sha2_password.cc#L1017-L1022 + */ + return MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + } + $fast_auth_payload = pack( 'CC', MySQL_Protocol::AUTH_MORE_DATA_HEADER, MySQL_Protocol::CACHING_SHA2_FAST_AUTH ); + $fast_auth_packet = MySQL_Protocol::build_packet( $this->packet_id++, $fast_auth_payload ); + return $fast_auth_packet . MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + } elseif ( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $auth_plugin_name ) { + // TODO: Implement authentication. + $this->is_authenticated = true; + return MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + } + + // Unsupported authentication plugin. + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 0x04D2, + 'HY000', + 'Unsupported authentication plugin: ' . $auth_plugin_name + ); + } + + /** + * Process a MySQL query from the client. + * + * @param string $query The query to process. + * @return string The query response packet. + */ + private function process_query( string $query ): string { + $query = trim( $query ); + + try { + $result = $this->adapter->handle_query( $query ); + if ( $result->error_info ) { + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + $result->error_info[1], + $result->error_info[0], + $result->error_info[2] + ); + } + + if ( count( $result->columns ) > 0 ) { + return $this->build_result_set_packets( + $result->columns, + $result->rows, + $result->affected_rows, + $result->last_insert_id + ); + } + + return MySQL_Protocol::build_ok_packet( + $this->packet_id++, + $this->status_flags, + $result->affected_rows, + $result->last_insert_id + ); + } catch ( Throwable $e ) { + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 0, + 'HY000', + 'Unknown error: ' . $e->getMessage() + ); + } + } + + /** + * Build the result set packets for a MySQL query. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset.html + * + * @param array $columns The columns of the result set. + * @param array $rows The rows of the result set. + * @return string The result set packets. + */ + private function build_result_set_packets( array $columns, array $rows, int $affected_rows, int $last_insert_id ): string { + // Columns. + $packets = MySQL_Protocol::build_column_count_packet( $this->packet_id++, count( $columns ) ); + foreach ( $columns as $column ) { + $packets .= MySQL_Protocol::build_column_definition_packet( $this->packet_id++, $column ); + } + + // EOF packet, if CLIENT_DEPRECATE_EOF is not supported. + if ( ! ( $this->client_capabilities & MySQL_Protocol::CLIENT_DEPRECATE_EOF ) ) { + $packets .= MySQL_Protocol::build_eof_packet( $this->packet_id++, $this->status_flags ); + } + + // Rows. + foreach ( $rows as $row ) { + $packets .= MySQL_Protocol::build_row_packet( $this->packet_id++, $columns, $row ); + } + + // OK or EOF packet, based on the CLIENT_DEPRECATE_EOF capability. + if ( $this->client_capabilities & MySQL_Protocol::CLIENT_DEPRECATE_EOF ) { + $packets .= MySQL_Protocol::build_ok_packet_as_eof( + $this->packet_id++, + $this->status_flags, + $affected_rows, + $last_insert_id + ); + } else { + $packets .= MySQL_Protocol::build_eof_packet( $this->packet_id++, $this->status_flags ); + } + return $packets; + } +} diff --git a/packages/wp-mysql-proxy/src/exceptions.php b/packages/wp-mysql-proxy/src/exceptions.php new file mode 100644 index 00000000..7220bf47 --- /dev/null +++ b/packages/wp-mysql-proxy/src/exceptions.php @@ -0,0 +1,16 @@ +port} -u root -e 'SELECT 123'" + ); + $process->run(); + + $this->assertEquals( 0, $process->getExitCode() ); + $this->assertStringContainsString( '123', $process->getOutput() ); + } + + public function test_auth_with_password(): void { + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e 'SELECT 123'" + ); + $process->run(); + + $this->assertEquals( 0, $process->getExitCode() ); + $this->assertStringContainsString( '123', $process->getOutput() ); + } + + public function test_auth_with_database(): void { + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -D sqlite_database -e 'SELECT 123'" + ); + $process->run(); + + $this->assertEquals( 0, $process->getExitCode() ); + $this->assertStringContainsString( '123', $process->getOutput() ); + } + + + public function test_auth_with_unknown_database(): void { + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -D unknown_database -e 'SELECT 123'" + ); + $process->run(); + + $this->assertEquals( 1, $process->getExitCode() ); + $this->assertStringContainsString( "Unknown database: 'unknown_database'", $process->getErrorOutput() ); + } + + public function test_query(): void { + $query = 'CREATE TABLE t (id INT PRIMARY KEY, name TEXT)'; + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e " . escapeshellarg( $query ) + ); + $process->run(); + $this->assertEquals( 0, $process->getExitCode() ); + + $query = 'INSERT INTO t (id, name) VALUES (123, "abc"), (456, "def")'; + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e " . escapeshellarg( $query ) + ); + $process->run(); + $this->assertEquals( 0, $process->getExitCode() ); + + $query = 'SELECT * FROM t'; + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e " . escapeshellarg( $query ) + ); + $process->run(); + $this->assertEquals( 0, $process->getExitCode() ); + $this->assertSame( + "id\tname\n123\tabc\n456\tdef\n", + $process->getOutput() + ); + } +} diff --git a/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php new file mode 100644 index 00000000..3d476534 --- /dev/null +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php @@ -0,0 +1,24 @@ +mysqli = new mysqli( '127.0.0.1', 'user', 'password', 'sqlite_database', $this->port ); + } + + public function test_query(): void { + $result = $this->mysqli->query( 'CREATE TABLE t (id INT PRIMARY KEY, name TEXT)' ); + $this->assertTrue( $result ); + + $result = $this->mysqli->query( 'INSERT INTO t (id, name) VALUES (123, "abc"), (456, "def")' ); + $this->assertEquals( 2, $result ); + } + + public function test_prepared_statement(): void { + // TODO: Implement prepared statements in the MySQL proxy. + $this->markTestSkipped( 'Prepared statements are not supported yet.' ); + } +} diff --git a/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php new file mode 100644 index 00000000..561121ab --- /dev/null +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php @@ -0,0 +1,66 @@ += 80400 ? PDO\SQLite::class : PDO::class; + $this->pdo = new $pdo_class( + sprintf( 'mysql:host=127.0.0.1;port=%d', $this->port ), + 'user', + 'password' + ); + } + + public function test_exec(): void { + $result = $this->pdo->exec( 'CREATE TABLE t (id INT PRIMARY KEY, name TEXT)' ); + $this->assertEquals( 0, $result ); + + $result = $this->pdo->exec( 'INSERT INTO t (id, name) VALUES (123, "abc"), (456, "def")' ); + $this->assertEquals( 2, $result ); + } + + public function test_query(): void { + $this->pdo->exec( 'CREATE TABLE t (id INT PRIMARY KEY, name TEXT)' ); + $this->pdo->exec( 'INSERT INTO t (id, name) VALUES (123, "abc"), (456, "def")' ); + + $result = $this->pdo->query( "SELECT 'test'" ); + $this->assertEquals( 'test', $result->fetchColumn() ); + + $result = $this->pdo->query( 'SELECT * FROM t' ); + $this->assertEquals( 2, $result->rowCount() ); + $this->assertEquals( + array( + array( + 'id' => 123, + 'name' => 'abc', + ), + array( + 'id' => 456, + 'name' => 'def', + ), + ), + $result->fetchAll( PDO::FETCH_ASSOC ) + ); + } + + public function test_prepared_statement(): void { + $this->pdo->exec( 'CREATE TABLE t (id INT PRIMARY KEY, name TEXT)' ); + $this->pdo->exec( 'INSERT INTO t (id, name) VALUES (123, "abc"), (456, "def")' ); + + $stmt = $this->pdo->prepare( 'SELECT * FROM t WHERE id = ?' ); + $stmt->execute( array( 123 ) ); + $this->assertEquals( + array( + array( + 'id' => 123, + 'name' => 'abc', + ), + ), + $stmt->fetchAll( PDO::FETCH_ASSOC ) + ); + } +} diff --git a/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_Test.php b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_Test.php new file mode 100644 index 00000000..b4504a03 --- /dev/null +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -0,0 +1,35 @@ +server = new MySQL_Server_Process( + array( + 'port' => $this->port, + 'db_path' => ':memory:', + ) + ); + } + + public function tearDown(): void { + $this->server->stop(); + $exit_code = $this->server->get_exit_code(); + if ( $this->hasFailed() || ( $exit_code > 0 && 143 !== $exit_code ) ) { + $hr = str_repeat( '-', 80 ); + fprintf( + STDERR, + "\n\n$hr\nSERVER OUTPUT:\n$hr\n[RETURN CODE]: %d\n\n[STDOUT]:\n%s\n\n[STDERR]:\n%s\n$hr\n", + $this->server->get_exit_code(), + $this->server->get_stdout(), + $this->server->get_stderr() + ); + } + } +} diff --git a/packages/wp-mysql-proxy/tests/bootstrap/bootstrap.php b/packages/wp-mysql-proxy/tests/bootstrap/bootstrap.php new file mode 100644 index 00000000..3f517288 --- /dev/null +++ b/packages/wp-mysql-proxy/tests/bootstrap/bootstrap.php @@ -0,0 +1,5 @@ + $port, + 'DB_PATH' => $options['db_path'] ?? ':memory:', + ) + ); + $this->process = new Process( + array( PHP_BINARY, __DIR__ . '/run-server.php' ), + null, + $env + ); + $this->process->start(); + + // Wait for the server to be ready. + for ( $i = 0; $i < 20; $i++ ) { + $connection = @fsockopen( '127.0.0.1', $port ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( $connection ) { + fclose( $connection ); + return; + } + usleep( 100000 ); + } + + // Connection timed out. + $this->stop(); + $error = $this->process->getErrorOutput(); + throw new Exception( + sprintf( 'Server failed to start on port %d: %s', $port, $error ) + ); + } + + public function stop(): void { + if ( isset( $this->process ) ) { + $this->process->stop(); + } + } + + public function get_exit_code(): ?int { + if ( ! isset( $this->process ) ) { + return null; + } + return $this->process->getExitCode() ?? null; + } + + public function get_stdout(): string { + if ( ! isset( $this->process ) ) { + return ''; + } + return $this->process->getOutput(); + } + + public function get_stderr(): string { + if ( ! isset( $this->process ) ) { + return ''; + } + return $this->process->getErrorOutput(); + } +} diff --git a/packages/wp-mysql-proxy/tests/bootstrap/run-server.php b/packages/wp-mysql-proxy/tests/bootstrap/run-server.php new file mode 100644 index 00000000..6b58e2ab --- /dev/null +++ b/packages/wp-mysql-proxy/tests/bootstrap/run-server.php @@ -0,0 +1,17 @@ + $port ) +); +$proxy->start(); diff --git a/readme.txt b/readme.txt index 7f03021b..48217744 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Contributors: wordpressdotorg, aristath, janjakes, zieladam, berislav.grgic Requires at least: 6.4 Tested up to: 6.6.1 Requires PHP: 7.2 -Stable tag: 2.2.14 +Stable tag: 2.2.15 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, database diff --git a/tests/WP_SQLite_Driver_Metadata_Tests.php b/tests/WP_SQLite_Driver_Metadata_Tests.php index 3089e65b..90cddb7f 100644 --- a/tests/WP_SQLite_Driver_Metadata_Tests.php +++ b/tests/WP_SQLite_Driver_Metadata_Tests.php @@ -11,7 +11,8 @@ class WP_SQLite_Driver_Metadata_Tests extends TestCase { // Before each test, we create a new database public function setUp(): void { - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), 'wp' diff --git a/tests/WP_SQLite_Driver_Query_Tests.php b/tests/WP_SQLite_Driver_Query_Tests.php index 073225af..2fd5d69d 100644 --- a/tests/WP_SQLite_Driver_Query_Tests.php +++ b/tests/WP_SQLite_Driver_Query_Tests.php @@ -104,7 +104,8 @@ public function setUp(): void { global $tables; $queries = explode( ';', $tables ); - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), 'wp' diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 51411385..542c5ea2 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -11,7 +11,8 @@ class WP_SQLite_Driver_Tests extends TestCase { // Before each test, we create a new database public function setUp(): void { - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), @@ -6059,7 +6060,8 @@ public function testComplexInformationSchemaQueries(): void { } public function testDatabaseNameEmpty(): void { - $pdo = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $pdo = new $pdo_class( 'sqlite::memory:' ); $connection = new WP_SQLite_Connection( array( 'pdo' => $pdo ) ); $this->expectException( WP_SQLite_Driver_Exception::class ); diff --git a/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php b/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php index e4010f70..6bf93e85 100644 --- a/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php +++ b/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php @@ -41,7 +41,8 @@ function wp_get_db_schema() { // Before each test, we create a new database public function setUp(): void { - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'pdo' => $this->sqlite ) ), 'wp' diff --git a/tests/WP_SQLite_Metadata_Tests.php b/tests/WP_SQLite_Metadata_Tests.php index 399edb53..9144a42c 100644 --- a/tests/WP_SQLite_Metadata_Tests.php +++ b/tests/WP_SQLite_Metadata_Tests.php @@ -14,7 +14,8 @@ public function setUp(): void { global $blog_tables; $queries = explode( ';', $blog_tables ); - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Translator( $this->sqlite ); $translator = $this->engine; diff --git a/tests/WP_SQLite_PDO_User_Defined_Functions_Tests.php b/tests/WP_SQLite_PDO_User_Defined_Functions_Tests.php index 4ba6ee1a..34740194 100644 --- a/tests/WP_SQLite_PDO_User_Defined_Functions_Tests.php +++ b/tests/WP_SQLite_PDO_User_Defined_Functions_Tests.php @@ -9,8 +9,9 @@ class WP_SQLite_PDO_User_Defined_Functions_Tests extends TestCase { * @dataProvider dataProviderForTestFieldFunction */ public function testFieldFunction( $expected, $args ) { - $pdo = new PDO( 'sqlite::memory:' ); - $fns = WP_SQLite_PDO_User_Defined_Functions::register_for( $pdo ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $pdo = new $pdo_class( 'sqlite::memory:' ); + $fns = WP_SQLite_PDO_User_Defined_Functions::register_for( $pdo ); $this->assertEquals( $expected, diff --git a/tests/WP_SQLite_Query_Tests.php b/tests/WP_SQLite_Query_Tests.php index 648cd4e3..00997631 100644 --- a/tests/WP_SQLite_Query_Tests.php +++ b/tests/WP_SQLite_Query_Tests.php @@ -23,7 +23,8 @@ public function setUp(): void { global $blog_tables; $queries = explode( ';', $blog_tables ); - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Translator( $this->sqlite ); $translator = $this->engine; diff --git a/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index a892fe72..5a72ca3c 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -11,7 +11,8 @@ class WP_SQLite_Translator_Tests extends TestCase { // Before each test, we create a new database public function setUp(): void { - $this->sqlite = new PDO( 'sqlite::memory:' ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->sqlite = new $pdo_class( 'sqlite::memory:' ); $this->engine = new WP_SQLite_Translator( $this->sqlite ); $this->engine->query( diff --git a/version.php b/version.php index f0039dc7..8f6bf97c 100644 --- a/version.php +++ b/version.php @@ -5,4 +5,4 @@ * * This constant needs to be updated on plugin release! */ -define( 'SQLITE_DRIVER_VERSION', '2.2.14' ); +define( 'SQLITE_DRIVER_VERSION', '2.2.15' ); diff --git a/wp-includes/mysql/class-wp-mysql-lexer.php b/wp-includes/mysql/class-wp-mysql-lexer.php index 7d6bcd0e..b9f582c3 100644 --- a/wp-includes/mysql/class-wp-mysql-lexer.php +++ b/wp-includes/mysql/class-wp-mysql-lexer.php @@ -2615,7 +2615,7 @@ private function read_identifier(): ?int { // If it can't, bail out early to avoid unnecessary UTF-8 decoding. // Identifiers are usually ASCII-only, so we can optimize for that. $byte_1 = ord( - $this->sql[ $this->bytes_already_read ] ?? '' + $this->sql[ $this->bytes_already_read ] ?? "\0" ); if ( $byte_1 < 0xC2 || $byte_1 > 0xEF ) { break; @@ -2623,7 +2623,7 @@ private function read_identifier(): ?int { // Look for a valid 2-byte UTF-8 symbol. Covers range U+0080 - U+07FF. $byte_2 = ord( - $this->sql[ $this->bytes_already_read + 1 ] ?? '' + $this->sql[ $this->bytes_already_read + 1 ] ?? "\0" ); if ( $byte_1 <= 0xDF @@ -2635,7 +2635,7 @@ private function read_identifier(): ?int { // Look for a valid 3-byte UTF-8 symbol in range U+0800 - U+FFFF. $byte_3 = ord( - $this->sql[ $this->bytes_already_read + 2 ] ?? '' + $this->sql[ $this->bytes_already_read + 2 ] ?? "\0" ); if ( $byte_1 <= 0xEF diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php index f79e9b55..b015d7c9 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-connection.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-connection.php @@ -76,7 +76,8 @@ public function __construct( array $options ) { if ( ! isset( $options['path'] ) || ! is_string( $options['path'] ) ) { throw new InvalidArgumentException( 'Option "path" is required when "connection" is not provided.' ); } - $this->pdo = new PDO( 'sqlite:' . $options['path'] ); + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->pdo = new $pdo_class( 'sqlite:' . $options['path'] ); } // Throw exceptions on error. diff --git a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index a1e44fd4..5959868a 100644 --- a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -26,12 +26,16 @@ class WP_SQLite_PDO_User_Defined_Functions { * Registers the user defined functions for SQLite to a PDO instance. * The functions are registered using PDO::sqliteCreateFunction(). * - * @param PDO $pdo The PDO object. + * @param PDO|PDO\SQLite $pdo The PDO object. */ - public static function register_for( PDO $pdo ): self { + public static function register_for( $pdo ): self { $instance = new self(); foreach ( $instance->functions as $f => $t ) { - $pdo->sqliteCreateFunction( $f, array( $instance, $t ) ); + if ( $pdo instanceof PDO\SQLite ) { + $pdo->createFunction( $f, array( $instance, $t ) ); + } else { + $pdo->sqliteCreateFunction( $f, array( $instance, $t ) ); + } } return $instance; } diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index c0ac1b85..62ed8b40 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -369,8 +369,9 @@ public function __construct( $pdo = null ) { PDO::ATTR_TIMEOUT => 5, ); - $dsn = 'sqlite:' . FQDB; - $pdo = new PDO( $dsn, null, null, $options ); // phpcs:ignore WordPress.DB.RestrictedClasses + $dsn = 'sqlite:' . FQDB; + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $pdo = new $pdo_class( $dsn, null, null, $options ); } catch ( PDOException $ex ) { $status = $ex->getCode(); if ( self::SQLITE_BUSY === $status || self::SQLITE_LOCKED === $status ) { diff --git a/wp-includes/sqlite/install-functions.php b/wp-includes/sqlite/install-functions.php index 022afc8f..bffe1425 100644 --- a/wp-includes/sqlite/install-functions.php +++ b/wp-includes/sqlite/install-functions.php @@ -25,7 +25,8 @@ function sqlite_make_db_sqlite() { $table_schemas = wp_get_db_schema(); $queries = explode( ';', $table_schemas ); try { - $pdo = new PDO( 'sqlite:' . FQDB, null, null, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $pdo = new $pdo_class( 'sqlite:' . FQDB, null, null, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses } catch ( PDOException $err ) { $err_data = $err->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $message = 'Database connection error!
'; @@ -84,7 +85,8 @@ function sqlite_make_db_sqlite() { $port = $host_parts[1]; } $dsn = 'mysql:host=' . $host . '; port=' . $port . '; dbname=' . DB_NAME; - $pdo_mysql = new PDO( $dsn, DB_USER, DB_PASSWORD, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\MySQL::class : PDO::class; // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $pdo_mysql = new $pdo_class( $dsn, DB_USER, DB_PASSWORD, array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ) ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO $pdo_mysql->query( 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";' ); $pdo_mysql->query( 'SET time_zone = "+00:00";' ); foreach ( $queries as $query ) {