diff --git a/packages/wp-mysql-proxy/src/class-mysql-protocol.php b/packages/wp-mysql-proxy/src/class-mysql-protocol.php index c72e71e3..8423394a 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-protocol.php +++ b/packages/wp-mysql-proxy/src/class-mysql-protocol.php @@ -6,9 +6,17 @@ * MySQL wire protocol constants and helper functions. */ class MySQL_Protocol { + /** + * MySQL protocol version. + * + * The current version 10 is used since MySQL 3.21.0. + */ + const PROTOCOL_VERSION = 10; + /** * MySQL client capability flags. * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/group__group__cs__capabilities__flags.html * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/mysql_com.h#L260 */ const CLIENT_LONG_PASSWORD = 1 << 0; // [NOT USED] Use improved version of old authentication. @@ -47,6 +55,7 @@ class MySQL_Protocol { /** * MySQL server status flags. * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/mysql__com_8h.html#a1d854e841086925be1883e4d7b4e8cad * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/mysql_com.h#L810 */ const SERVER_STATUS_IN_TRANS = 1 << 0; // A multi-statement transaction has been started. @@ -67,8 +76,9 @@ class MySQL_Protocol { /** * MySQL command types. * - * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/my_command.h#L48 * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_command_phase.html + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/my__command_8h.html#ae2ff1badf13d2b8099af8b47831281e1 + * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/my_command.h#L48 */ const COM_SLEEP = 0; // Tells the server to sleep for the given number of seconds. const COM_QUIT = 1; // Tells the server that the client wants it to close the connection. @@ -107,6 +117,7 @@ class MySQL_Protocol { /** * MySQL field types. * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/field__types_8h.html#a69e798807026a0f7e12b1d6c72374854 * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/field_types.h#L55 * */ @@ -141,6 +152,7 @@ class MySQL_Protocol { /** * MySQL field flags. * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/group__group__cs__column__definition__flags.html * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/mysql_com.h#L154 */ const FIELD_NOT_NULL_FLAG = 1 << 0; // Field can't be NULL. @@ -184,198 +196,439 @@ class MySQL_Protocol { const EOF_PACKET_HEADER = 0xfe; const ERR_PACKET_HEADER = 0xff; + /** + * MySQL server-side authentication plugins. + * + * This list includes only server-side plugins for MySQL Standard Edition. + * MySQL Enterprise Edition has additional plugins that are not listed here. + * + * @see https://dev.mysql.com/doc/refman/8.4/en/authentication-plugins.html + * @see https://dev.mysql.com/doc/refman/8.4/en/pluggable-authentication.html + */ + const DEFAULT_AUTH_PLUGIN = self::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + const AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD = 'mysql_native_password'; // [DEPRECATED] Old built-in authentication. Default in MySQL < 8.0. + const AUTH_PLUGIN_CACHING_SHA2_PASSWORD = 'caching_sha2_password'; // Pluggable SHA-2 authentication. Default in MySQL >= 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; - const AUTH_PLUGIN_NAME = 'caching_sha2_password'; - // Character set and collation constants (using utf8mb4 general collation) - const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) + // Character set and collation constants + const CHARSET_UTF8MB4 = 0xff; // Max packet length constant const MAX_PACKET_LENGTH = 0x00ffffff; - // Helper: Packets assembly and parsing - public static function encode_int_8( int $val ): string { - return chr( $val & 0xff ); + /** + * 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 ); } - public static function encode_int_16( int $val ): string { - return pack( 'v', $val & 0xffff ); + /** + * 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 + ); } - public static function encode_int_24( int $val ): string { - // 3-byte little-endian integer - return substr( pack( 'V', $val & 0xffffff ), 0, 3 ); + /** + * 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 ); } - public static function encode_int_32( int $val ): string { - return pack( 'V', $val ); + /** + * 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 ); } - public static function encode_length_encoded_int( int $val ): string { - // Encodes an integer in MySQL's length-encoded format - if ( $val < 0xfb ) { - return chr( $val ); - } elseif ( $val <= 0xffff ) { - return "\xfc" . self::encode_int_16( $val ); - } elseif ( $val <= 0xffffff ) { - return "\xfd" . self::encode_int_24( $val ); + /** + * 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 { - return "\xfe" . pack( 'P', $val ); // 8-byte little-endian for 64-bit + $auth_plugin_data_length = 0; + $auth_plugin_name = ''; } - } - public static function encode_length_encoded_string( string $str ): string { - return self::encode_length_encoded_int( strlen( $str ) ) . $str; + /** + * 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 ); } - // Hashing for caching_sha2_password (fast auth algorithm) - public static function sha_256_hash( string $password, string $salt ): string { - $stage1 = hash( 'sha256', $password, true ); - $stage2 = hash( 'sha256', $stage1, true ); - $scramble = hash( 'sha256', $stage2 . substr( $salt, 0, 20 ), true ); - // XOR stage1 and scramble to get token - return $stage1 ^ $scramble; + /** + * Build 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 initial handshake packet (server greeting) - public static function build_handshake_packet( int $conn_id, string &$auth_plugin_data ): string { - $protocol_version = 0x0a; // Handshake protocol version (10) - $server_version = '8.9.38-php-mysql-server'; // Fake server version - // Generate random auth plugin data (20-byte salt) - $salt1 = random_bytes( 8 ); - $salt2 = random_bytes( 12 ); // total salt length = 8+12 = 20 bytes (with filler) - $auth_plugin_data = $salt1 . $salt2; - // Lower 2 bytes of capability flags - $cap_flags_lower = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) & 0xffff; - // Upper 2 bytes of capability flags - $cap_flags_upper = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) >> 16; - $charset = self::CHARSET_UTF8MB4; - $status_flags = self::SERVER_STATUS_AUTOCOMMIT; - - // Assemble handshake packet payload - $payload = chr( $protocol_version ); - $payload .= $server_version . "\0"; - $payload .= self::encode_int_32( $conn_id ); - $payload .= $salt1; - $payload .= "\0"; // filler byte - $payload .= self::encode_int_16( $cap_flags_lower ); - $payload .= chr( $charset ); - $payload .= self::encode_int_16( $status_flags ); - $payload .= self::encode_int_16( $cap_flags_upper ); - $payload .= chr( strlen( $auth_plugin_data ) + 1 ); // auth plugin data length (salt + \0) - $payload .= str_repeat( "\0", 10 ); // 10-byte reserved filler - $payload .= $salt2; - $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 - $payload .= self::AUTH_PLUGIN_NAME . "\0"; - return $payload; + /** + * Build 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 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_HEADER ); - $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 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 ERR packet (for errors) - public static function build_err_packet( int $error_code, string $sql_state, string $message ): string { - $payload = chr( self::ERR_PACKET_HEADER ); - $payload .= self::encode_int_16( $error_code ); - $payload .= '#' . strtoupper( $sql_state ); - $payload .= $message; - return $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. + ); } - // 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++ ); + /** + * 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 ); } - // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) - $eof_payload = chr( self::EOF_PACKET_HEADER ) . 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; + /** + * 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; + } - 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++ ); + /** + * 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; + } - // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) - $eof_payload_2 = chr( self::EOF_PACKET_HEADER ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); - $packet_stream .= self::wrap_packet( $eof_payload_2, $sequence_id++ ); - - return $packet_stream; + /** + * 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; } - // 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; + /** + * 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-result.php b/packages/wp-mysql-proxy/src/class-mysql-result.php index 2b0a858e..181ec1c9 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-result.php +++ b/packages/wp-mysql-proxy/src/class-mysql-result.php @@ -24,18 +24,4 @@ public static function from_error( string $sql_state, int $code, string $message $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 index f459ec1e..70d5a18b 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -2,286 +2,400 @@ namespace WP_MySQL_Proxy; +use Throwable; use WP_MySQL_Proxy\Adapter\Adapter; +/** + * MySQL server session handling a single client connection. + */ class MySQL_Session { + /** + * Client capabilites that are supported by the server. + */ + const CAPABILITIES = ( + MySQL_Protocol::CLIENT_PROTOCOL_41 + | MySQL_Protocol::CLIENT_DEPRECATE_EOF + | MySQL_Protocol::CLIENT_SECURE_CONNECTION + | MySQL_Protocol::CLIENT_PLUGIN_AUTH + | MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + | MySQL_Protocol::CLIENT_CONNECT_WITH_DB + ); + + /** + * MySQL server version. + * + * @var string + */ + private $server_version = '8.0.38-php-mysql-server'; + + /** + * Character set that is used by the server. + * + * @var int + */ + private $character_set = MySQL_Protocol::CHARSET_UTF8MB4; + + /** + * Status flags representing the server state. + * + * @var int + */ + private $status_flags = MySQL_Protocol::SERVER_STATUS_AUTOCOMMIT; + + /** + * An adapter instance to execute MySQL queries. + * + * @var Adapter + */ private $adapter; - private $client_id; + + /** + * Connection ID. + * + * @var int + */ + private $connection_id; + + /** + * Client capabilities. + * + * @var int + */ + private $client_capabilities = 0; + + /** + * Authentication plugin data (a random 20-byte salt/scramble). + * + * @var string + */ private $auth_plugin_data; - private $sequence_id; - private $authenticated = false; - private $buffer = ''; - public function __construct( Adapter $adapter, int $client_id ) { + /** + * Whether the client is authenticated. + * + * @var bool + */ + private $is_authenticated = false; + + /** + * Packet sequence ID. + * + * @var int + */ + private $packet_id; + + /** + * Buffer to store incoming data from the client. + * + * @var string + */ + private $buffer = ''; + + /** + * Constructor. + * + * @param Adapter $adapter The MySQL query adapter instance. + * @param int $connection_id The connection ID. + */ + public function __construct( Adapter $adapter, int $connection_id ) { $this->adapter = $adapter; - $this->client_id = $client_id; + $this->connection_id = $connection_id; $this->auth_plugin_data = ''; - $this->sequence_id = 0; + $this->packet_id = 0; + + // Generate random auth plugin data (20-byte salt) + $this->auth_plugin_data = random_bytes( 20 ); } /** - * Get the initial handshake packet to send to the client + * Check if there's any buffered data that hasn't been processed yet * - * @return string Binary packet data to send to client + * @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 { - $handshake_payload = MySQL_Protocol::build_handshake_packet( $this->client_id, $this->auth_plugin_data ); - return MySQL_Protocol::encode_int_24( strlen( $handshake_payload ) ) . - MySQL_Protocol::encode_int_8( $this->sequence_id++ ) . - $handshake_payload; + 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 + * 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 + * @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 existing buffer + // Append new data to the existing buffer. $this->buffer .= $data; - // Check if we have enough data for a header + // 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 - $packet_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; + // 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 - $total_packet_length = 4 + $packet_length; - if ( strlen( $this->buffer ) < $total_packet_length ) { + // Check if we have the complete packet. + $packet_length = 4 + $payload_length; + if ( strlen( $this->buffer ) < $packet_length ) { throw new Incomplete_Input_Exception( - 'Incomplete packet payload, have ' . strlen( $this->buffer ) . - ' bytes, need ' . $total_packet_length . ' bytes' + sprintf( + 'Incomplete packet payload, have %d bytes, but need %d bytes', + strlen( $this->buffer ), + $packet_length + ) ); } - // Extract the complete packet - $packet = substr( $this->buffer, 0, $total_packet_length ); + // Extract the packet payload. + $payload = substr( $this->buffer, 4, $payload_length ); - // Remove the processed packet from the buffer - $this->buffer = substr( $this->buffer, $total_packet_length ); + // Remove the whole packet from the buffer. + $this->buffer = substr( $this->buffer, $packet_length ); - // Process the packet - $payload = substr( $packet, 4, $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 + */ - // If not authenticated yet, process authentication - if ( ! $this->authenticated ) { + // Authentication phase. + if ( ! $this->is_authenticated ) { return $this->process_authentication( $payload ); } - // Otherwise, process as a command + // Command phase. $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 ''; - } elseif ( MySQL_Protocol::COM_PING === $command ) { - return MySQL_Protocol::wrap_packet( MySQL_Protocol::build_ok_packet(), $received_sequence_id + 1 ); - } 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; + 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 packet from client + * Process authentication payload from the client. * - * @param string $payload Authentication packet payload - * @return string Response packet to send back + * @param string $payload The authentication payload. + * @return string The authentication response packet. */ 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; + // Decode the first 5 fields. + $data = unpack( + 'Vclient_flags/Vmax_packet_size/Ccharacter_set/x23filler/Z*username', + $payload + ); - $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 ); + // Calculate the offset of the authentication response. + $offset = 32 + strlen( $data['username'] ) + 1; - $username = $this->read_null_terminated_string( $payload, $offset ); + $client_flags = $data['client_flags']; + $this->client_capabilities = $client_flags; + // Decode the authentication response. $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 ); + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response = MySQL_Protocol::read_length_encoded_string( $payload, $offset ); } else { - $auth_response = $this->read_null_terminated_string( $payload, $offset ); + $length = ord( $payload[ $offset++ ] ); + $auth_response = substr( $payload, $offset, $length ); + $offset += $length; } - $database = ''; - if ( $capability_flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { - $database = $this->read_null_terminated_string( $payload, $offset ); + // 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 ( $capability_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { - $auth_plugin_name = $this->read_null_terminated_string( $payload, $offset ); + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_name = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); } - if ( $capability_flags & MySQL_Protocol::CLIENT_CONNECT_ATTRS ) { - $attrs_length = $this->read_length_encoded_int( $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. } - $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_HEADER ) . 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; + /** + * 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 ); } - // 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; + // Unsupported authentication plugin. + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 0x04D2, + 'HY000', + 'Unsupported authentication plugin: ' . $auth_plugin_name + ); } /** - * Process a query from the client + * Process a MySQL query from the client. * - * @param string $query SQL query to process - * @return string Response packet to send back + * @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 ); - return $result->to_packets(); - } catch ( MySQL_Proxy_Exception $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; + 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() + ); } } /** - * Check if there's any buffered data that hasn't been processed yet + * Build the result set packets for a MySQL query. * - * @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 + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_query_response_text_resultset.html * - * @return int Number of bytes in buffer + * @param array $columns The columns of the result set. + * @param array $rows The rows of the result set. + * @return string The result set packets. */ - public function get_buffer_size(): int { - return strlen( $this->buffer ); + 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/tests/WP_MySQL_Proxy_CLI_Test.php b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php new file mode 100644 index 00000000..339c2739 --- /dev/null +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php @@ -0,0 +1,73 @@ +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 index b83a2b8e..3d476534 100644 --- a/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php @@ -6,7 +6,7 @@ class WP_MySQL_Proxy_MySQLi_Test extends WP_MySQL_Proxy_Test { public function setUp(): void { parent::setUp(); - $this->mysqli = new mysqli( '127.0.0.1', 'WordPress', 'WordPress', 'WordPress', $this->port ); + $this->mysqli = new mysqli( '127.0.0.1', 'user', 'password', 'sqlite_database', $this->port ); } public function test_query(): void { 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 index d36e412e..eb0dcd81 100644 --- a/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php +++ b/packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php @@ -9,8 +9,8 @@ public function setUp(): void { $this->pdo = new PDO( sprintf( 'mysql:host=127.0.0.1;port=%d', $this->port ), - 'WordPress', - 'WordPress' + 'user', + 'password' ); }