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/packages/wp-mysql-proxy/README.md b/packages/wp-mysql-proxy/README.md new file mode 100644 index 00000000..82e13787 --- /dev/null +++ b/packages/wp-mysql-proxy/README.md @@ -0,0 +1,37 @@ +# 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 mysql-proxy.php [--database ] [--port ] + +Options: + -h, --help Show this help message and exit. + -d, --database= The path to the SQLite database file. Default: :memory: + -p, --port= The port to listen on. Default: 3306 +``` + +### 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 ) +); +$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..0f763a76 --- /dev/null +++ b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php @@ -0,0 +1,46 @@ +] [--port ] + +Options: + -h, --help Show this help message and exit. + -d, --database= The path to the SQLite database file. Default: :memory: + -p, --port= The port to listen on. Default: 3306 + +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 ); +} + +// Start the MySQL proxy. +$proxy = new MySQL_Proxy( + new SQLite_Adapter( $db_path ), + array( 'port' => $port ) +); +$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-mysql-protocol.php b/packages/wp-mysql-proxy/src/class-mysql-protocol.php new file mode 100644 index 00000000..965be70f --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-protocol.php @@ -0,0 +1,305 @@ +> 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( array $columns, array $rows ): 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( $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 ( $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 + $col_payload .= self::encode_length_encoded_string( $col['table'] ?? '' ); + + // Original table name + $col_payload .= self::encode_length_encoded_string( $col['orgTable'] ?? '' ); + + // Column alias + $col_payload .= self::encode_length_encoded_string( $col['name'] ); + + // Original column name + $col_payload .= self::encode_length_encoded_string( $col['orgName'] ?? $col['name'] ); + + // Length of the remaining fixed fields. @TODO: What does that mean? + $col_payload .= self::encode_length_encoded_int( $col['fixedLen'] ?? 0x0c ); + $col_payload .= self::encode_int_16( $col['charset'] ?? MySQL_Protocol::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 ( $rows as $row ) { + $row_payload = ''; + // Iterate through columns in the defined order to match column definitions + foreach ( $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; + } +} 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..2f8ee224 --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-proxy.php @@ -0,0 +1,133 @@ +query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_set_option( $this->socket, SOL_SOCKET, SO_REUSEADDR, 1 ); + socket_bind( $this->socket, '0.0.0.0', $this->port ); + socket_listen( $this->socket ); + echo "MySQL PHP Proxy 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, true ) ) { + $client = socket_accept( $this->socket ); + if ( $client ) { + echo "New client connected.\n"; + $this->clients[] = $client; + $client_id = $this->get_client_id( $client ); + $this->client_servers[ $client_id ] = new MySQL_Session( $this->query_handler ); + + // Send initial 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, true ) ] ); + } + + // Handle client activity + 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"; + $client_id = $this->get_client_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 + $client_id = $this->get_client_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 ); + } + echo "Response written\n"; + + // Process any buffered data + while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { + echo "Processing buffered data\n"; + try { + $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); + 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"; + } + } + + /** + * Get a numeric ID for a client connected to the proxy. + * + * @param resource|object $client The client Socket object or resource. + * @return int The numeric ID of the client. + */ + private function get_client_id( $client ): int { + if ( is_resource( $client ) ) { + return get_resource_id( $client ); + } else { + return spl_object_id( $client ); + } + } +} 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..2b0a858e --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-result.php @@ -0,0 +1,41 @@ +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; + } + + public function to_packets(): string { + if ( $this->error_info ) { + $err_packet = MySQL_Protocol::build_err_packet( $this->error_info[1], $this->error_info[0], $this->error_info[2] ); + return MySQL_Protocol::encode_int_24( strlen( $err_packet ) ) . MySQL_Protocol::encode_int_8( 1 ) . $err_packet; + } + + if ( count( $this->columns ) > 0 ) { + return MySQL_Protocol::build_result_set_packets( $this->columns, $this->rows ); + } + + $ok_packet = MySQL_Protocol::build_ok_packet( $this->affected_rows, $this->last_insert_id ); + return MySQL_Protocol::encode_int_24( strlen( $ok_packet ) ) . MySQL_Protocol::encode_int_8( 1 ) . $ok_packet; + } +} 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..95dd3e91 --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -0,0 +1,296 @@ +adapter = $adapter; + $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 = MySQL_Protocol::build_handshake_packet( $this->connection_id, $this->auth_plugin_data ); + return MySQL_Protocol::encode_int_24( strlen( $handshake_payload ) ) . + MySQL_Protocol::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 ( MySQL_Protocol::COM_QUERY === $command ) { + $query = substr( $payload, 1 ); + return $this->process_query( $query ); + } elseif ( MySQL_Protocol::COM_INIT_DB === $command ) { + return $this->process_query( 'USE ' . substr( $payload, 1 ) ); + } elseif ( MySQL_Protocol::COM_QUIT === $command ) { + return ''; + } else { + // Unsupported command + $err_packet = MySQL_Protocol::build_err_packet( 0x04D2, 'HY000', 'Unsupported command' ); + return MySQL_Protocol::encode_int_24( strlen( $err_packet ) ) . + MySQL_Protocol::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 & MySQL_Protocol::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 & MySQL_Protocol::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 & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { + $database = $this->read_null_terminated_string( $payload, $offset ); + } + + $auth_plugin_name = ''; + if ( $capability_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_name = $this->read_null_terminated_string( $payload, $offset ); + } + + if ( $capability_flags & MySQL_Protocol::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 ( MySQL_Protocol::AUTH_PLUGIN_NAME === $auth_plugin_name ) { + $fast_auth_payload = chr( MySQL_Protocol::AUTH_MORE_DATA ) . chr( MySQL_Protocol::CACHING_SHA2_FAST_AUTH ); + $response_packets .= MySQL_Protocol::encode_int_24( strlen( $fast_auth_payload ) ); + $response_packets .= MySQL_Protocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $fast_auth_payload; + } + + $ok_packet = MySQL_Protocol::build_ok_packet(); + $response_packets .= MySQL_Protocol::encode_int_24( strlen( $ok_packet ) ); + $response_packets .= MySQL_Protocol::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->adapter->handle_query( $query ); + return $result->to_packets(); + } catch ( MySQLServerException $e ) { + $err_packet = MySQL_Protocol::build_err_packet( 0x04A7, '42000', 'Syntax error or unsupported query: ' . $e->getMessage() ); + return MySQL_Protocol::encode_int_24( strlen( $err_packet ) ) . + MySQL_Protocol::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 ); + } +} diff --git a/packages/wp-mysql-proxy/src/exceptions.php b/packages/wp-mysql-proxy/src/exceptions.php new file mode 100644 index 00000000..767d07eb --- /dev/null +++ b/packages/wp-mysql-proxy/src/exceptions.php @@ -0,0 +1,16 @@ +mysqli = new mysqli( '127.0.0.1', 'WordPress', 'WordPress', 'WordPress', $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..d36e412e --- /dev/null +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php @@ -0,0 +1,65 @@ +pdo = new PDO( + sprintf( 'mysql:host=127.0.0.1;port=%d', $this->port ), + 'WordPress', + 'WordPress' + ); + } + + 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();