From a549c6db919375affd0de601f7207fbe23146340 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 15:17:48 +0200 Subject: [PATCH 01/22] Add MySQL protocol implementation from https://github.com/adamziel/mysql-sqlite-network-proxy --- .../src/handler-sqlite-translation.php | 102 ++ packages/wp-mysql-proxy/src/mysql-server.php | 954 ++++++++++++++++++ .../src/run-sqlite-translation.php | 18 + 3 files changed, 1074 insertions(+) create mode 100644 packages/wp-mysql-proxy/src/handler-sqlite-translation.php create mode 100644 packages/wp-mysql-proxy/src/mysql-server.php create mode 100644 packages/wp-mysql-proxy/src/run-sqlite-translation.php diff --git a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php new file mode 100644 index 00000000..19658a88 --- /dev/null +++ b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php @@ -0,0 +1,102 @@ +sqlite_driver = new WP_SQLite_Driver( + new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), + 'wordpress' + ); + } + + public function handleQuery(string $query): MySQLServerQueryResult { + try { + $rows = $this->sqlite_driver->query($query); + if ( $this->sqlite_driver->get_last_column_count() > 0 ) { + $columns = $this->computeColumnInfo(); + return new SelectQueryResult($columns, $rows); + } + return new OkayPacketResult( + $this->sqlite_driver->get_last_return_value() ?? 0, + $this->sqlite_driver->get_insert_id() ?? 0 + ); + } catch (Throwable $e) { + return new ErrorQueryResult($e->getMessage()); + } + } + + public function computeColumnInfo() { + $columns = []; + + $column_meta = $this->sqlite_driver->get_last_column_meta(); + + $types = [ + 'DECIMAL' => MySQLProtocol::FIELD_TYPE_DECIMAL, + 'TINY' => MySQLProtocol::FIELD_TYPE_TINY, + 'SHORT' => MySQLProtocol::FIELD_TYPE_SHORT, + 'LONG' => MySQLProtocol::FIELD_TYPE_LONG, + 'FLOAT' => MySQLProtocol::FIELD_TYPE_FLOAT, + 'DOUBLE' => MySQLProtocol::FIELD_TYPE_DOUBLE, + 'NULL' => MySQLProtocol::FIELD_TYPE_NULL, + 'TIMESTAMP' => MySQLProtocol::FIELD_TYPE_TIMESTAMP, + 'LONGLONG' => MySQLProtocol::FIELD_TYPE_LONGLONG, + 'INT24' => MySQLProtocol::FIELD_TYPE_INT24, + 'DATE' => MySQLProtocol::FIELD_TYPE_DATE, + 'TIME' => MySQLProtocol::FIELD_TYPE_TIME, + 'DATETIME' => MySQLProtocol::FIELD_TYPE_DATETIME, + 'YEAR' => MySQLProtocol::FIELD_TYPE_YEAR, + 'NEWDATE' => MySQLProtocol::FIELD_TYPE_NEWDATE, + 'VARCHAR' => MySQLProtocol::FIELD_TYPE_VARCHAR, + 'BIT' => MySQLProtocol::FIELD_TYPE_BIT, + 'NEWDECIMAL' => MySQLProtocol::FIELD_TYPE_NEWDECIMAL, + 'ENUM' => MySQLProtocol::FIELD_TYPE_ENUM, + 'SET' => MySQLProtocol::FIELD_TYPE_SET, + 'TINY_BLOB' => MySQLProtocol::FIELD_TYPE_TINY_BLOB, + 'MEDIUM_BLOB' => MySQLProtocol::FIELD_TYPE_MEDIUM_BLOB, + 'LONG_BLOB' => MySQLProtocol::FIELD_TYPE_LONG_BLOB, + 'BLOB' => MySQLProtocol::FIELD_TYPE_BLOB, + 'VAR_STRING' => MySQLProtocol::FIELD_TYPE_VAR_STRING, + 'STRING' => MySQLProtocol::FIELD_TYPE_STRING, + 'GEOMETRY' => MySQLProtocol::FIELD_TYPE_GEOMETRY, + ]; + + foreach ($column_meta as $column) { + $type = $types[$column['native_type']] ?? null; + if ( null === $type ) { + throw new Exception('Unknown column type: ' . $column['native_type']); + } + $columns[] = [ + 'name' => $column['name'], + 'length' => $column['len'], + 'type' => $type, + 'flags' => 129, + 'decimals' => $column['precision'] + ]; + } + return $columns; + } +} diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php new file mode 100644 index 00000000..95df3f1a --- /dev/null +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -0,0 +1,954 @@ + string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] + public array $rows; // Array of rows, each an array of values (strings, numbers, or null) + + public function __construct(array $columns = [], array $rows = []) { + $this->columns = $columns; + $this->rows = $rows; + } + + public function toPackets(): string { + return MySQLProtocol::buildResultSetPackets($this); + } +} + +class OkayPacketResult implements MySQLServerQueryResult { + public int $affectedRows; + public int $lastInsertId; + + public function __construct(int $affectedRows, int $lastInsertId) { + $this->affectedRows = $affectedRows; + $this->lastInsertId = $lastInsertId; + } + + public function toPackets(): string { + $ok_packet = MySQLProtocol::buildOkPacket($this->affectedRows, $this->lastInsertId); + return MySQLProtocol::encodeInt24(strlen($ok_packet)) . MySQLProtocol::encodeInt8(1) . $ok_packet; + } +} + +class ErrorQueryResult implements MySQLServerQueryResult { + public string $code; + public string $sqlState; + public string $message; + + public function __construct(string $message = "Syntax error or unsupported query", string $sqlState = "42000", int $code = 0x04A7) { + $this->code = $code; + $this->sqlState = $sqlState; + $this->message = $message; + } + + public function toPackets(): string { + $err_packet = MySQLProtocol::buildErrPacket($this->code, $this->sqlState, $this->message); + return MySQLProtocol::encodeInt24(strlen($err_packet)) . MySQLProtocol::encodeInt8(1) . $err_packet; + } +} + +class MySQLProtocol { + // MySQL client/server capability flags (partial list) + const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags + const CLIENT_CONNECT_WITH_DB = 0x00000008; + const CLIENT_PROTOCOL_41 = 0x00000200; + const CLIENT_SECURE_CONNECTION = 0x00008000; + const CLIENT_MULTI_STATEMENTS = 0x00010000; + const CLIENT_MULTI_RESULTS = 0x00020000; + const CLIENT_PS_MULTI_RESULTS = 0x00040000; + const CLIENT_PLUGIN_AUTH = 0x00080000; + const CLIENT_CONNECT_ATTRS = 0x00100000; + const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; + const CLIENT_DEPRECATE_EOF = 0x01000000; + + // MySQL status flags + const SERVER_STATUS_AUTOCOMMIT = 0x0002; + + /** + * MySQL command types + * + * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html + */ + const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ + const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ + const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ + const COM_QUERY = 0x03; /** Tells the server to execute a query. */ + const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ + const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ + const COM_DROP_DB = 0x06; /** Currently refused by the server. */ + const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ + const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ + const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ + const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ + const COM_CONNECT = 0x0B; /** Currently refused by the server. */ + const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ + const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ + const COM_PING = 0x0E; /** Check if the server is alive. */ + const COM_TIME = 0x0F; /** Currently refused by the server. */ + const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ + const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ + const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ + const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ + const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ + const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ + const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ + const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ + const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ + const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ + const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ + const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ + const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ + const COM_DAEMON = 0x1D; /** Currently refused by the server. */ + const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ + const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ + const COM_CLONE = 0x20; /** Tells the server to clone a server. */ + + // Special packet markers + const OK_PACKET = 0x00; + const EOF_PACKET = 0xfe; + const ERR_PACKET = 0xff; + const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) + + // Auth specific markers for caching_sha2_password + const CACHING_SHA2_FAST_AUTH = 3; + const CACHING_SHA2_FULL_AUTH = 4; + const AUTH_PLUGIN_NAME = 'caching_sha2_password'; + + // Field types + const FIELD_TYPE_DECIMAL = 0x00; + const FIELD_TYPE_TINY = 0x01; + const FIELD_TYPE_SHORT = 0x02; + const FIELD_TYPE_LONG = 0x03; + const FIELD_TYPE_FLOAT = 0x04; + const FIELD_TYPE_DOUBLE = 0x05; + const FIELD_TYPE_NULL = 0x06; + const FIELD_TYPE_TIMESTAMP = 0x07; + const FIELD_TYPE_LONGLONG = 0x08; + const FIELD_TYPE_INT24 = 0x09; + const FIELD_TYPE_DATE = 0x0a; + const FIELD_TYPE_TIME = 0x0b; + const FIELD_TYPE_DATETIME = 0x0c; + const FIELD_TYPE_YEAR = 0x0d; + const FIELD_TYPE_NEWDATE = 0x0e; + const FIELD_TYPE_VARCHAR = 0x0f; + const FIELD_TYPE_BIT = 0x10; + const FIELD_TYPE_NEWDECIMAL = 0xf6; + const FIELD_TYPE_ENUM = 0xf7; + const FIELD_TYPE_SET = 0xf8; + const FIELD_TYPE_TINY_BLOB = 0xf9; + const FIELD_TYPE_MEDIUM_BLOB = 0xfa; + const FIELD_TYPE_LONG_BLOB = 0xfb; + const FIELD_TYPE_BLOB = 0xfc; + const FIELD_TYPE_VAR_STRING = 0xfd; + const FIELD_TYPE_STRING = 0xfe; + const FIELD_TYPE_GEOMETRY = 0xff; + + // Field flags + const NOT_NULL_FLAG = 0x1; + const PRI_KEY_FLAG = 0x2; + const UNIQUE_KEY_FLAG = 0x4; + const MULTIPLE_KEY_FLAG = 0x8; + const BLOB_FLAG = 0x10; + const UNSIGNED_FLAG = 0x20; + const ZEROFILL_FLAG = 0x40; + const BINARY_FLAG = 0x80; + const ENUM_FLAG = 0x100; + const AUTO_INCREMENT_FLAG = 0x200; + const TIMESTAMP_FLAG = 0x400; + const SET_FLAG = 0x800; + + // Character set and collation constants (using utf8mb4 general collation) + const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) + + // Max packet length constant + const MAX_PACKET_LENGTH = 0x00ffffff; + + private $current_db = ''; + + // Helper: Packets assembly and parsing + public static function encodeInt8(int $val): string { + return chr($val & 0xff); + } + public static function encodeInt16(int $val): string { + return pack('v', $val & 0xffff); + } + public static function encodeInt24(int $val): string { + // 3-byte little-endian integer + return substr(pack('V', $val & 0xffffff), 0, 3); + } + public static function encodeInt32(int $val): string { + return pack('V', $val); + } + public static function encodeLengthEncodedInt(int $val): string { + // Encodes an integer in MySQL's length-encoded format + if ($val < 0xfb) { + return chr($val); + } elseif ($val <= 0xffff) { + return "\xfc" . self::encodeInt16($val); + } elseif ($val <= 0xffffff) { + return "\xfd" . self::encodeInt24($val); + } else { + return "\xfe" . pack('P', $val); // 8-byte little-endian for 64-bit + } + } + public static function encodeLengthEncodedString(string $str): string { + return self::encodeLengthEncodedInt(strlen($str)) . $str; + } + + // Hashing for caching_sha2_password (fast auth algorithm) + public static function sha256Hash(string $password, string $salt): string { + $stage1 = hash('sha256', $password, true); + $stage2 = hash('sha256', $stage1, true); + $scramble = hash('sha256', $stage2 . substr($salt, 0, 20), true); + // XOR stage1 and scramble to get token + return $stage1 ^ $scramble; + } + + // Build initial handshake packet (server greeting) + public static function buildHandshakePacket(int $connId, string &$authPluginData): string { + $protocol_version = 0x0a; // Handshake protocol version (10) + $server_version = "5.7.30-php-mysql-server"; // Fake server version + // Generate random auth plugin data (20-byte salt) + $salt1 = random_bytes(8); + $salt2 = random_bytes(12); // total salt length = 8+12 = 20 bytes (with filler) + $authPluginData = $salt1 . $salt2; + // Lower 2 bytes of capability flags + $capFlagsLower = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) & 0xffff; + // Upper 2 bytes of capability flags + $capFlagsUpper = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) >> 16; + $charset = self::CHARSET_UTF8MB4; + $statusFlags = self::SERVER_STATUS_AUTOCOMMIT; + + // Assemble handshake packet payload + $payload = chr($protocol_version); + $payload .= $server_version . "\0"; + $payload .= self::encodeInt32($connId); + $payload .= $salt1; + $payload .= "\0"; // filler byte + $payload .= self::encodeInt16($capFlagsLower); + $payload .= chr($charset); + $payload .= self::encodeInt16($statusFlags); + $payload .= self::encodeInt16($capFlagsUpper); + $payload .= chr(strlen($authPluginData) + 1); // auth plugin data length (salt + \0) + $payload .= str_repeat("\0", 10); // 10-byte reserved filler + $payload .= $salt2; + $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 + $payload .= self::AUTH_PLUGIN_NAME . "\0"; + return $payload; + } + + // Build OK packet (after successful authentication or query execution) + public static function buildOkPacket(int $affectedRows = 0, int $lastInsertId = 0): string { + $payload = chr(self::OK_PACKET); + $payload .= self::encodeLengthEncodedInt($affectedRows); + $payload .= self::encodeLengthEncodedInt($lastInsertId); + $payload .= self::encodeInt16(self::SERVER_STATUS_AUTOCOMMIT); // server status + $payload .= self::encodeInt16(0); // no warning count + // No human-readable message for simplicity + return $payload; + } + + // Build ERR packet (for errors) + public static function buildErrPacket(int $errorCode, string $sqlState, string $message): string { + $payload = chr(self::ERR_PACKET); + $payload .= self::encodeInt16($errorCode); + $payload .= "#" . strtoupper($sqlState); + $payload .= $message; + return $payload; + } + + // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) + public static function buildResultSetPackets(SelectQueryResult $result): string { + $sequenceId = 1; // Sequence starts at 1 for resultset (after COM_QUERY) + $packetStream = ''; + + // 1. Column count packet (length-encoded integer for number of columns) + $colCount = count($result->columns); + $colCountPayload = self::encodeLengthEncodedInt($colCount); + $packetStream .= self::wrapPacket($colCountPayload, $sequenceId++); + + // 2. Column definition packets for each column + foreach ($result->columns as $col) { + // Protocol::ColumnDefinition41 format:] + $colPayload = self::encodeLengthEncodedString($col['catalog'] ?? 'sqlite'); + $colPayload .= self::encodeLengthEncodedString($col['schema'] ?? ''); + + // Table alias + $colPayload .= self::encodeLengthEncodedString($col['table'] ?? ''); + + // Original table name + $colPayload .= self::encodeLengthEncodedString($col['orgTable'] ?? ''); + + // Column alias + $colPayload .= self::encodeLengthEncodedString($col['name']); + + // Original column name + $colPayload .= self::encodeLengthEncodedString($col['orgName'] ?? $col['name']); + + // Length of the remaining fixed fields. @TODO: What does that mean? + $colPayload .= self::encodeLengthEncodedInt($col['fixedLen'] ?? 0x0c); + $colPayload .= self::encodeInt16($col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4); + $colPayload .= self::encodeInt32($col['length']); + $colPayload .= self::encodeInt8($col['type']); + $colPayload .= self::encodeInt16($col['flags']); + $colPayload .= self::encodeInt8($col['decimals']); + $colPayload .= "\x00"; // filler (1 byte, reserved) + + $packetStream .= self::wrapPacket($colPayload, $sequenceId++); + } + // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) + $eofPayload = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); + $packetStream .= self::wrapPacket($eofPayload, $sequenceId++); + + // 4. Row data packets (each row is a series of length-encoded values) + foreach ($result->rows as $row) { + $rowPayload = ""; + // Iterate through columns in the defined order to match column definitions + foreach ($result->columns as $col) { + $columnName = $col['name']; + $val = $row->{$columnName} ?? null; + + if ($val === null) { + // NULL is represented by 0xfb (NULL_VALUE) + $rowPayload .= "\xfb"; + } else { + $valStr = (string)$val; + $rowPayload .= self::encodeLengthEncodedString($valStr); + } + } + $packetStream .= self::wrapPacket($rowPayload, $sequenceId++); + } + + // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) + $eofPayload2 = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); + $packetStream .= self::wrapPacket($eofPayload2, $sequenceId++); + + return $packetStream; + } + + // Helper to wrap a payload into a packet with length and sequence id + public static function wrapPacket(string $payload, int $sequenceId): string { + $length = strlen($payload); + $header = self::encodeInt24($length) . self::encodeInt8($sequenceId); + return $header . $payload; + } +} + +class IncompleteInputException extends MySQLServerException { + public function __construct(string $message = "Incomplete input data, more bytes needed") { + parent::__construct($message); + } +} + +class MySQLGateway { + private $query_handler; + private $connection_id; + private $auth_plugin_data; + private $sequence_id; + private $authenticated = false; + private $buffer = ''; + + public function __construct(MySQLQueryHandler $query_handler) { + $this->query_handler = $query_handler; + $this->connection_id = random_int(1, 1000); + $this->auth_plugin_data = ""; + $this->sequence_id = 0; + } + + /** + * Get the initial handshake packet to send to the client + * + * @return string Binary packet data to send to client + */ + public function getInitialHandshake(): string { + $handshakePayload = MySQLProtocol::buildHandshakePacket($this->connection_id, $this->auth_plugin_data); + return MySQLProtocol::encodeInt24(strlen($handshakePayload)) . + MySQLProtocol::encodeInt8($this->sequence_id++) . + $handshakePayload; + } + + /** + * Process bytes received from the client + * + * @param string $data Binary data received from client + * @return string|null Response to send back to client, or null if no response needed + * @throws IncompleteInputException When more data is needed to complete a packet + */ + public function receiveBytes(string $data): ?string { + // Append new data to existing buffer + $this->buffer .= $data; + + // Check if we have enough data for a header + if (strlen($this->buffer) < 4) { + throw new IncompleteInputException("Incomplete packet header, need more bytes"); + } + + // Parse packet header + $packetLength = unpack('V', substr($this->buffer, 0, 3) . "\x00")[1]; + $receivedSequenceId = ord($this->buffer[3]); + + // Check if we have the complete packet + $totalPacketLength = 4 + $packetLength; + if (strlen($this->buffer) < $totalPacketLength) { + throw new IncompleteInputException( + "Incomplete packet payload, have " . strlen($this->buffer) . + " bytes, need " . $totalPacketLength . " bytes" + ); + } + + // Extract the complete packet + $packet = substr($this->buffer, 0, $totalPacketLength); + + // Remove the processed packet from the buffer + $this->buffer = substr($this->buffer, $totalPacketLength); + + // Process the packet + $payload = substr($packet, 4, $packetLength); + + // If not authenticated yet, process authentication + if (!$this->authenticated) { + return $this->processAuthentication($payload); + } + + // Otherwise, process as a command + $command = ord($payload[0]); + if ($command === MySQLProtocol::COM_QUERY) { + $query = substr($payload, 1); + return $this->processQuery($query); + } elseif ($command === MySQLProtocol::COM_INIT_DB) { + return $this->processQuery('USE ' . substr($payload, 1)); + } elseif ($command === MySQLProtocol::COM_QUIT) { + return ''; + } else { + // Unsupported command + $errPacket = MySQLProtocol::buildErrPacket(0x04D2, "HY000", "Unsupported command"); + return MySQLProtocol::encodeInt24(strlen($errPacket)) . + MySQLProtocol::encodeInt8(1) . + $errPacket; + } + } + + /** + * Process authentication packet from client + * + * @param string $payload Authentication packet payload + * @return string Response packet to send back + */ + private function processAuthentication(string $payload): string { + $offset = 0; + $payloadLength = strlen($payload); + + $capabilityFlags = $this->readUnsignedIntLittleEndian($payload, $offset, 4); + $offset += 4; + + $clientMaxPacketSize = $this->readUnsignedIntLittleEndian($payload, $offset, 4); + $offset += 4; + + $clientCharacterSet = 0; + if ($offset < $payloadLength) { + $clientCharacterSet = ord($payload[$offset]); + } + $offset += 1; + + // Skip reserved bytes (always zero) + $offset = min($payloadLength, $offset + 23); + + $username = $this->readNullTerminatedString($payload, $offset); + + $authResponse = ''; + if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) { + $authResponseLength = $this->readLengthEncodedInt($payload, $offset); + $authResponse = substr($payload, $offset, $authResponseLength); + $offset = min($payloadLength, $offset + $authResponseLength); + } elseif ($capabilityFlags & MySQLProtocol::CLIENT_SECURE_CONNECTION) { + $authResponseLength = 0; + if ($offset < $payloadLength) { + $authResponseLength = ord($payload[$offset]); + } + $offset += 1; + $authResponse = substr($payload, $offset, $authResponseLength); + $offset = min($payloadLength, $offset + $authResponseLength); + } else { + $authResponse = $this->readNullTerminatedString($payload, $offset); + } + + $database = ''; + if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_WITH_DB) { + $database = $this->readNullTerminatedString($payload, $offset); + } + + $authPluginName = ''; + if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH) { + $authPluginName = $this->readNullTerminatedString($payload, $offset); + } + + if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_ATTRS) { + $attrsLength = $this->readLengthEncodedInt($payload, $offset); + $offset = min($payloadLength, $offset + $attrsLength); + } + + $this->authenticated = true; + $this->sequence_id = 2; + + $responsePackets = ''; + + if ($authPluginName === MySQLProtocol::AUTH_PLUGIN_NAME) { + $fastAuthPayload = chr(MySQLProtocol::AUTH_MORE_DATA) . chr(MySQLProtocol::CACHING_SHA2_FAST_AUTH); + $responsePackets .= MySQLProtocol::encodeInt24(strlen($fastAuthPayload)); + $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); + $responsePackets .= $fastAuthPayload; + } + + $okPacket = MySQLProtocol::buildOkPacket(); + $responsePackets .= MySQLProtocol::encodeInt24(strlen($okPacket)); + $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); + $responsePackets .= $okPacket; + + return $responsePackets; + } + + private function readUnsignedIntLittleEndian(string $payload, int $offset, int $length): int { + $slice = substr($payload, $offset, $length); + if ($slice === '' || $length <= 0) { + return 0; + } + + switch ($length) { + case 1: + return ord($slice[0]); + case 2: + $padded = str_pad($slice, 2, "\x00", STR_PAD_RIGHT); + $unpacked = unpack('v', $padded); + return $unpacked[1] ?? 0; + case 3: + case 4: + default: + $padded = str_pad($slice, 4, "\x00", STR_PAD_RIGHT); + $unpacked = unpack('V', $padded); + return $unpacked[1] ?? 0; + } + } + + private function readNullTerminatedString(string $payload, int &$offset): string { + $nullPosition = strpos($payload, "\0", $offset); + if ($nullPosition === false) { + $result = substr($payload, $offset); + $offset = strlen($payload); + return $result; + } + + $result = substr($payload, $offset, $nullPosition - $offset); + $offset = $nullPosition + 1; + return $result; + } + + private function readLengthEncodedInt(string $payload, int &$offset): int { + if ($offset >= strlen($payload)) { + return 0; + } + + $first = ord($payload[$offset]); + $offset += 1; + + if ($first < 0xfb) { + return $first; + } + + if ($first === 0xfb) { + return 0; + } + + if ($first === 0xfc) { + $value = $this->readUnsignedIntLittleEndian($payload, $offset, 2); + $offset += 2; + return $value; + } + + if ($first === 0xfd) { + $value = $this->readUnsignedIntLittleEndian($payload, $offset, 3); + $offset += 3; + return $value; + } + + // 0xfe indicates an 8-byte integer + $value = 0; + $slice = substr($payload, $offset, 8); + if ($slice !== '') { + $slice = str_pad($slice, 8, "\x00"); + $value = unpack('P', $slice)[1]; + } + $offset += 8; + return (int) $value; + } + + /** + * Process a query from the client + * + * @param string $query SQL query to process + * @return string Response packet to send back + */ + private function processQuery(string $query): string { + $query = trim($query); + + try { + $result = $this->query_handler->handleQuery($query); + return $result->toPackets(); + } catch (MySQLServerException $e) { + $errPacket = MySQLProtocol::buildErrPacket(0x04A7, "42000", "Syntax error or unsupported query: " . $e->getMessage()); + return MySQLProtocol::encodeInt24(strlen($errPacket)) . + MySQLProtocol::encodeInt8(1) . + $errPacket; + } + } + + /** + * Reset the server state for a new connection + */ + public function reset(): void { + $this->connection_id = random_int(1, 1000); + $this->auth_plugin_data = ""; + $this->sequence_id = 0; + $this->authenticated = false; + $this->buffer = ''; + } + + /** + * Check if there's any buffered data that hasn't been processed yet + * + * @return bool True if there's data in the buffer + */ + public function hasBufferedData(): bool { + return !empty($this->buffer); + } + + /** + * Get the number of bytes currently in the buffer + * + * @return int Number of bytes in buffer + */ + public function getBufferSize(): int { + return strlen($this->buffer); + } +} + +class SingleUseMySQLSocketServer { + private $server; + private $socket; + private $port; + + public function __construct(MySQLQueryHandler $query_handler, $options = []) { + $this->server = new MySQLGateway($query_handler); + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_bind($this->socket, '0.0.0.0', $this->port); + socket_listen($this->socket); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + + // Accept a single client for simplicity + $client = socket_accept($this->socket); + if (!$client) { + exit("Failed to accept connection\n"); + } + $this->handleClient($client); + socket_close($client); + socket_close($this->socket); + } + + private function handleClient($client) { + // Send initial handshake + $handshake = $this->server->getInitialHandshake(); + socket_write($client, $handshake); + + while (true) { + // Read available data (up to 4096 bytes at a time) + $data = @socket_read($client, 4096); + if ($data === false || $data === '') { + break; // connection closed + } + + try { + // Process the data + $response = $this->server->receiveBytes($data); + if ($response) { + socket_write($client, $response); + } + + // If there's still data in the buffer, process it immediately + while ($this->server->hasBufferedData()) { + try { + // Try to process more complete packets from the buffer + $response = $this->server->receiveBytes(''); + if ($response) { + socket_write($client, $response); + } + } catch (IncompleteInputException $e) { + // Not enough data to complete another packet, wait for more + break; + } + } + } catch (IncompleteInputException $e) { + // Not enough data yet, continue reading + continue; + } + } + + echo "Client disconnected, terminating the server.\n"; + $this->server->reset(); + } +} + +if(!function_exists('post_message_to_js')) { + function post_message_to_js(string $message) { + echo 'The "post_message_to_js" function is only available in WordPress Playground but you are running it in a standalone PHP environment.' . PHP_EOL; + echo 'The message was: ' . $message . PHP_EOL; + } +} + +class MySQLSocketServer { + private $query_handler; + private $socket; + private $port; + private $clients = []; + private $clientServers = []; + + public function __construct(MySQLQueryHandler $query_handler, $options = []) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_bind($this->socket, '0.0.0.0', $this->port); + socket_listen($this->socket); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + while (true) { + // Prepare arrays for socket_select() + $read = array_merge([$this->socket], $this->clients); + $write = null; + $except = null; + + // Wait for activity on any socket + $select_result = socket_select($read, $write, $except, null); + if($select_result === false || $select_result <= 0) { + continue; + } + + // Check if there's a new connection + if (in_array($this->socket, $read)) { + $client = socket_accept($this->socket); + if ($client) { + echo "New client connected.\n"; + $this->clients[] = $client; + $clientId = spl_object_id($client); + $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); + + // Send initial handshake + echo "Pre handshake\n"; + $handshake = $this->clientServers[$clientId]->getInitialHandshake(); + echo "Post handshake\n"; + socket_write($client, $handshake); + } + // Remove server socket from read array + unset($read[array_search($this->socket, $read)]); + } + + // Handle client activity + echo "Waiting for client activity\n"; + foreach ($read as $client) { + echo "calling socket_read\n"; + $data = @socket_read($client, 4096); + echo "socket_read returned\n"; + $display = ''; + for ($i = 0; $i < strlen($data); $i++) { + $byte = ord($data[$i]); + if ($byte >= 32 && $byte <= 126) { + // Printable ASCII character + $display .= $data[$i]; + } else { + // Non-printable, show as hex + $display .= sprintf('%02x ', $byte); + } + } + echo rtrim($display) . "\n"; + + if ($data === false || $data === '') { + // Client disconnected + echo "Client disconnected.\n"; + $clientId = spl_object_id($client); + $this->clientServers[$clientId]->reset(); + unset($this->clientServers[$clientId]); + socket_close($client); + unset($this->clients[array_search($client, $this->clients)]); + continue; + } + + try { + // Process the data + $clientId = spl_object_id($client); + echo "Receiving bytes\n"; + $response = $this->clientServers[$clientId]->receiveBytes($data); + if ($response) { + echo "Writing response\n"; + echo $response; + socket_write($client, $response); + } + echo "Response written\n"; + + // Process any buffered data + while ($this->clientServers[$clientId]->hasBufferedData()) { + echo "Processing buffered data\n"; + try { + $response = $this->clientServers[$clientId]->receiveBytes(''); + if ($response) { + socket_write($client, $response); + } + } catch (IncompleteInputException $e) { + break; + } + } + echo "After the while loop\n"; + } catch (IncompleteInputException $e) { + echo "Incomplete input exception\n"; + continue; + } + } + echo "restarting the while() loop!\n"; + } + } +} + + +class MySQLPlaygroundYieldServer { + private $query_handler; + private $clients = []; + private $clientServers = []; + private $port; + + public function __construct(MySQLQueryHandler $query_handler, $options = []) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; + + // Main event loop + while (true) { + // Wait for a message from JS + $message = post_message_to_js(json_encode([ + 'type' => 'ready_for_event' + ])); + + $command = json_decode($message, true); + var_dump('decoded event', $command); + if (!$command || !isset($command['type'])) { + continue; + } + + switch ($command['type']) { + case 'new_connection': + $this->handleNewConnection($command['clientId']); + break; + + case 'data_received': + $this->handleDataReceived($command['clientId'], $command['data']); + break; + + case 'client_disconnected': + $this->handleClientDisconnected($command['clientId']); + break; + } + } + } + + private function handleNewConnection($clientId) { + echo "New client connected (ID: $clientId).\n"; + $this->clients[] = $clientId; + $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); + + // Send initial handshake + $handshake = $this->clientServers[$clientId]->getInitialHandshake(); + $this->sendResponse($clientId, $handshake); + } + + private function handleDataReceived($clientId, $encodedData) { + if (!isset($this->clientServers[$clientId])) { + throw new IncompleteInputException('No client server found'); + return; + } + + $data = base64_decode($encodedData); + + try { + // Process the data + $response = $this->clientServers[$clientId]->receiveBytes($data); + if ($response) { + $this->sendResponse($clientId, $response); + } else { + throw new IncompleteInputException('No response from client'); + } + + // Process any buffered data + while ($this->clientServers[$clientId]->hasBufferedData()) { + try { + $response = $this->clientServers[$clientId]->receiveBytes(''); + if ($response) { + $this->sendResponse($clientId, $response); + } + } catch (IncompleteInputException $e) { + throw $e; + break; + } + } + } catch (IncompleteInputException $e) { + // Not enough data yet, wait for mo + throw $e; + } + } + + private function handleClientDisconnected($clientId) { + echo "Client disconnected (ID: $clientId).\n"; + if (isset($this->clientServers[$clientId])) { + $this->clientServers[$clientId]->reset(); + unset($this->clientServers[$clientId]); + } + + $index = array_search($clientId, $this->clients); + if ($index !== false) { + unset($this->clients[$index]); + } + } + + private function sendResponse($clientId, $data) { + var_dump('sending response'); + $response = json_encode([ + 'type' => 'response_from_php', + 'clientId' => $clientId, + 'data' => base64_encode($data) + ]); + post_message_to_js($response); + } +} diff --git a/packages/wp-mysql-proxy/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php new file mode 100644 index 00000000..3680c699 --- /dev/null +++ b/packages/wp-mysql-proxy/src/run-sqlite-translation.php @@ -0,0 +1,18 @@ +SQLite proxy that parses MySQL queries and transforms them into SQLite operations. + * + * Most queries works, and the upcoming translation driver should bring the parity much + * closer to 100%: https://github.com/WordPress/sqlite-database-integration/pull/157 + */ + +require_once __DIR__ . '/mysql-server.php'; +require_once __DIR__ . '/handler-sqlite-translation.php'; + +define('WP_SQLITE_AST_DRIVER', true); + +$server = new MySQLSocketServer( + new SQLiteTranslationHandler(__DIR__ . '/database/test.db'), + ['port' => 3306] +); +$server->start(); From ac6e299a5e77519324c9bd7996501cd9668dd440 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 15:58:12 +0200 Subject: [PATCH 02/22] Use WordPress coding styles --- .../src/handler-sqlite-translation.php | 38 +- packages/wp-mysql-proxy/src/mysql-server.php | 1713 +++++++++-------- .../src/run-sqlite-translation.php | 6 +- 3 files changed, 884 insertions(+), 873 deletions(-) diff --git a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php index 19658a88..3d044c81 100644 --- a/packages/wp-mysql-proxy/src/handler-sqlite-translation.php +++ b/packages/wp-mysql-proxy/src/handler-sqlite-translation.php @@ -1,4 +1,4 @@ -sqlite_driver = new WP_SQLite_Driver( new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), - 'wordpress' + 'sqlite_database' ); } - public function handleQuery(string $query): MySQLServerQueryResult { + public function handle_query( string $query ): MySQLServerQueryResult { try { - $rows = $this->sqlite_driver->query($query); + $rows = $this->sqlite_driver->query( $query ); if ( $this->sqlite_driver->get_last_column_count() > 0 ) { $columns = $this->computeColumnInfo(); - return new SelectQueryResult($columns, $rows); + return new SelectQueryResult( $columns, $rows ); } return new OkayPacketResult( $this->sqlite_driver->get_last_return_value() ?? 0, $this->sqlite_driver->get_insert_id() ?? 0 ); - } catch (Throwable $e) { - return new ErrorQueryResult($e->getMessage()); + } catch ( Throwable $e ) { + return new ErrorQueryResult( $e->getMessage() ); } } public function computeColumnInfo() { - $columns = []; + $columns = array(); $column_meta = $this->sqlite_driver->get_last_column_meta(); - $types = [ + $types = array( 'DECIMAL' => MySQLProtocol::FIELD_TYPE_DECIMAL, 'TINY' => MySQLProtocol::FIELD_TYPE_TINY, 'SHORT' => MySQLProtocol::FIELD_TYPE_SHORT, @@ -82,20 +82,20 @@ public function computeColumnInfo() { 'VAR_STRING' => MySQLProtocol::FIELD_TYPE_VAR_STRING, 'STRING' => MySQLProtocol::FIELD_TYPE_STRING, 'GEOMETRY' => MySQLProtocol::FIELD_TYPE_GEOMETRY, - ]; + ); - foreach ($column_meta as $column) { - $type = $types[$column['native_type']] ?? null; + foreach ( $column_meta as $column ) { + $type = $types[ $column['native_type'] ] ?? null; if ( null === $type ) { - throw new Exception('Unknown column type: ' . $column['native_type']); + throw new Exception( 'Unknown column type: ' . $column['native_type'] ); } - $columns[] = [ + $columns[] = array( 'name' => $column['name'], 'length' => $column['len'], 'type' => $type, 'flags' => 129, - 'decimals' => $column['precision'] - ]; + 'decimals' => $column['precision'], + ); } return $columns; } diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php index 95df3f1a..3d021665 100644 --- a/packages/wp-mysql-proxy/src/mysql-server.php +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -1,954 +1,965 @@ - string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] - public array $rows; // Array of rows, each an array of values (strings, numbers, or null) + public $columns; // Each column: ['name' => string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] + public $rows; // Array of rows, each an array of values (strings, numbers, or null) - public function __construct(array $columns = [], array $rows = []) { - $this->columns = $columns; - $this->rows = $rows; - } + public function __construct( array $columns = array(), array $rows = array() ) { + $this->columns = $columns; + $this->rows = $rows; + } - public function toPackets(): string { - return MySQLProtocol::buildResultSetPackets($this); + public function to_packets(): string { + return MySQLProtocol::build_result_set_packets( $this ); } } class OkayPacketResult implements MySQLServerQueryResult { - public int $affectedRows; - public int $lastInsertId; + public $affected_rows; + public $last_insert_id; - public function __construct(int $affectedRows, int $lastInsertId) { - $this->affectedRows = $affectedRows; - $this->lastInsertId = $lastInsertId; + public function __construct( int $affected_rows, int $last_insert_id ) { + $this->affected_rows = $affected_rows; + $this->last_insert_id = $last_insert_id; } - public function toPackets(): string { - $ok_packet = MySQLProtocol::buildOkPacket($this->affectedRows, $this->lastInsertId); - return MySQLProtocol::encodeInt24(strlen($ok_packet)) . MySQLProtocol::encodeInt8(1) . $ok_packet; + public function to_packets(): string { + $ok_packet = MySQLProtocol::build_ok_packet( $this->affected_rows, $this->last_insert_id ); + return MySQLProtocol::encode_int_24( strlen( $ok_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $ok_packet; } } class ErrorQueryResult implements MySQLServerQueryResult { - public string $code; - public string $sqlState; - public string $message; - - public function __construct(string $message = "Syntax error or unsupported query", string $sqlState = "42000", int $code = 0x04A7) { - $this->code = $code; - $this->sqlState = $sqlState; - $this->message = $message; + public $code; + public $sql_state; + public $message; + + public function __construct( string $message = 'Syntax error or unsupported query', string $sql_state = '42000', int $code = 0x04A7 ) { + $this->code = $code; + $this->sql_state = $sql_state; + $this->message = $message; } - public function toPackets(): string { - $err_packet = MySQLProtocol::buildErrPacket($this->code, $this->sqlState, $this->message); - return MySQLProtocol::encodeInt24(strlen($err_packet)) . MySQLProtocol::encodeInt8(1) . $err_packet; + public function to_packets(): string { + $err_packet = MySQLProtocol::build_err_packet( $this->code, $this->sql_state, $this->message ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $err_packet; } } class MySQLProtocol { - // MySQL client/server capability flags (partial list) - const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags - const CLIENT_CONNECT_WITH_DB = 0x00000008; - const CLIENT_PROTOCOL_41 = 0x00000200; - const CLIENT_SECURE_CONNECTION = 0x00008000; - const CLIENT_MULTI_STATEMENTS = 0x00010000; - const CLIENT_MULTI_RESULTS = 0x00020000; - const CLIENT_PS_MULTI_RESULTS = 0x00040000; - const CLIENT_PLUGIN_AUTH = 0x00080000; - const CLIENT_CONNECT_ATTRS = 0x00100000; - const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; - const CLIENT_DEPRECATE_EOF = 0x01000000; - - // MySQL status flags - const SERVER_STATUS_AUTOCOMMIT = 0x0002; + // MySQL client/server capability flags (partial list) + const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags + const CLIENT_CONNECT_WITH_DB = 0x00000008; + const CLIENT_PROTOCOL_41 = 0x00000200; + const CLIENT_SECURE_CONNECTION = 0x00008000; + const CLIENT_MULTI_STATEMENTS = 0x00010000; + const CLIENT_MULTI_RESULTS = 0x00020000; + const CLIENT_PS_MULTI_RESULTS = 0x00040000; + const CLIENT_PLUGIN_AUTH = 0x00080000; + const CLIENT_CONNECT_ATTRS = 0x00100000; + const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; + const CLIENT_DEPRECATE_EOF = 0x01000000; + + // MySQL status flags + const SERVER_STATUS_AUTOCOMMIT = 0x0002; /** * MySQL command types * * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html */ - const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ + const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ - const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ - const COM_QUERY = 0x03; /** Tells the server to execute a query. */ - const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ - const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ - const COM_DROP_DB = 0x06; /** Currently refused by the server. */ - const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ - const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ - const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ - const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ - const COM_CONNECT = 0x0B; /** Currently refused by the server. */ - const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ - const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ - const COM_PING = 0x0E; /** Check if the server is alive. */ - const COM_TIME = 0x0F; /** Currently refused by the server. */ - const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ - const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ - const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ - const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ - const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ - const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ - const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ - const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ - const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ - const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ - const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ - const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ - const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ - const COM_DAEMON = 0x1D; /** Currently refused by the server. */ - const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ - const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ - const COM_CLONE = 0x20; /** Tells the server to clone a server. */ - - // Special packet markers - const OK_PACKET = 0x00; - const EOF_PACKET = 0xfe; - const ERR_PACKET = 0xff; - const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) - - // Auth specific markers for caching_sha2_password - const CACHING_SHA2_FAST_AUTH = 3; - const CACHING_SHA2_FULL_AUTH = 4; - const AUTH_PLUGIN_NAME = 'caching_sha2_password'; - - // Field types - const FIELD_TYPE_DECIMAL = 0x00; - const FIELD_TYPE_TINY = 0x01; - const FIELD_TYPE_SHORT = 0x02; - const FIELD_TYPE_LONG = 0x03; - const FIELD_TYPE_FLOAT = 0x04; - const FIELD_TYPE_DOUBLE = 0x05; - const FIELD_TYPE_NULL = 0x06; - const FIELD_TYPE_TIMESTAMP = 0x07; - const FIELD_TYPE_LONGLONG = 0x08; - const FIELD_TYPE_INT24 = 0x09; - const FIELD_TYPE_DATE = 0x0a; - const FIELD_TYPE_TIME = 0x0b; - const FIELD_TYPE_DATETIME = 0x0c; - const FIELD_TYPE_YEAR = 0x0d; - const FIELD_TYPE_NEWDATE = 0x0e; - const FIELD_TYPE_VARCHAR = 0x0f; - const FIELD_TYPE_BIT = 0x10; - const FIELD_TYPE_NEWDECIMAL = 0xf6; - const FIELD_TYPE_ENUM = 0xf7; - const FIELD_TYPE_SET = 0xf8; - const FIELD_TYPE_TINY_BLOB = 0xf9; - const FIELD_TYPE_MEDIUM_BLOB = 0xfa; - const FIELD_TYPE_LONG_BLOB = 0xfb; - const FIELD_TYPE_BLOB = 0xfc; - const FIELD_TYPE_VAR_STRING = 0xfd; - const FIELD_TYPE_STRING = 0xfe; - const FIELD_TYPE_GEOMETRY = 0xff; - - // Field flags - const NOT_NULL_FLAG = 0x1; - const PRI_KEY_FLAG = 0x2; - const UNIQUE_KEY_FLAG = 0x4; - const MULTIPLE_KEY_FLAG = 0x8; - const BLOB_FLAG = 0x10; - const UNSIGNED_FLAG = 0x20; - const ZEROFILL_FLAG = 0x40; - const BINARY_FLAG = 0x80; - const ENUM_FLAG = 0x100; - const AUTO_INCREMENT_FLAG = 0x200; - const TIMESTAMP_FLAG = 0x400; - const SET_FLAG = 0x800; - - // Character set and collation constants (using utf8mb4 general collation) - const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) - - // Max packet length constant - const MAX_PACKET_LENGTH = 0x00ffffff; - - private $current_db = ''; - - // Helper: Packets assembly and parsing - public static function encodeInt8(int $val): string { - return chr($val & 0xff); - } - public static function encodeInt16(int $val): string { - return pack('v', $val & 0xffff); - } - public static function encodeInt24(int $val): string { - // 3-byte little-endian integer - return substr(pack('V', $val & 0xffffff), 0, 3); - } - public static function encodeInt32(int $val): string { - return pack('V', $val); - } - public static function encodeLengthEncodedInt(int $val): string { - // Encodes an integer in MySQL's length-encoded format - if ($val < 0xfb) { - return chr($val); - } elseif ($val <= 0xffff) { - return "\xfc" . self::encodeInt16($val); - } elseif ($val <= 0xffffff) { - return "\xfd" . self::encodeInt24($val); - } else { - return "\xfe" . pack('P', $val); // 8-byte little-endian for 64-bit - } - } - public static function encodeLengthEncodedString(string $str): string { - return self::encodeLengthEncodedInt(strlen($str)) . $str; - } - - // Hashing for caching_sha2_password (fast auth algorithm) - public static function sha256Hash(string $password, string $salt): string { - $stage1 = hash('sha256', $password, true); - $stage2 = hash('sha256', $stage1, true); - $scramble = hash('sha256', $stage2 . substr($salt, 0, 20), true); - // XOR stage1 and scramble to get token - return $stage1 ^ $scramble; - } - - // Build initial handshake packet (server greeting) - public static function buildHandshakePacket(int $connId, string &$authPluginData): string { - $protocol_version = 0x0a; // Handshake protocol version (10) - $server_version = "5.7.30-php-mysql-server"; // Fake server version - // Generate random auth plugin data (20-byte salt) - $salt1 = random_bytes(8); - $salt2 = random_bytes(12); // total salt length = 8+12 = 20 bytes (with filler) - $authPluginData = $salt1 . $salt2; - // Lower 2 bytes of capability flags - $capFlagsLower = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) & 0xffff; - // Upper 2 bytes of capability flags - $capFlagsUpper = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) >> 16; - $charset = self::CHARSET_UTF8MB4; - $statusFlags = self::SERVER_STATUS_AUTOCOMMIT; - - // Assemble handshake packet payload - $payload = chr($protocol_version); - $payload .= $server_version . "\0"; - $payload .= self::encodeInt32($connId); - $payload .= $salt1; - $payload .= "\0"; // filler byte - $payload .= self::encodeInt16($capFlagsLower); - $payload .= chr($charset); - $payload .= self::encodeInt16($statusFlags); - $payload .= self::encodeInt16($capFlagsUpper); - $payload .= chr(strlen($authPluginData) + 1); // auth plugin data length (salt + \0) - $payload .= str_repeat("\0", 10); // 10-byte reserved filler - $payload .= $salt2; - $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 - $payload .= self::AUTH_PLUGIN_NAME . "\0"; - return $payload; - } - - // Build OK packet (after successful authentication or query execution) - public static function buildOkPacket(int $affectedRows = 0, int $lastInsertId = 0): string { - $payload = chr(self::OK_PACKET); - $payload .= self::encodeLengthEncodedInt($affectedRows); - $payload .= self::encodeLengthEncodedInt($lastInsertId); - $payload .= self::encodeInt16(self::SERVER_STATUS_AUTOCOMMIT); // server status - $payload .= self::encodeInt16(0); // no warning count - // No human-readable message for simplicity - return $payload; - } - - // Build ERR packet (for errors) - public static function buildErrPacket(int $errorCode, string $sqlState, string $message): string { - $payload = chr(self::ERR_PACKET); - $payload .= self::encodeInt16($errorCode); - $payload .= "#" . strtoupper($sqlState); - $payload .= $message; - return $payload; - } - - // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) - public static function buildResultSetPackets(SelectQueryResult $result): string { - $sequenceId = 1; // Sequence starts at 1 for resultset (after COM_QUERY) - $packetStream = ''; - - // 1. Column count packet (length-encoded integer for number of columns) - $colCount = count($result->columns); - $colCountPayload = self::encodeLengthEncodedInt($colCount); - $packetStream .= self::wrapPacket($colCountPayload, $sequenceId++); - - // 2. Column definition packets for each column - foreach ($result->columns as $col) { - // Protocol::ColumnDefinition41 format:] - $colPayload = self::encodeLengthEncodedString($col['catalog'] ?? 'sqlite'); - $colPayload .= self::encodeLengthEncodedString($col['schema'] ?? ''); + const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ + const COM_QUERY = 0x03; /** Tells the server to execute a query. */ + const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ + const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ + const COM_DROP_DB = 0x06; /** Currently refused by the server. */ + const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ + const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ + const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ + const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ + const COM_CONNECT = 0x0B; /** Currently refused by the server. */ + const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ + const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ + const COM_PING = 0x0E; /** Check if the server is alive. */ + const COM_TIME = 0x0F; /** Currently refused by the server. */ + const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ + const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ + const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ + const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ + const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ + const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ + const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ + const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ + const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ + const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ + const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ + const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ + const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ + const COM_DAEMON = 0x1D; /** Currently refused by the server. */ + const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ + const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ + const COM_CLONE = 0x20; /** Tells the server to clone a server. */ + + // Special packet markers + const OK_PACKET = 0x00; + const EOF_PACKET = 0xfe; + const ERR_PACKET = 0xff; + const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) + + // Auth specific markers for caching_sha2_password + const CACHING_SHA2_FAST_AUTH = 3; + const CACHING_SHA2_FULL_AUTH = 4; + const AUTH_PLUGIN_NAME = 'caching_sha2_password'; + + // Field types + const FIELD_TYPE_DECIMAL = 0x00; + const FIELD_TYPE_TINY = 0x01; + const FIELD_TYPE_SHORT = 0x02; + const FIELD_TYPE_LONG = 0x03; + const FIELD_TYPE_FLOAT = 0x04; + const FIELD_TYPE_DOUBLE = 0x05; + const FIELD_TYPE_NULL = 0x06; + const FIELD_TYPE_TIMESTAMP = 0x07; + const FIELD_TYPE_LONGLONG = 0x08; + const FIELD_TYPE_INT24 = 0x09; + const FIELD_TYPE_DATE = 0x0a; + const FIELD_TYPE_TIME = 0x0b; + const FIELD_TYPE_DATETIME = 0x0c; + const FIELD_TYPE_YEAR = 0x0d; + const FIELD_TYPE_NEWDATE = 0x0e; + const FIELD_TYPE_VARCHAR = 0x0f; + const FIELD_TYPE_BIT = 0x10; + const FIELD_TYPE_NEWDECIMAL = 0xf6; + const FIELD_TYPE_ENUM = 0xf7; + const FIELD_TYPE_SET = 0xf8; + const FIELD_TYPE_TINY_BLOB = 0xf9; + const FIELD_TYPE_MEDIUM_BLOB = 0xfa; + const FIELD_TYPE_LONG_BLOB = 0xfb; + const FIELD_TYPE_BLOB = 0xfc; + const FIELD_TYPE_VAR_STRING = 0xfd; + const FIELD_TYPE_STRING = 0xfe; + const FIELD_TYPE_GEOMETRY = 0xff; + + // Field flags + const NOT_NULL_FLAG = 0x1; + const PRI_KEY_FLAG = 0x2; + const UNIQUE_KEY_FLAG = 0x4; + const MULTIPLE_KEY_FLAG = 0x8; + const BLOB_FLAG = 0x10; + const UNSIGNED_FLAG = 0x20; + const ZEROFILL_FLAG = 0x40; + const BINARY_FLAG = 0x80; + const ENUM_FLAG = 0x100; + const AUTO_INCREMENT_FLAG = 0x200; + const TIMESTAMP_FLAG = 0x400; + const SET_FLAG = 0x800; + + // Character set and collation constants (using utf8mb4 general collation) + const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) + + // Max packet length constant + const MAX_PACKET_LENGTH = 0x00ffffff; + + private $current_db = ''; + + // Helper: Packets assembly and parsing + public static function encode_int_8( int $val ): string { + return chr( $val & 0xff ); + } + + public static function encode_int_16( int $val ): string { + return pack( 'v', $val & 0xffff ); + } + + public static function encode_int_24( int $val ): string { + // 3-byte little-endian integer + return substr( pack( 'V', $val & 0xffffff ), 0, 3 ); + } + + public static function encode_int_32( int $val ): string { + return pack( 'V', $val ); + } + + public static function encode_length_encoded_int( int $val ): string { + // Encodes an integer in MySQL's length-encoded format + if ( $val < 0xfb ) { + return chr( $val ); + } elseif ( $val <= 0xffff ) { + return "\xfc" . self::encode_int_16( $val ); + } elseif ( $val <= 0xffffff ) { + return "\xfd" . self::encode_int_24( $val ); + } else { + return "\xfe" . pack( 'P', $val ); // 8-byte little-endian for 64-bit + } + } + + public static function encode_length_encoded_string( string $str ): string { + return self::encode_length_encoded_int( strlen( $str ) ) . $str; + } + + // Hashing for caching_sha2_password (fast auth algorithm) + public static function sha_256_hash( string $password, string $salt ): string { + $stage1 = hash( 'sha256', $password, true ); + $stage2 = hash( 'sha256', $stage1, true ); + $scramble = hash( 'sha256', $stage2 . substr( $salt, 0, 20 ), true ); + // XOR stage1 and scramble to get token + return $stage1 ^ $scramble; + } + + // Build initial handshake packet (server greeting) + public static function build_handshake_packet( int $conn_id, string &$auth_plugin_data ): string { + $protocol_version = 0x0a; // Handshake protocol version (10) + $server_version = '5.7.30-php-mysql-server'; // Fake server version + // Generate random auth plugin data (20-byte salt) + $salt1 = random_bytes( 8 ); + $salt2 = random_bytes( 12 ); // total salt length = 8+12 = 20 bytes (with filler) + $auth_plugin_data = $salt1 . $salt2; + // Lower 2 bytes of capability flags + $cap_flags_lower = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) & 0xffff; + // Upper 2 bytes of capability flags + $cap_flags_upper = ( + self::CLIENT_PROTOCOL_41 | + self::CLIENT_SECURE_CONNECTION | + self::CLIENT_PLUGIN_AUTH | + self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ) >> 16; + $charset = self::CHARSET_UTF8MB4; + $status_flags = self::SERVER_STATUS_AUTOCOMMIT; + + // Assemble handshake packet payload + $payload = chr( $protocol_version ); + $payload .= $server_version . "\0"; + $payload .= self::encode_int_32( $conn_id ); + $payload .= $salt1; + $payload .= "\0"; // filler byte + $payload .= self::encode_int_16( $cap_flags_lower ); + $payload .= chr( $charset ); + $payload .= self::encode_int_16( $status_flags ); + $payload .= self::encode_int_16( $cap_flags_upper ); + $payload .= chr( strlen( $auth_plugin_data ) + 1 ); // auth plugin data length (salt + \0) + $payload .= str_repeat( "\0", 10 ); // 10-byte reserved filler + $payload .= $salt2; + $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 + $payload .= self::AUTH_PLUGIN_NAME . "\0"; + return $payload; + } + + // Build OK packet (after successful authentication or query execution) + public static function build_ok_packet( int $affected_rows = 0, int $last_insert_id = 0 ): string { + $payload = chr( self::OK_PACKET ); + $payload .= self::encode_length_encoded_int( $affected_rows ); + $payload .= self::encode_length_encoded_int( $last_insert_id ); + $payload .= self::encode_int_16( self::SERVER_STATUS_AUTOCOMMIT ); // server status + $payload .= self::encode_int_16( 0 ); // no warning count + // No human-readable message for simplicity + return $payload; + } + + // Build ERR packet (for errors) + public static function build_err_packet( int $error_code, string $sql_state, string $message ): string { + $payload = chr( self::ERR_PACKET ); + $payload .= self::encode_int_16( $error_code ); + $payload .= '#' . strtoupper( $sql_state ); + $payload .= $message; + return $payload; + } + + // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) + public static function build_result_set_packets( SelectQueryResult $result ): string { + $sequence_id = 1; // Sequence starts at 1 for resultset (after COM_QUERY) + $packet_stream = ''; + + // 1. Column count packet (length-encoded integer for number of columns) + $col_count = count( $result->columns ); + $col_count_payload = self::encode_length_encoded_int( $col_count ); + $packet_stream .= self::wrap_packet( $col_count_payload, $sequence_id++ ); + + // 2. Column definition packets for each column + foreach ( $result->columns as $col ) { + // Protocol::ColumnDefinition41 format:] + $col_payload = self::encode_length_encoded_string( $col['catalog'] ?? 'sqlite' ); + $col_payload .= self::encode_length_encoded_string( $col['schema'] ?? '' ); // Table alias - $colPayload .= self::encodeLengthEncodedString($col['table'] ?? ''); + $col_payload .= self::encode_length_encoded_string( $col['table'] ?? '' ); // Original table name - $colPayload .= self::encodeLengthEncodedString($col['orgTable'] ?? ''); + $col_payload .= self::encode_length_encoded_string( $col['orgTable'] ?? '' ); // Column alias - $colPayload .= self::encodeLengthEncodedString($col['name']); + $col_payload .= self::encode_length_encoded_string( $col['name'] ); // Original column name - $colPayload .= self::encodeLengthEncodedString($col['orgName'] ?? $col['name']); + $col_payload .= self::encode_length_encoded_string( $col['orgName'] ?? $col['name'] ); // Length of the remaining fixed fields. @TODO: What does that mean? - $colPayload .= self::encodeLengthEncodedInt($col['fixedLen'] ?? 0x0c); - $colPayload .= self::encodeInt16($col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4); - $colPayload .= self::encodeInt32($col['length']); - $colPayload .= self::encodeInt8($col['type']); - $colPayload .= self::encodeInt16($col['flags']); - $colPayload .= self::encodeInt8($col['decimals']); - $colPayload .= "\x00"; // filler (1 byte, reserved) - - $packetStream .= self::wrapPacket($colPayload, $sequenceId++); - } - // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) - $eofPayload = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); - $packetStream .= self::wrapPacket($eofPayload, $sequenceId++); - - // 4. Row data packets (each row is a series of length-encoded values) - foreach ($result->rows as $row) { - $rowPayload = ""; - // Iterate through columns in the defined order to match column definitions - foreach ($result->columns as $col) { - $columnName = $col['name']; - $val = $row->{$columnName} ?? null; - - if ($val === null) { - // NULL is represented by 0xfb (NULL_VALUE) - $rowPayload .= "\xfb"; - } else { - $valStr = (string)$val; - $rowPayload .= self::encodeLengthEncodedString($valStr); - } - } - $packetStream .= self::wrapPacket($rowPayload, $sequenceId++); - } - - // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) - $eofPayload2 = chr(self::EOF_PACKET) . self::encodeInt16(0) . self::encodeInt16(0); - $packetStream .= self::wrapPacket($eofPayload2, $sequenceId++); - - return $packetStream; - } - - // Helper to wrap a payload into a packet with length and sequence id - public static function wrapPacket(string $payload, int $sequenceId): string { - $length = strlen($payload); - $header = self::encodeInt24($length) . self::encodeInt8($sequenceId); - return $header . $payload; - } + $col_payload .= self::encode_length_encoded_int( $col['fixedLen'] ?? 0x0c ); + $col_payload .= self::encode_int_16( $col['charset'] ?? MySQLProtocol::CHARSET_UTF8MB4 ); + $col_payload .= self::encode_int_32( $col['length'] ); + $col_payload .= self::encode_int_8( $col['type'] ); + $col_payload .= self::encode_int_16( $col['flags'] ); + $col_payload .= self::encode_int_8( $col['decimals'] ); + $col_payload .= "\x00"; // filler (1 byte, reserved) + + $packet_stream .= self::wrap_packet( $col_payload, $sequence_id++ ); + } + // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) + $eof_payload = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); + $packet_stream .= self::wrap_packet( $eof_payload, $sequence_id++ ); + + // 4. Row data packets (each row is a series of length-encoded values) + foreach ( $result->rows as $row ) { + $row_payload = ''; + // Iterate through columns in the defined order to match column definitions + foreach ( $result->columns as $col ) { + $column_name = $col['name']; + $val = $row->{$column_name} ?? null; + + if ( null === $val ) { + // NULL is represented by 0xfb (NULL_VALUE) + $row_payload .= "\xfb"; + } else { + $val_str = (string) $val; + $row_payload .= self::encode_length_encoded_string( $val_str ); + } + } + $packet_stream .= self::wrap_packet( $row_payload, $sequence_id++ ); + } + + // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) + $eof_payload_2 = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); + $packet_stream .= self::wrap_packet( $eof_payload_2, $sequence_id++ ); + + return $packet_stream; + } + + // Helper to wrap a payload into a packet with length and sequence id + public static function wrap_packet( string $payload, int $sequence_id ): string { + $length = strlen( $payload ); + $header = self::encode_int_24( $length ) . self::encode_int_8( $sequence_id ); + return $header . $payload; + } } class IncompleteInputException extends MySQLServerException { - public function __construct(string $message = "Incomplete input data, more bytes needed") { - parent::__construct($message); - } + public function __construct( string $message = 'Incomplete input data, more bytes needed' ) { + parent::__construct( $message ); + } } class MySQLGateway { - private $query_handler; - private $connection_id; - private $auth_plugin_data; - private $sequence_id; - private $authenticated = false; - private $buffer = ''; - - public function __construct(MySQLQueryHandler $query_handler) { - $this->query_handler = $query_handler; - $this->connection_id = random_int(1, 1000); - $this->auth_plugin_data = ""; - $this->sequence_id = 0; - } - - /** - * Get the initial handshake packet to send to the client - * - * @return string Binary packet data to send to client - */ - public function getInitialHandshake(): string { - $handshakePayload = MySQLProtocol::buildHandshakePacket($this->connection_id, $this->auth_plugin_data); - return MySQLProtocol::encodeInt24(strlen($handshakePayload)) . - MySQLProtocol::encodeInt8($this->sequence_id++) . - $handshakePayload; - } - - /** - * Process bytes received from the client - * - * @param string $data Binary data received from client - * @return string|null Response to send back to client, or null if no response needed - * @throws IncompleteInputException When more data is needed to complete a packet - */ - public function receiveBytes(string $data): ?string { - // Append new data to existing buffer - $this->buffer .= $data; - - // Check if we have enough data for a header - if (strlen($this->buffer) < 4) { - throw new IncompleteInputException("Incomplete packet header, need more bytes"); - } - - // Parse packet header - $packetLength = unpack('V', substr($this->buffer, 0, 3) . "\x00")[1]; - $receivedSequenceId = ord($this->buffer[3]); - - // Check if we have the complete packet - $totalPacketLength = 4 + $packetLength; - if (strlen($this->buffer) < $totalPacketLength) { - throw new IncompleteInputException( - "Incomplete packet payload, have " . strlen($this->buffer) . - " bytes, need " . $totalPacketLength . " bytes" - ); - } - - // Extract the complete packet - $packet = substr($this->buffer, 0, $totalPacketLength); - - // Remove the processed packet from the buffer - $this->buffer = substr($this->buffer, $totalPacketLength); - - // Process the packet - $payload = substr($packet, 4, $packetLength); - - // If not authenticated yet, process authentication - if (!$this->authenticated) { - return $this->processAuthentication($payload); - } - - // Otherwise, process as a command - $command = ord($payload[0]); - if ($command === MySQLProtocol::COM_QUERY) { - $query = substr($payload, 1); - return $this->processQuery($query); - } elseif ($command === MySQLProtocol::COM_INIT_DB) { - return $this->processQuery('USE ' . substr($payload, 1)); - } elseif ($command === MySQLProtocol::COM_QUIT) { - return ''; - } else { - // Unsupported command - $errPacket = MySQLProtocol::buildErrPacket(0x04D2, "HY000", "Unsupported command"); - return MySQLProtocol::encodeInt24(strlen($errPacket)) . - MySQLProtocol::encodeInt8(1) . - $errPacket; - } - } - - /** - * Process authentication packet from client - * - * @param string $payload Authentication packet payload - * @return string Response packet to send back - */ - private function processAuthentication(string $payload): string { - $offset = 0; - $payloadLength = strlen($payload); - - $capabilityFlags = $this->readUnsignedIntLittleEndian($payload, $offset, 4); - $offset += 4; - - $clientMaxPacketSize = $this->readUnsignedIntLittleEndian($payload, $offset, 4); - $offset += 4; - - $clientCharacterSet = 0; - if ($offset < $payloadLength) { - $clientCharacterSet = ord($payload[$offset]); - } - $offset += 1; - - // Skip reserved bytes (always zero) - $offset = min($payloadLength, $offset + 23); - - $username = $this->readNullTerminatedString($payload, $offset); - - $authResponse = ''; - if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) { - $authResponseLength = $this->readLengthEncodedInt($payload, $offset); - $authResponse = substr($payload, $offset, $authResponseLength); - $offset = min($payloadLength, $offset + $authResponseLength); - } elseif ($capabilityFlags & MySQLProtocol::CLIENT_SECURE_CONNECTION) { - $authResponseLength = 0; - if ($offset < $payloadLength) { - $authResponseLength = ord($payload[$offset]); - } - $offset += 1; - $authResponse = substr($payload, $offset, $authResponseLength); - $offset = min($payloadLength, $offset + $authResponseLength); - } else { - $authResponse = $this->readNullTerminatedString($payload, $offset); - } - - $database = ''; - if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_WITH_DB) { - $database = $this->readNullTerminatedString($payload, $offset); - } - - $authPluginName = ''; - if ($capabilityFlags & MySQLProtocol::CLIENT_PLUGIN_AUTH) { - $authPluginName = $this->readNullTerminatedString($payload, $offset); - } - - if ($capabilityFlags & MySQLProtocol::CLIENT_CONNECT_ATTRS) { - $attrsLength = $this->readLengthEncodedInt($payload, $offset); - $offset = min($payloadLength, $offset + $attrsLength); - } - - $this->authenticated = true; - $this->sequence_id = 2; - - $responsePackets = ''; - - if ($authPluginName === MySQLProtocol::AUTH_PLUGIN_NAME) { - $fastAuthPayload = chr(MySQLProtocol::AUTH_MORE_DATA) . chr(MySQLProtocol::CACHING_SHA2_FAST_AUTH); - $responsePackets .= MySQLProtocol::encodeInt24(strlen($fastAuthPayload)); - $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); - $responsePackets .= $fastAuthPayload; - } - - $okPacket = MySQLProtocol::buildOkPacket(); - $responsePackets .= MySQLProtocol::encodeInt24(strlen($okPacket)); - $responsePackets .= MySQLProtocol::encodeInt8($this->sequence_id++); - $responsePackets .= $okPacket; - - return $responsePackets; - } - - private function readUnsignedIntLittleEndian(string $payload, int $offset, int $length): int { - $slice = substr($payload, $offset, $length); - if ($slice === '' || $length <= 0) { - return 0; - } - - switch ($length) { - case 1: - return ord($slice[0]); - case 2: - $padded = str_pad($slice, 2, "\x00", STR_PAD_RIGHT); - $unpacked = unpack('v', $padded); - return $unpacked[1] ?? 0; - case 3: - case 4: - default: - $padded = str_pad($slice, 4, "\x00", STR_PAD_RIGHT); - $unpacked = unpack('V', $padded); - return $unpacked[1] ?? 0; - } - } - - private function readNullTerminatedString(string $payload, int &$offset): string { - $nullPosition = strpos($payload, "\0", $offset); - if ($nullPosition === false) { - $result = substr($payload, $offset); - $offset = strlen($payload); - return $result; - } - - $result = substr($payload, $offset, $nullPosition - $offset); - $offset = $nullPosition + 1; - return $result; - } - - private function readLengthEncodedInt(string $payload, int &$offset): int { - if ($offset >= strlen($payload)) { - return 0; - } - - $first = ord($payload[$offset]); - $offset += 1; - - if ($first < 0xfb) { - return $first; - } - - if ($first === 0xfb) { - return 0; - } - - if ($first === 0xfc) { - $value = $this->readUnsignedIntLittleEndian($payload, $offset, 2); - $offset += 2; - return $value; - } - - if ($first === 0xfd) { - $value = $this->readUnsignedIntLittleEndian($payload, $offset, 3); - $offset += 3; - return $value; - } - - // 0xfe indicates an 8-byte integer - $value = 0; - $slice = substr($payload, $offset, 8); - if ($slice !== '') { - $slice = str_pad($slice, 8, "\x00"); - $value = unpack('P', $slice)[1]; - } - $offset += 8; - return (int) $value; - } - - /** - * Process a query from the client - * - * @param string $query SQL query to process - * @return string Response packet to send back - */ - private function processQuery(string $query): string { - $query = trim($query); - - try { - $result = $this->query_handler->handleQuery($query); - return $result->toPackets(); - } catch (MySQLServerException $e) { - $errPacket = MySQLProtocol::buildErrPacket(0x04A7, "42000", "Syntax error or unsupported query: " . $e->getMessage()); - return MySQLProtocol::encodeInt24(strlen($errPacket)) . - MySQLProtocol::encodeInt8(1) . - $errPacket; - } - } - - /** - * Reset the server state for a new connection - */ - public function reset(): void { - $this->connection_id = random_int(1, 1000); - $this->auth_plugin_data = ""; - $this->sequence_id = 0; - $this->authenticated = false; - $this->buffer = ''; - } - - /** - * Check if there's any buffered data that hasn't been processed yet - * - * @return bool True if there's data in the buffer - */ - public function hasBufferedData(): bool { - return !empty($this->buffer); - } - - /** - * Get the number of bytes currently in the buffer - * - * @return int Number of bytes in buffer - */ - public function getBufferSize(): int { - return strlen($this->buffer); - } + private $query_handler; + private $connection_id; + private $auth_plugin_data; + private $sequence_id; + private $authenticated = false; + private $buffer = ''; + + public function __construct( MySQLQueryHandler $query_handler ) { + $this->query_handler = $query_handler; + $this->connection_id = random_int( 1, 1000 ); + $this->auth_plugin_data = ''; + $this->sequence_id = 0; + } + + /** + * Get the initial handshake packet to send to the client + * + * @return string Binary packet data to send to client + */ + public function get_initial_handshake(): string { + $handshake_payload = MySQLProtocol::build_handshake_packet( $this->connection_id, $this->auth_plugin_data ); + return MySQLProtocol::encode_int_24( strlen( $handshake_payload ) ) . + MySQLProtocol::encode_int_8( $this->sequence_id++ ) . + $handshake_payload; + } + + /** + * Process bytes received from the client + * + * @param string $data Binary data received from client + * @return string|null Response to send back to client, or null if no response needed + * @throws IncompleteInputException When more data is needed to complete a packet + */ + public function receive_bytes( string $data ): ?string { + // Append new data to existing buffer + $this->buffer .= $data; + + // Check if we have enough data for a header + if ( strlen( $this->buffer ) < 4 ) { + throw new IncompleteInputException( 'Incomplete packet header, need more bytes' ); + } + + // Parse packet header + $packet_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; + $received_sequence_id = ord( $this->buffer[3] ); + + // Check if we have the complete packet + $total_packet_length = 4 + $packet_length; + if ( strlen( $this->buffer ) < $total_packet_length ) { + throw new IncompleteInputException( + 'Incomplete packet payload, have ' . strlen( $this->buffer ) . + ' bytes, need ' . $total_packet_length . ' bytes' + ); + } + + // Extract the complete packet + $packet = substr( $this->buffer, 0, $total_packet_length ); + + // Remove the processed packet from the buffer + $this->buffer = substr( $this->buffer, $total_packet_length ); + + // Process the packet + $payload = substr( $packet, 4, $packet_length ); + + // If not authenticated yet, process authentication + if ( ! $this->authenticated ) { + return $this->process_authentication( $payload ); + } + + // Otherwise, process as a command + $command = ord( $payload[0] ); + if ( MySQLProtocol::COM_QUERY === $command ) { + $query = substr( $payload, 1 ); + return $this->process_query( $query ); + } elseif ( MySQLProtocol::COM_INIT_DB === $command ) { + return $this->process_query( 'USE ' . substr( $payload, 1 ) ); + } elseif ( MySQLProtocol::COM_QUIT === $command ) { + return ''; + } else { + // Unsupported command + $err_packet = MySQLProtocol::build_err_packet( 0x04D2, 'HY000', 'Unsupported command' ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . + MySQLProtocol::encode_int_8( 1 ) . + $err_packet; + } + } + + /** + * Process authentication packet from client + * + * @param string $payload Authentication packet payload + * @return string Response packet to send back + */ + private function process_authentication( string $payload ): string { + $offset = 0; + $payload_length = strlen( $payload ); + + $capability_flags = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); + $offset += 4; + + $client_max_packet_size = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); + $offset += 4; + + $client_character_set = 0; + if ( $offset < $payload_length ) { + $client_character_set = ord( $payload[ $offset ] ); + } + $offset += 1; + + // Skip reserved bytes (always zero) + $offset = min( $payload_length, $offset + 23 ); + + $username = $this->read_null_terminated_string( $payload, $offset ); + + $auth_response = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response_length = $this->read_length_encoded_int( $payload, $offset ); + $auth_response = substr( $payload, $offset, $auth_response_length ); + $offset = min( $payload_length, $offset + $auth_response_length ); + } elseif ( $capability_flags & MySQLProtocol::CLIENT_SECURE_CONNECTION ) { + $auth_response_length = 0; + if ( $offset < $payload_length ) { + $auth_response_length = ord( $payload[ $offset ] ); + } + $offset += 1; + $auth_response = substr( $payload, $offset, $auth_response_length ); + $offset = min( $payload_length, $offset + $auth_response_length ); + } else { + $auth_response = $this->read_null_terminated_string( $payload, $offset ); + } + + $database = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_WITH_DB ) { + $database = $this->read_null_terminated_string( $payload, $offset ); + } + + $auth_plugin_name = ''; + if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH ) { + $auth_plugin_name = $this->read_null_terminated_string( $payload, $offset ); + } + + if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_ATTRS ) { + $attrs_length = $this->read_length_encoded_int( $payload, $offset ); + $offset = min( $payload_length, $offset + $attrs_length ); + } + + $this->authenticated = true; + $this->sequence_id = 2; + + $response_packets = ''; + + if ( MySQLProtocol::AUTH_PLUGIN_NAME === $auth_plugin_name ) { + $fast_auth_payload = chr( MySQLProtocol::AUTH_MORE_DATA ) . chr( MySQLProtocol::CACHING_SHA2_FAST_AUTH ); + $response_packets .= MySQLProtocol::encode_int_24( strlen( $fast_auth_payload ) ); + $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $fast_auth_payload; + } + + $ok_packet = MySQLProtocol::build_ok_packet(); + $response_packets .= MySQLProtocol::encode_int_24( strlen( $ok_packet ) ); + $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); + $response_packets .= $ok_packet; + + return $response_packets; + } + + private function read_unsigned_int_little_endian( string $payload, int $offset, int $length ): int { + $slice = substr( $payload, $offset, $length ); + if ( '' === $slice || $length <= 0 ) { + return 0; + } + + switch ( $length ) { + case 1: + return ord( $slice[0] ); + case 2: + $padded = str_pad( $slice, 2, "\x00", STR_PAD_RIGHT ); + $unpacked = unpack( 'v', $padded ); + return $unpacked[1] ?? 0; + case 3: + case 4: + default: + $padded = str_pad( $slice, 4, "\x00", STR_PAD_RIGHT ); + $unpacked = unpack( 'V', $padded ); + return $unpacked[1] ?? 0; + } + } + + private function read_null_terminated_string( string $payload, int &$offset ): string { + $null_position = strpos( $payload, "\0", $offset ); + if ( false === $null_position ) { + $result = substr( $payload, $offset ); + $offset = strlen( $payload ); + return $result; + } + + $result = substr( $payload, $offset, $null_position - $offset ); + $offset = $null_position + 1; + return $result; + } + + private function read_length_encoded_int( string $payload, int &$offset ): int { + if ( $offset >= strlen( $payload ) ) { + return 0; + } + + $first = ord( $payload[ $offset ] ); + $offset += 1; + + if ( $first < 0xfb ) { + return $first; + } + + if ( 0xfb === $first ) { + return 0; + } + + if ( 0xfc === $first ) { + $value = $this->read_unsigned_int_little_endian( $payload, $offset, 2 ); + $offset += 2; + return $value; + } + + if ( 0xfd === $first ) { + $value = $this->read_unsigned_int_little_endian( $payload, $offset, 3 ); + $offset += 3; + return $value; + } + + // 0xfe indicates an 8-byte integer + $value = 0; + $slice = substr( $payload, $offset, 8 ); + if ( '' !== $slice ) { + $slice = str_pad( $slice, 8, "\x00" ); + $value = unpack( 'P', $slice )[1]; + } + $offset += 8; + return (int) $value; + } + + /** + * Process a query from the client + * + * @param string $query SQL query to process + * @return string Response packet to send back + */ + private function process_query( string $query ): string { + $query = trim( $query ); + + try { + $result = $this->query_handler->handle_query( $query ); + return $result->to_packets(); + } catch ( MySQLServerException $e ) { + $err_packet = MySQLProtocol::build_err_packet( 0x04A7, '42000', 'Syntax error or unsupported query: ' . $e->getMessage() ); + return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . + MySQLProtocol::encode_int_8( 1 ) . + $err_packet; + } + } + + /** + * Reset the server state for a new connection + */ + public function reset(): void { + $this->connection_id = random_int( 1, 1000 ); + $this->auth_plugin_data = ''; + $this->sequence_id = 0; + $this->authenticated = false; + $this->buffer = ''; + } + + /** + * Check if there's any buffered data that hasn't been processed yet + * + * @return bool True if there's data in the buffer + */ + public function has_buffered_data(): bool { + return ! empty( $this->buffer ); + } + + /** + * Get the number of bytes currently in the buffer + * + * @return int Number of bytes in buffer + */ + public function get_buffer_size(): int { + return strlen( $this->buffer ); + } } class SingleUseMySQLSocketServer { - private $server; - private $socket; - private $port; - - public function __construct(MySQLQueryHandler $query_handler, $options = []) { - $this->server = new MySQLGateway($query_handler); - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - socket_bind($this->socket, '0.0.0.0', $this->port); - socket_listen($this->socket); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - - // Accept a single client for simplicity - $client = socket_accept($this->socket); - if (!$client) { - exit("Failed to accept connection\n"); - } - $this->handleClient($client); - socket_close($client); - socket_close($this->socket); - } - - private function handleClient($client) { - // Send initial handshake - $handshake = $this->server->getInitialHandshake(); - socket_write($client, $handshake); - - while (true) { - // Read available data (up to 4096 bytes at a time) - $data = @socket_read($client, 4096); - if ($data === false || $data === '') { - break; // connection closed - } - - try { - // Process the data - $response = $this->server->receiveBytes($data); - if ($response) { - socket_write($client, $response); - } - - // If there's still data in the buffer, process it immediately - while ($this->server->hasBufferedData()) { - try { - // Try to process more complete packets from the buffer - $response = $this->server->receiveBytes(''); - if ($response) { - socket_write($client, $response); - } - } catch (IncompleteInputException $e) { - // Not enough data to complete another packet, wait for more - break; - } - } - } catch (IncompleteInputException $e) { - // Not enough data yet, continue reading - continue; - } - } - - echo "Client disconnected, terminating the server.\n"; - $this->server->reset(); - } + private $server; + private $socket; + private $port; + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->server = new MySQLGateway( $query_handler ); + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $this->socket, '0.0.0.0', $this->port ); + socket_listen( $this->socket ); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + + // Accept a single client for simplicity + $client = socket_accept( $this->socket ); + if ( ! $client ) { + exit( "Failed to accept connection\n" ); + } + $this->handle_client( $client ); + socket_close( $client ); + socket_close( $this->socket ); + } + + private function handle_client( $client ) { + // Send initial handshake + $handshake = $this->server->get_initial_handshake(); + socket_write( $client, $handshake ); + + while ( true ) { + // Read available data (up to 4096 bytes at a time) + $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( false === $data || '' === $data ) { + break; // connection closed + } + + try { + // Process the data + $response = $this->server->receive_bytes( $data ); + if ( $response ) { + socket_write( $client, $response ); + } + + // If there's still data in the buffer, process it immediately + while ( $this->server->has_buffered_data() ) { + try { + // Try to process more complete packets from the buffer + $response = $this->server->receive_bytes( '' ); + if ( $response ) { + socket_write( $client, $response ); + } + } catch ( IncompleteInputException $e ) { + // Not enough data to complete another packet, wait for more + break; + } + } + } catch ( IncompleteInputException $e ) { + // Not enough data yet, continue reading + continue; + } + } + + echo "Client disconnected, terminating the server.\n"; + $this->server->reset(); + } } -if(!function_exists('post_message_to_js')) { - function post_message_to_js(string $message) { +if ( ! function_exists( 'post_message_to_js' ) ) { + function post_message_to_js( string $message ) { echo 'The "post_message_to_js" function is only available in WordPress Playground but you are running it in a standalone PHP environment.' . PHP_EOL; echo 'The message was: ' . $message . PHP_EOL; } } class MySQLSocketServer { - private $query_handler; - private $socket; - private $port; - private $clients = []; - private $clientServers = []; - - public function __construct(MySQLQueryHandler $query_handler, $options = []) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); - socket_bind($this->socket, '0.0.0.0', $this->port); - socket_listen($this->socket); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - while (true) { - // Prepare arrays for socket_select() - $read = array_merge([$this->socket], $this->clients); - $write = null; - $except = null; - - // Wait for activity on any socket - $select_result = socket_select($read, $write, $except, null); - if($select_result === false || $select_result <= 0) { + private $query_handler; + private $socket; + private $port; + private $clients = array(); + private $client_servers = array(); + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $this->socket, '0.0.0.0', $this->port ); + socket_listen( $this->socket ); + echo "MySQL PHP Server listening on port {$this->port}...\n"; + while ( true ) { + // Prepare arrays for socket_select() + $read = array_merge( array( $this->socket ), $this->clients ); + $write = null; + $except = null; + + // Wait for activity on any socket + $select_result = socket_select( $read, $write, $except, null ); + if ( false === $select_result || $select_result <= 0 ) { continue; } // Check if there's a new connection - if (in_array($this->socket, $read)) { - $client = socket_accept($this->socket); - if ($client) { + if ( in_array( $this->socket, $read, true ) ) { + $client = socket_accept( $this->socket ); + if ( $client ) { echo "New client connected.\n"; - $this->clients[] = $client; - $clientId = spl_object_id($client); - $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); + $this->clients[] = $client; + $client_id = spl_object_id( $client ); + $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); // Send initial handshake - echo "Pre handshake\n"; - $handshake = $this->clientServers[$clientId]->getInitialHandshake(); - echo "Post handshake\n"; - socket_write($client, $handshake); + echo "Pre handshake\n"; + $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); + echo "Post handshake\n"; + socket_write( $client, $handshake ); } // Remove server socket from read array - unset($read[array_search($this->socket, $read)]); + unset( $read[ array_search( $this->socket, $read, true ) ] ); } // Handle client activity - echo "Waiting for client activity\n"; - foreach ($read as $client) { - echo "calling socket_read\n"; - $data = @socket_read($client, 4096); - echo "socket_read returned\n"; - $display = ''; - for ($i = 0; $i < strlen($data); $i++) { - $byte = ord($data[$i]); - if ($byte >= 32 && $byte <= 126) { - // Printable ASCII character - $display .= $data[$i]; - } else { - // Non-printable, show as hex - $display .= sprintf('%02x ', $byte); - } - } - echo rtrim($display) . "\n"; - - if ($data === false || $data === '') { + echo "Waiting for client activity\n"; + foreach ( $read as $client ) { + echo "calling socket_read\n"; + $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + echo "socket_read returned\n"; + $display = ''; + for ( $i = 0; $i < strlen( $data ); $i++ ) { + $byte = ord( $data[ $i ] ); + if ( $byte >= 32 && $byte <= 126 ) { + // Printable ASCII character + $display .= $data[ $i ]; + } else { + // Non-printable, show as hex + $display .= sprintf( '%02x ', $byte ); + } + } + echo rtrim( $display ) . "\n"; + + if ( false === $data || '' === $data ) { // Client disconnected echo "Client disconnected.\n"; - $clientId = spl_object_id($client); - $this->clientServers[$clientId]->reset(); - unset($this->clientServers[$clientId]); - socket_close($client); - unset($this->clients[array_search($client, $this->clients)]); + $client_id = spl_object_id( $client ); + $this->client_servers[ $client_id ]->reset(); + unset( $this->client_servers[ $client_id ] ); + socket_close( $client ); + unset( $this->clients[ array_search( $client, $this->clients, true ) ] ); continue; } try { // Process the data - $clientId = spl_object_id($client); - echo "Receiving bytes\n"; - $response = $this->clientServers[$clientId]->receiveBytes($data); - if ($response) { + $client_id = spl_object_id( $client ); + echo "Receiving bytes\n"; + $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); + if ( $response ) { echo "Writing response\n"; echo $response; - socket_write($client, $response); + socket_write( $client, $response ); } - echo "Response written\n"; + echo "Response written\n"; // Process any buffered data - while ($this->clientServers[$clientId]->hasBufferedData()) { - echo "Processing buffered data\n"; + while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { + echo "Processing buffered data\n"; try { - $response = $this->clientServers[$clientId]->receiveBytes(''); - if ($response) { - socket_write($client, $response); + $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); + if ( $response ) { + socket_write( $client, $response ); } - } catch (IncompleteInputException $e) { + } catch ( IncompleteInputException $e ) { break; } } - echo "After the while loop\n"; - } catch (IncompleteInputException $e) { - echo "Incomplete input exception\n"; + echo "After the while loop\n"; + } catch ( IncompleteInputException $e ) { + echo "Incomplete input exception\n"; continue; } } - echo "restarting the while() loop!\n"; - } - } + echo "restarting the while() loop!\n"; + } + } } class MySQLPlaygroundYieldServer { - private $query_handler; - private $clients = []; - private $clientServers = []; - private $port; - - public function __construct(MySQLQueryHandler $query_handler, $options = []) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; - - // Main event loop - while (true) { - // Wait for a message from JS - $message = post_message_to_js(json_encode([ - 'type' => 'ready_for_event' - ])); - - $command = json_decode($message, true); - var_dump('decoded event', $command); - if (!$command || !isset($command['type'])) { - continue; - } - - switch ($command['type']) { - case 'new_connection': - $this->handleNewConnection($command['clientId']); - break; - - case 'data_received': - $this->handleDataReceived($command['clientId'], $command['data']); - break; - - case 'client_disconnected': - $this->handleClientDisconnected($command['clientId']); - break; - } - } - } - - private function handleNewConnection($clientId) { - echo "New client connected (ID: $clientId).\n"; - $this->clients[] = $clientId; - $this->clientServers[$clientId] = new MySQLGateway($this->query_handler); - - // Send initial handshake - $handshake = $this->clientServers[$clientId]->getInitialHandshake(); - $this->sendResponse($clientId, $handshake); - } - - private function handleDataReceived($clientId, $encodedData) { - if (!isset($this->clientServers[$clientId])) { - throw new IncompleteInputException('No client server found'); - return; - } - - $data = base64_decode($encodedData); - - try { - // Process the data - $response = $this->clientServers[$clientId]->receiveBytes($data); - if ($response) { - $this->sendResponse($clientId, $response); - } else { - throw new IncompleteInputException('No response from client'); - } - - // Process any buffered data - while ($this->clientServers[$clientId]->hasBufferedData()) { - try { - $response = $this->clientServers[$clientId]->receiveBytes(''); - if ($response) { - $this->sendResponse($clientId, $response); - } - } catch (IncompleteInputException $e) { + private $query_handler; + private $clients = array(); + private $client_servers = array(); + private $port; + + public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { + $this->query_handler = $query_handler; + $this->port = $options['port'] ?? 3306; + } + + public function start() { + echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; + + // Main event loop + while ( true ) { + // Wait for a message from JS + $message = post_message_to_js( + json_encode( + array( + 'type' => 'ready_for_event', + ) + ) + ); + + $command = json_decode( $message, true ); + var_dump( 'decoded event', $command ); + if ( ! $command || ! isset( $command['type'] ) ) { + continue; + } + + switch ( $command['type'] ) { + case 'new_connection': + $this->handle_new_connection( $command['clientId'] ); + break; + + case 'data_received': + $this->handle_data_received( $command['clientId'], $command['data'] ); + break; + + case 'client_disconnected': + $this->handle_client_disconnected( $command['clientId'] ); + break; + } + } + } + + private function handle_new_connection( $client_id ) { + echo "New client connected (ID: $client_id).\n"; + $this->clients[] = $client_id; + $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); + + // Send initial handshake + $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); + $this->send_response( $client_id, $handshake ); + } + + private function handle_data_received( $client_id, $encoded_data ) { + if ( ! isset( $this->client_servers[ $client_id ] ) ) { + throw new IncompleteInputException( 'No client server found' ); + } + + $data = base64_decode( $encoded_data ); + + try { + // Process the data + $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); + if ( $response ) { + $this->send_response( $client_id, $response ); + } else { + throw new IncompleteInputException( 'No response from client' ); + } + + // Process any buffered data + while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { + try { + $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); + if ( $response ) { + $this->send_response( $client_id, $response ); + } + } catch ( IncompleteInputException $e ) { throw $e; - break; - } - } - } catch (IncompleteInputException $e) { - // Not enough data yet, wait for mo + } + } + } catch ( IncompleteInputException $e ) { + // Not enough data yet, wait for mo throw $e; - } - } - - private function handleClientDisconnected($clientId) { - echo "Client disconnected (ID: $clientId).\n"; - if (isset($this->clientServers[$clientId])) { - $this->clientServers[$clientId]->reset(); - unset($this->clientServers[$clientId]); - } - - $index = array_search($clientId, $this->clients); - if ($index !== false) { - unset($this->clients[$index]); - } - } - - private function sendResponse($clientId, $data) { - var_dump('sending response'); - $response = json_encode([ - 'type' => 'response_from_php', - 'clientId' => $clientId, - 'data' => base64_encode($data) - ]); - post_message_to_js($response); - } + } + } + + private function handle_client_disconnected( $client_id ) { + echo "Client disconnected (ID: $client_id).\n"; + if ( isset( $this->client_servers[ $client_id ] ) ) { + $this->client_servers[ $client_id ]->reset(); + unset( $this->client_servers[ $client_id ] ); + } + + $index = array_search( $client_id, $this->clients, true ); + if ( false !== $index ) { + unset( $this->clients[ $index ] ); + } + } + + private function send_response( $client_id, $data ) { + var_dump( 'sending response' ); + $response = json_encode( + array( + 'type' => 'response_from_php', + 'clientId' => $client_id, + 'data' => base64_encode( $data ), + ) + ); + post_message_to_js( $response ); + } } diff --git a/packages/wp-mysql-proxy/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php index 3680c699..1aa5acf3 100644 --- a/packages/wp-mysql-proxy/src/run-sqlite-translation.php +++ b/packages/wp-mysql-proxy/src/run-sqlite-translation.php @@ -9,10 +9,10 @@ require_once __DIR__ . '/mysql-server.php'; require_once __DIR__ . '/handler-sqlite-translation.php'; -define('WP_SQLITE_AST_DRIVER', true); +define( 'WP_SQLITE_AST_DRIVER', true ); $server = new MySQLSocketServer( - new SQLiteTranslationHandler(__DIR__ . '/database/test.db'), - ['port' => 3306] + new SQLiteTranslationHandler( __DIR__ . '/../database/test.db' ), + array( 'port' => 3306 ) ); $server->start(); From 2ba9e36325305727a61703c7d00638bbbfd551ac Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 16:02:27 +0200 Subject: [PATCH 03/22] Remove unused and Playground-specific code --- packages/wp-mysql-proxy/src/mysql-server.php | 193 ------------------- 1 file changed, 193 deletions(-) diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php index 3d021665..b2517033 100644 --- a/packages/wp-mysql-proxy/src/mysql-server.php +++ b/packages/wp-mysql-proxy/src/mysql-server.php @@ -658,82 +658,6 @@ public function get_buffer_size(): int { } } -class SingleUseMySQLSocketServer { - private $server; - private $socket; - private $port; - - public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { - $this->server = new MySQLGateway( $query_handler ); - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); - socket_bind( $this->socket, '0.0.0.0', $this->port ); - socket_listen( $this->socket ); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - - // Accept a single client for simplicity - $client = socket_accept( $this->socket ); - if ( ! $client ) { - exit( "Failed to accept connection\n" ); - } - $this->handle_client( $client ); - socket_close( $client ); - socket_close( $this->socket ); - } - - private function handle_client( $client ) { - // Send initial handshake - $handshake = $this->server->get_initial_handshake(); - socket_write( $client, $handshake ); - - while ( true ) { - // Read available data (up to 4096 bytes at a time) - $data = @socket_read( $client, 4096 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - if ( false === $data || '' === $data ) { - break; // connection closed - } - - try { - // Process the data - $response = $this->server->receive_bytes( $data ); - if ( $response ) { - socket_write( $client, $response ); - } - - // If there's still data in the buffer, process it immediately - while ( $this->server->has_buffered_data() ) { - try { - // Try to process more complete packets from the buffer - $response = $this->server->receive_bytes( '' ); - if ( $response ) { - socket_write( $client, $response ); - } - } catch ( IncompleteInputException $e ) { - // Not enough data to complete another packet, wait for more - break; - } - } - } catch ( IncompleteInputException $e ) { - // Not enough data yet, continue reading - continue; - } - } - - echo "Client disconnected, terminating the server.\n"; - $this->server->reset(); - } -} - -if ( ! function_exists( 'post_message_to_js' ) ) { - function post_message_to_js( string $message ) { - echo 'The "post_message_to_js" function is only available in WordPress Playground but you are running it in a standalone PHP environment.' . PHP_EOL; - echo 'The message was: ' . $message . PHP_EOL; - } -} - class MySQLSocketServer { private $query_handler; private $socket; @@ -846,120 +770,3 @@ public function start() { } } } - - -class MySQLPlaygroundYieldServer { - private $query_handler; - private $clients = array(); - private $client_servers = array(); - private $port; - - public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - echo "MySQL PHP Server listening via message passing on port {$this->port}...\n"; - - // Main event loop - while ( true ) { - // Wait for a message from JS - $message = post_message_to_js( - json_encode( - array( - 'type' => 'ready_for_event', - ) - ) - ); - - $command = json_decode( $message, true ); - var_dump( 'decoded event', $command ); - if ( ! $command || ! isset( $command['type'] ) ) { - continue; - } - - switch ( $command['type'] ) { - case 'new_connection': - $this->handle_new_connection( $command['clientId'] ); - break; - - case 'data_received': - $this->handle_data_received( $command['clientId'], $command['data'] ); - break; - - case 'client_disconnected': - $this->handle_client_disconnected( $command['clientId'] ); - break; - } - } - } - - private function handle_new_connection( $client_id ) { - echo "New client connected (ID: $client_id).\n"; - $this->clients[] = $client_id; - $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); - - // Send initial handshake - $handshake = $this->client_servers[ $client_id ]->get_initial_handshake(); - $this->send_response( $client_id, $handshake ); - } - - private function handle_data_received( $client_id, $encoded_data ) { - if ( ! isset( $this->client_servers[ $client_id ] ) ) { - throw new IncompleteInputException( 'No client server found' ); - } - - $data = base64_decode( $encoded_data ); - - try { - // Process the data - $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); - if ( $response ) { - $this->send_response( $client_id, $response ); - } else { - throw new IncompleteInputException( 'No response from client' ); - } - - // Process any buffered data - while ( $this->client_servers[ $client_id ]->has_buffered_data() ) { - try { - $response = $this->client_servers[ $client_id ]->receive_bytes( '' ); - if ( $response ) { - $this->send_response( $client_id, $response ); - } - } catch ( IncompleteInputException $e ) { - throw $e; - } - } - } catch ( IncompleteInputException $e ) { - // Not enough data yet, wait for mo - throw $e; - } - } - - private function handle_client_disconnected( $client_id ) { - echo "Client disconnected (ID: $client_id).\n"; - if ( isset( $this->client_servers[ $client_id ] ) ) { - $this->client_servers[ $client_id ]->reset(); - unset( $this->client_servers[ $client_id ] ); - } - - $index = array_search( $client_id, $this->clients, true ); - if ( false !== $index ) { - unset( $this->clients[ $index ] ); - } - } - - private function send_response( $client_id, $data ) { - var_dump( 'sending response' ); - $response = json_encode( - array( - 'type' => 'response_from_php', - 'clientId' => $client_id, - 'data' => base64_encode( $data ), - ) - ); - post_message_to_js( $response ); - } -} From 894e472eefde9c7e82ff472a652c3bae2a3b3baf Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 31 Oct 2025 15:24:27 +0100 Subject: [PATCH 04/22] Set up MySQL proxy package, improve class naming and organization --- .gitattributes | 1 + packages/wp-mysql-proxy/composer.json | 19 + .../src/Adapter/class-adapter.php | 9 + .../src/Adapter/class-sqlite-adapter.php | 122 +++ .../src/class-mysql-protocol.php | 305 +++++++ .../wp-mysql-proxy/src/class-mysql-proxy.php | 119 +++ .../wp-mysql-proxy/src/class-mysql-result.php | 41 + .../src/class-mysql-session.php | 296 +++++++ packages/wp-mysql-proxy/src/exceptions.php | 16 + .../src/handler-sqlite-translation.php | 102 --- packages/wp-mysql-proxy/src/mysql-server.php | 772 ------------------ .../src/run-sqlite-translation.php | 17 +- 12 files changed, 935 insertions(+), 884 deletions(-) create mode 100644 packages/wp-mysql-proxy/composer.json create mode 100644 packages/wp-mysql-proxy/src/Adapter/class-adapter.php create mode 100644 packages/wp-mysql-proxy/src/Adapter/class-sqlite-adapter.php create mode 100644 packages/wp-mysql-proxy/src/class-mysql-protocol.php create mode 100644 packages/wp-mysql-proxy/src/class-mysql-proxy.php create mode 100644 packages/wp-mysql-proxy/src/class-mysql-result.php create mode 100644 packages/wp-mysql-proxy/src/class-mysql-session.php create mode 100644 packages/wp-mysql-proxy/src/exceptions.php delete mode 100644 packages/wp-mysql-proxy/src/handler-sqlite-translation.php delete mode 100644 packages/wp-mysql-proxy/src/mysql-server.php 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/packages/wp-mysql-proxy/composer.json b/packages/wp-mysql-proxy/composer.json new file mode 100644 index 00000000..65b2e914 --- /dev/null +++ b/packages/wp-mysql-proxy/composer.json @@ -0,0 +1,19 @@ +{ + "name": "wordpress/wp-mysql-proxy", + "type": "library", + "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/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..1c5e2c55 --- /dev/null +++ b/packages/wp-mysql-proxy/src/class-mysql-proxy.php @@ -0,0 +1,119 @@ +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 = spl_object_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 = spl_object_id( $client ); + $this->client_servers[ $client_id ]->reset(); + unset( $this->client_servers[ $client_id ] ); + socket_close( $client ); + unset( $this->clients[ array_search( $client, $this->clients, true ) ] ); + continue; + } + + try { + // Process the data + $client_id = spl_object_id( $client ); + echo "Receiving bytes\n"; + $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); + if ( $response ) { + echo "Writing response\n"; + echo $response; + socket_write( $client, $response ); + } + 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"; + } + } +} 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 @@ +sqlite_driver = new WP_SQLite_Driver( - new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ), - 'sqlite_database' - ); - } - - public function handle_query( string $query ): MySQLServerQueryResult { - try { - $rows = $this->sqlite_driver->query( $query ); - if ( $this->sqlite_driver->get_last_column_count() > 0 ) { - $columns = $this->computeColumnInfo(); - return new SelectQueryResult( $columns, $rows ); - } - return new OkayPacketResult( - $this->sqlite_driver->get_last_return_value() ?? 0, - $this->sqlite_driver->get_insert_id() ?? 0 - ); - } catch ( Throwable $e ) { - return new ErrorQueryResult( $e->getMessage() ); - } - } - - public function computeColumnInfo() { - $columns = array(); - - $column_meta = $this->sqlite_driver->get_last_column_meta(); - - $types = array( - 'DECIMAL' => MySQLProtocol::FIELD_TYPE_DECIMAL, - 'TINY' => MySQLProtocol::FIELD_TYPE_TINY, - 'SHORT' => MySQLProtocol::FIELD_TYPE_SHORT, - 'LONG' => MySQLProtocol::FIELD_TYPE_LONG, - 'FLOAT' => MySQLProtocol::FIELD_TYPE_FLOAT, - 'DOUBLE' => MySQLProtocol::FIELD_TYPE_DOUBLE, - 'NULL' => MySQLProtocol::FIELD_TYPE_NULL, - 'TIMESTAMP' => MySQLProtocol::FIELD_TYPE_TIMESTAMP, - 'LONGLONG' => MySQLProtocol::FIELD_TYPE_LONGLONG, - 'INT24' => MySQLProtocol::FIELD_TYPE_INT24, - 'DATE' => MySQLProtocol::FIELD_TYPE_DATE, - 'TIME' => MySQLProtocol::FIELD_TYPE_TIME, - 'DATETIME' => MySQLProtocol::FIELD_TYPE_DATETIME, - 'YEAR' => MySQLProtocol::FIELD_TYPE_YEAR, - 'NEWDATE' => MySQLProtocol::FIELD_TYPE_NEWDATE, - 'VARCHAR' => MySQLProtocol::FIELD_TYPE_VARCHAR, - 'BIT' => MySQLProtocol::FIELD_TYPE_BIT, - 'NEWDECIMAL' => MySQLProtocol::FIELD_TYPE_NEWDECIMAL, - 'ENUM' => MySQLProtocol::FIELD_TYPE_ENUM, - 'SET' => MySQLProtocol::FIELD_TYPE_SET, - 'TINY_BLOB' => MySQLProtocol::FIELD_TYPE_TINY_BLOB, - 'MEDIUM_BLOB' => MySQLProtocol::FIELD_TYPE_MEDIUM_BLOB, - 'LONG_BLOB' => MySQLProtocol::FIELD_TYPE_LONG_BLOB, - 'BLOB' => MySQLProtocol::FIELD_TYPE_BLOB, - 'VAR_STRING' => MySQLProtocol::FIELD_TYPE_VAR_STRING, - 'STRING' => MySQLProtocol::FIELD_TYPE_STRING, - 'GEOMETRY' => MySQLProtocol::FIELD_TYPE_GEOMETRY, - ); - - foreach ( $column_meta as $column ) { - $type = $types[ $column['native_type'] ] ?? null; - if ( null === $type ) { - throw new Exception( 'Unknown column type: ' . $column['native_type'] ); - } - $columns[] = array( - 'name' => $column['name'], - 'length' => $column['len'], - 'type' => $type, - 'flags' => 129, - 'decimals' => $column['precision'], - ); - } - return $columns; - } -} diff --git a/packages/wp-mysql-proxy/src/mysql-server.php b/packages/wp-mysql-proxy/src/mysql-server.php deleted file mode 100644 index b2517033..00000000 --- a/packages/wp-mysql-proxy/src/mysql-server.php +++ /dev/null @@ -1,772 +0,0 @@ - string, 'type' => int, 'length' => int, 'flags' => int, 'decimals' => int] - public $rows; // Array of rows, each an array of values (strings, numbers, or null) - - public function __construct( array $columns = array(), array $rows = array() ) { - $this->columns = $columns; - $this->rows = $rows; - } - - public function to_packets(): string { - return MySQLProtocol::build_result_set_packets( $this ); - } -} - -class OkayPacketResult implements MySQLServerQueryResult { - public $affected_rows; - public $last_insert_id; - - public function __construct( int $affected_rows, int $last_insert_id ) { - $this->affected_rows = $affected_rows; - $this->last_insert_id = $last_insert_id; - } - - public function to_packets(): string { - $ok_packet = MySQLProtocol::build_ok_packet( $this->affected_rows, $this->last_insert_id ); - return MySQLProtocol::encode_int_24( strlen( $ok_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $ok_packet; - } -} - -class ErrorQueryResult implements MySQLServerQueryResult { - public $code; - public $sql_state; - public $message; - - public function __construct( string $message = 'Syntax error or unsupported query', string $sql_state = '42000', int $code = 0x04A7 ) { - $this->code = $code; - $this->sql_state = $sql_state; - $this->message = $message; - } - - public function to_packets(): string { - $err_packet = MySQLProtocol::build_err_packet( $this->code, $this->sql_state, $this->message ); - return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . MySQLProtocol::encode_int_8( 1 ) . $err_packet; - } -} - -class MySQLProtocol { - // MySQL client/server capability flags (partial list) - const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags - const CLIENT_CONNECT_WITH_DB = 0x00000008; - const CLIENT_PROTOCOL_41 = 0x00000200; - const CLIENT_SECURE_CONNECTION = 0x00008000; - const CLIENT_MULTI_STATEMENTS = 0x00010000; - const CLIENT_MULTI_RESULTS = 0x00020000; - const CLIENT_PS_MULTI_RESULTS = 0x00040000; - const CLIENT_PLUGIN_AUTH = 0x00080000; - const CLIENT_CONNECT_ATTRS = 0x00100000; - const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; - const CLIENT_DEPRECATE_EOF = 0x01000000; - - // MySQL status flags - const SERVER_STATUS_AUTOCOMMIT = 0x0002; - - /** - * MySQL command types - * - * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html - */ - const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ - const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ - const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ - const COM_QUERY = 0x03; /** Tells the server to execute a query. */ - const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ - const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ - const COM_DROP_DB = 0x06; /** Currently refused by the server. */ - const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ - const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ - const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ - const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ - const COM_CONNECT = 0x0B; /** Currently refused by the server. */ - const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ - const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ - const COM_PING = 0x0E; /** Check if the server is alive. */ - const COM_TIME = 0x0F; /** Currently refused by the server. */ - const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ - const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ - const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ - const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ - const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ - const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ - const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ - const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ - const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ - const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ - const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ - const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ - const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ - const COM_DAEMON = 0x1D; /** Currently refused by the server. */ - const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ - const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ - const COM_CLONE = 0x20; /** Tells the server to clone a server. */ - - // Special packet markers - const OK_PACKET = 0x00; - const EOF_PACKET = 0xfe; - const ERR_PACKET = 0xff; - const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) - - // Auth specific markers for caching_sha2_password - const CACHING_SHA2_FAST_AUTH = 3; - const CACHING_SHA2_FULL_AUTH = 4; - const AUTH_PLUGIN_NAME = 'caching_sha2_password'; - - // Field types - const FIELD_TYPE_DECIMAL = 0x00; - const FIELD_TYPE_TINY = 0x01; - const FIELD_TYPE_SHORT = 0x02; - const FIELD_TYPE_LONG = 0x03; - const FIELD_TYPE_FLOAT = 0x04; - const FIELD_TYPE_DOUBLE = 0x05; - const FIELD_TYPE_NULL = 0x06; - const FIELD_TYPE_TIMESTAMP = 0x07; - const FIELD_TYPE_LONGLONG = 0x08; - const FIELD_TYPE_INT24 = 0x09; - const FIELD_TYPE_DATE = 0x0a; - const FIELD_TYPE_TIME = 0x0b; - const FIELD_TYPE_DATETIME = 0x0c; - const FIELD_TYPE_YEAR = 0x0d; - const FIELD_TYPE_NEWDATE = 0x0e; - const FIELD_TYPE_VARCHAR = 0x0f; - const FIELD_TYPE_BIT = 0x10; - const FIELD_TYPE_NEWDECIMAL = 0xf6; - const FIELD_TYPE_ENUM = 0xf7; - const FIELD_TYPE_SET = 0xf8; - const FIELD_TYPE_TINY_BLOB = 0xf9; - const FIELD_TYPE_MEDIUM_BLOB = 0xfa; - const FIELD_TYPE_LONG_BLOB = 0xfb; - const FIELD_TYPE_BLOB = 0xfc; - const FIELD_TYPE_VAR_STRING = 0xfd; - const FIELD_TYPE_STRING = 0xfe; - const FIELD_TYPE_GEOMETRY = 0xff; - - // Field flags - const NOT_NULL_FLAG = 0x1; - const PRI_KEY_FLAG = 0x2; - const UNIQUE_KEY_FLAG = 0x4; - const MULTIPLE_KEY_FLAG = 0x8; - const BLOB_FLAG = 0x10; - const UNSIGNED_FLAG = 0x20; - const ZEROFILL_FLAG = 0x40; - const BINARY_FLAG = 0x80; - const ENUM_FLAG = 0x100; - const AUTO_INCREMENT_FLAG = 0x200; - const TIMESTAMP_FLAG = 0x400; - const SET_FLAG = 0x800; - - // Character set and collation constants (using utf8mb4 general collation) - const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) - - // Max packet length constant - const MAX_PACKET_LENGTH = 0x00ffffff; - - private $current_db = ''; - - // Helper: Packets assembly and parsing - public static function encode_int_8( int $val ): string { - return chr( $val & 0xff ); - } - - public static function encode_int_16( int $val ): string { - return pack( 'v', $val & 0xffff ); - } - - public static function encode_int_24( int $val ): string { - // 3-byte little-endian integer - return substr( pack( 'V', $val & 0xffffff ), 0, 3 ); - } - - public static function encode_int_32( int $val ): string { - return pack( 'V', $val ); - } - - public static function encode_length_encoded_int( int $val ): string { - // Encodes an integer in MySQL's length-encoded format - if ( $val < 0xfb ) { - return chr( $val ); - } elseif ( $val <= 0xffff ) { - return "\xfc" . self::encode_int_16( $val ); - } elseif ( $val <= 0xffffff ) { - return "\xfd" . self::encode_int_24( $val ); - } else { - return "\xfe" . pack( 'P', $val ); // 8-byte little-endian for 64-bit - } - } - - public static function encode_length_encoded_string( string $str ): string { - return self::encode_length_encoded_int( strlen( $str ) ) . $str; - } - - // Hashing for caching_sha2_password (fast auth algorithm) - public static function sha_256_hash( string $password, string $salt ): string { - $stage1 = hash( 'sha256', $password, true ); - $stage2 = hash( 'sha256', $stage1, true ); - $scramble = hash( 'sha256', $stage2 . substr( $salt, 0, 20 ), true ); - // XOR stage1 and scramble to get token - return $stage1 ^ $scramble; - } - - // Build initial handshake packet (server greeting) - public static function build_handshake_packet( int $conn_id, string &$auth_plugin_data ): string { - $protocol_version = 0x0a; // Handshake protocol version (10) - $server_version = '5.7.30-php-mysql-server'; // Fake server version - // Generate random auth plugin data (20-byte salt) - $salt1 = random_bytes( 8 ); - $salt2 = random_bytes( 12 ); // total salt length = 8+12 = 20 bytes (with filler) - $auth_plugin_data = $salt1 . $salt2; - // Lower 2 bytes of capability flags - $cap_flags_lower = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) & 0xffff; - // Upper 2 bytes of capability flags - $cap_flags_upper = ( - self::CLIENT_PROTOCOL_41 | - self::CLIENT_SECURE_CONNECTION | - self::CLIENT_PLUGIN_AUTH | - self::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - ) >> 16; - $charset = self::CHARSET_UTF8MB4; - $status_flags = self::SERVER_STATUS_AUTOCOMMIT; - - // Assemble handshake packet payload - $payload = chr( $protocol_version ); - $payload .= $server_version . "\0"; - $payload .= self::encode_int_32( $conn_id ); - $payload .= $salt1; - $payload .= "\0"; // filler byte - $payload .= self::encode_int_16( $cap_flags_lower ); - $payload .= chr( $charset ); - $payload .= self::encode_int_16( $status_flags ); - $payload .= self::encode_int_16( $cap_flags_upper ); - $payload .= chr( strlen( $auth_plugin_data ) + 1 ); // auth plugin data length (salt + \0) - $payload .= str_repeat( "\0", 10 ); // 10-byte reserved filler - $payload .= $salt2; - $payload .= "\0"; // terminating NUL for auth-plugin-data-part-2 - $payload .= self::AUTH_PLUGIN_NAME . "\0"; - return $payload; - } - - // Build OK packet (after successful authentication or query execution) - public static function build_ok_packet( int $affected_rows = 0, int $last_insert_id = 0 ): string { - $payload = chr( self::OK_PACKET ); - $payload .= self::encode_length_encoded_int( $affected_rows ); - $payload .= self::encode_length_encoded_int( $last_insert_id ); - $payload .= self::encode_int_16( self::SERVER_STATUS_AUTOCOMMIT ); // server status - $payload .= self::encode_int_16( 0 ); // no warning count - // No human-readable message for simplicity - return $payload; - } - - // Build ERR packet (for errors) - public static function build_err_packet( int $error_code, string $sql_state, string $message ): string { - $payload = chr( self::ERR_PACKET ); - $payload .= self::encode_int_16( $error_code ); - $payload .= '#' . strtoupper( $sql_state ); - $payload .= $message; - return $payload; - } - - // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) - public static function build_result_set_packets( SelectQueryResult $result ): string { - $sequence_id = 1; // Sequence starts at 1 for resultset (after COM_QUERY) - $packet_stream = ''; - - // 1. Column count packet (length-encoded integer for number of columns) - $col_count = count( $result->columns ); - $col_count_payload = self::encode_length_encoded_int( $col_count ); - $packet_stream .= self::wrap_packet( $col_count_payload, $sequence_id++ ); - - // 2. Column definition packets for each column - foreach ( $result->columns as $col ) { - // Protocol::ColumnDefinition41 format:] - $col_payload = self::encode_length_encoded_string( $col['catalog'] ?? 'sqlite' ); - $col_payload .= self::encode_length_encoded_string( $col['schema'] ?? '' ); - - // Table alias - $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'] ?? MySQLProtocol::CHARSET_UTF8MB4 ); - $col_payload .= self::encode_int_32( $col['length'] ); - $col_payload .= self::encode_int_8( $col['type'] ); - $col_payload .= self::encode_int_16( $col['flags'] ); - $col_payload .= self::encode_int_8( $col['decimals'] ); - $col_payload .= "\x00"; // filler (1 byte, reserved) - - $packet_stream .= self::wrap_packet( $col_payload, $sequence_id++ ); - } - // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) - $eof_payload = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); - $packet_stream .= self::wrap_packet( $eof_payload, $sequence_id++ ); - - // 4. Row data packets (each row is a series of length-encoded values) - foreach ( $result->rows as $row ) { - $row_payload = ''; - // Iterate through columns in the defined order to match column definitions - foreach ( $result->columns as $col ) { - $column_name = $col['name']; - $val = $row->{$column_name} ?? null; - - if ( null === $val ) { - // NULL is represented by 0xfb (NULL_VALUE) - $row_payload .= "\xfb"; - } else { - $val_str = (string) $val; - $row_payload .= self::encode_length_encoded_string( $val_str ); - } - } - $packet_stream .= self::wrap_packet( $row_payload, $sequence_id++ ); - } - - // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) - $eof_payload_2 = chr( self::EOF_PACKET ) . self::encode_int_16( 0 ) . self::encode_int_16( 0 ); - $packet_stream .= self::wrap_packet( $eof_payload_2, $sequence_id++ ); - - return $packet_stream; - } - - // Helper to wrap a payload into a packet with length and sequence id - public static function wrap_packet( string $payload, int $sequence_id ): string { - $length = strlen( $payload ); - $header = self::encode_int_24( $length ) . self::encode_int_8( $sequence_id ); - return $header . $payload; - } -} - -class IncompleteInputException extends MySQLServerException { - public function __construct( string $message = 'Incomplete input data, more bytes needed' ) { - parent::__construct( $message ); - } -} - -class MySQLGateway { - private $query_handler; - private $connection_id; - private $auth_plugin_data; - private $sequence_id; - private $authenticated = false; - private $buffer = ''; - - public function __construct( MySQLQueryHandler $query_handler ) { - $this->query_handler = $query_handler; - $this->connection_id = random_int( 1, 1000 ); - $this->auth_plugin_data = ''; - $this->sequence_id = 0; - } - - /** - * Get the initial handshake packet to send to the client - * - * @return string Binary packet data to send to client - */ - public function get_initial_handshake(): string { - $handshake_payload = MySQLProtocol::build_handshake_packet( $this->connection_id, $this->auth_plugin_data ); - return MySQLProtocol::encode_int_24( strlen( $handshake_payload ) ) . - MySQLProtocol::encode_int_8( $this->sequence_id++ ) . - $handshake_payload; - } - - /** - * Process bytes received from the client - * - * @param string $data Binary data received from client - * @return string|null Response to send back to client, or null if no response needed - * @throws IncompleteInputException When more data is needed to complete a packet - */ - public function receive_bytes( string $data ): ?string { - // Append new data to existing buffer - $this->buffer .= $data; - - // Check if we have enough data for a header - if ( strlen( $this->buffer ) < 4 ) { - throw new IncompleteInputException( 'Incomplete packet header, need more bytes' ); - } - - // Parse packet header - $packet_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; - $received_sequence_id = ord( $this->buffer[3] ); - - // Check if we have the complete packet - $total_packet_length = 4 + $packet_length; - if ( strlen( $this->buffer ) < $total_packet_length ) { - throw new IncompleteInputException( - 'Incomplete packet payload, have ' . strlen( $this->buffer ) . - ' bytes, need ' . $total_packet_length . ' bytes' - ); - } - - // Extract the complete packet - $packet = substr( $this->buffer, 0, $total_packet_length ); - - // Remove the processed packet from the buffer - $this->buffer = substr( $this->buffer, $total_packet_length ); - - // Process the packet - $payload = substr( $packet, 4, $packet_length ); - - // If not authenticated yet, process authentication - if ( ! $this->authenticated ) { - return $this->process_authentication( $payload ); - } - - // Otherwise, process as a command - $command = ord( $payload[0] ); - if ( MySQLProtocol::COM_QUERY === $command ) { - $query = substr( $payload, 1 ); - return $this->process_query( $query ); - } elseif ( MySQLProtocol::COM_INIT_DB === $command ) { - return $this->process_query( 'USE ' . substr( $payload, 1 ) ); - } elseif ( MySQLProtocol::COM_QUIT === $command ) { - return ''; - } else { - // Unsupported command - $err_packet = MySQLProtocol::build_err_packet( 0x04D2, 'HY000', 'Unsupported command' ); - return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . - MySQLProtocol::encode_int_8( 1 ) . - $err_packet; - } - } - - /** - * Process authentication packet from client - * - * @param string $payload Authentication packet payload - * @return string Response packet to send back - */ - private function process_authentication( string $payload ): string { - $offset = 0; - $payload_length = strlen( $payload ); - - $capability_flags = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); - $offset += 4; - - $client_max_packet_size = $this->read_unsigned_int_little_endian( $payload, $offset, 4 ); - $offset += 4; - - $client_character_set = 0; - if ( $offset < $payload_length ) { - $client_character_set = ord( $payload[ $offset ] ); - } - $offset += 1; - - // Skip reserved bytes (always zero) - $offset = min( $payload_length, $offset + 23 ); - - $username = $this->read_null_terminated_string( $payload, $offset ); - - $auth_response = ''; - if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { - $auth_response_length = $this->read_length_encoded_int( $payload, $offset ); - $auth_response = substr( $payload, $offset, $auth_response_length ); - $offset = min( $payload_length, $offset + $auth_response_length ); - } elseif ( $capability_flags & MySQLProtocol::CLIENT_SECURE_CONNECTION ) { - $auth_response_length = 0; - if ( $offset < $payload_length ) { - $auth_response_length = ord( $payload[ $offset ] ); - } - $offset += 1; - $auth_response = substr( $payload, $offset, $auth_response_length ); - $offset = min( $payload_length, $offset + $auth_response_length ); - } else { - $auth_response = $this->read_null_terminated_string( $payload, $offset ); - } - - $database = ''; - if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_WITH_DB ) { - $database = $this->read_null_terminated_string( $payload, $offset ); - } - - $auth_plugin_name = ''; - if ( $capability_flags & MySQLProtocol::CLIENT_PLUGIN_AUTH ) { - $auth_plugin_name = $this->read_null_terminated_string( $payload, $offset ); - } - - if ( $capability_flags & MySQLProtocol::CLIENT_CONNECT_ATTRS ) { - $attrs_length = $this->read_length_encoded_int( $payload, $offset ); - $offset = min( $payload_length, $offset + $attrs_length ); - } - - $this->authenticated = true; - $this->sequence_id = 2; - - $response_packets = ''; - - if ( MySQLProtocol::AUTH_PLUGIN_NAME === $auth_plugin_name ) { - $fast_auth_payload = chr( MySQLProtocol::AUTH_MORE_DATA ) . chr( MySQLProtocol::CACHING_SHA2_FAST_AUTH ); - $response_packets .= MySQLProtocol::encode_int_24( strlen( $fast_auth_payload ) ); - $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); - $response_packets .= $fast_auth_payload; - } - - $ok_packet = MySQLProtocol::build_ok_packet(); - $response_packets .= MySQLProtocol::encode_int_24( strlen( $ok_packet ) ); - $response_packets .= MySQLProtocol::encode_int_8( $this->sequence_id++ ); - $response_packets .= $ok_packet; - - return $response_packets; - } - - private function read_unsigned_int_little_endian( string $payload, int $offset, int $length ): int { - $slice = substr( $payload, $offset, $length ); - if ( '' === $slice || $length <= 0 ) { - return 0; - } - - switch ( $length ) { - case 1: - return ord( $slice[0] ); - case 2: - $padded = str_pad( $slice, 2, "\x00", STR_PAD_RIGHT ); - $unpacked = unpack( 'v', $padded ); - return $unpacked[1] ?? 0; - case 3: - case 4: - default: - $padded = str_pad( $slice, 4, "\x00", STR_PAD_RIGHT ); - $unpacked = unpack( 'V', $padded ); - return $unpacked[1] ?? 0; - } - } - - private function read_null_terminated_string( string $payload, int &$offset ): string { - $null_position = strpos( $payload, "\0", $offset ); - if ( false === $null_position ) { - $result = substr( $payload, $offset ); - $offset = strlen( $payload ); - return $result; - } - - $result = substr( $payload, $offset, $null_position - $offset ); - $offset = $null_position + 1; - return $result; - } - - private function read_length_encoded_int( string $payload, int &$offset ): int { - if ( $offset >= strlen( $payload ) ) { - return 0; - } - - $first = ord( $payload[ $offset ] ); - $offset += 1; - - if ( $first < 0xfb ) { - return $first; - } - - if ( 0xfb === $first ) { - return 0; - } - - if ( 0xfc === $first ) { - $value = $this->read_unsigned_int_little_endian( $payload, $offset, 2 ); - $offset += 2; - return $value; - } - - if ( 0xfd === $first ) { - $value = $this->read_unsigned_int_little_endian( $payload, $offset, 3 ); - $offset += 3; - return $value; - } - - // 0xfe indicates an 8-byte integer - $value = 0; - $slice = substr( $payload, $offset, 8 ); - if ( '' !== $slice ) { - $slice = str_pad( $slice, 8, "\x00" ); - $value = unpack( 'P', $slice )[1]; - } - $offset += 8; - return (int) $value; - } - - /** - * Process a query from the client - * - * @param string $query SQL query to process - * @return string Response packet to send back - */ - private function process_query( string $query ): string { - $query = trim( $query ); - - try { - $result = $this->query_handler->handle_query( $query ); - return $result->to_packets(); - } catch ( MySQLServerException $e ) { - $err_packet = MySQLProtocol::build_err_packet( 0x04A7, '42000', 'Syntax error or unsupported query: ' . $e->getMessage() ); - return MySQLProtocol::encode_int_24( strlen( $err_packet ) ) . - MySQLProtocol::encode_int_8( 1 ) . - $err_packet; - } - } - - /** - * Reset the server state for a new connection - */ - public function reset(): void { - $this->connection_id = random_int( 1, 1000 ); - $this->auth_plugin_data = ''; - $this->sequence_id = 0; - $this->authenticated = false; - $this->buffer = ''; - } - - /** - * Check if there's any buffered data that hasn't been processed yet - * - * @return bool True if there's data in the buffer - */ - public function has_buffered_data(): bool { - return ! empty( $this->buffer ); - } - - /** - * Get the number of bytes currently in the buffer - * - * @return int Number of bytes in buffer - */ - public function get_buffer_size(): int { - return strlen( $this->buffer ); - } -} - -class MySQLSocketServer { - private $query_handler; - private $socket; - private $port; - private $clients = array(); - private $client_servers = array(); - - public function __construct( MySQLQueryHandler $query_handler, $options = array() ) { - $this->query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; - } - - public function start() { - $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); - socket_bind( $this->socket, '0.0.0.0', $this->port ); - socket_listen( $this->socket ); - echo "MySQL PHP Server listening on port {$this->port}...\n"; - while ( true ) { - // Prepare arrays for socket_select() - $read = array_merge( array( $this->socket ), $this->clients ); - $write = null; - $except = null; - - // Wait for activity on any socket - $select_result = socket_select( $read, $write, $except, null ); - if ( false === $select_result || $select_result <= 0 ) { - continue; - } - - // Check if there's a new connection - if ( in_array( $this->socket, $read, true ) ) { - $client = socket_accept( $this->socket ); - if ( $client ) { - echo "New client connected.\n"; - $this->clients[] = $client; - $client_id = spl_object_id( $client ); - $this->client_servers[ $client_id ] = new MySQLGateway( $this->query_handler ); - - // Send initial handshake - echo "Pre handshake\n"; - $handshake = $this->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 = spl_object_id( $client ); - $this->client_servers[ $client_id ]->reset(); - unset( $this->client_servers[ $client_id ] ); - socket_close( $client ); - unset( $this->clients[ array_search( $client, $this->clients, true ) ] ); - continue; - } - - try { - // Process the data - $client_id = spl_object_id( $client ); - echo "Receiving bytes\n"; - $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); - if ( $response ) { - echo "Writing response\n"; - echo $response; - socket_write( $client, $response ); - } - 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"; - } - } -} diff --git a/packages/wp-mysql-proxy/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php index 1aa5acf3..a0e78c50 100644 --- a/packages/wp-mysql-proxy/src/run-sqlite-translation.php +++ b/packages/wp-mysql-proxy/src/run-sqlite-translation.php @@ -1,18 +1,15 @@ -SQLite proxy that parses MySQL queries and transforms them into SQLite operations. - * - * Most queries works, and the upcoming translation driver should bring the parity much - * closer to 100%: https://github.com/WordPress/sqlite-database-integration/pull/157 - */ + 3306 ) ); -$server->start(); +$proxy->start(); From 850c81fa4caf51e12ff05c3d2268c1f81c9d8035 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 30 Oct 2025 14:31:52 +0100 Subject: [PATCH 05/22] Add MySQL proxy tests --- .github/workflows/mysql-proxy-tests.yml | 33 +++++++++ packages/wp-mysql-proxy/phpunit.xml | 8 +++ .../tests/WP_MySQL_Proxy_MySQLi_Test.php | 24 +++++++ .../tests/WP_MySQL_Proxy_PDO_Test.php | 65 +++++++++++++++++ .../tests/WP_MySQL_Proxy_Test.php | 35 +++++++++ .../tests/bootstrap/bootstrap.php | 5 ++ .../tests/bootstrap/mysql-server-process.php | 72 +++++++++++++++++++ .../tests/bootstrap/run-server.php | 17 +++++ 8 files changed, 259 insertions(+) create mode 100644 .github/workflows/mysql-proxy-tests.yml create mode 100644 packages/wp-mysql-proxy/phpunit.xml create mode 100644 packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php create mode 100644 packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php create mode 100644 packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_Test.php create mode 100644 packages/wp-mysql-proxy/tests/bootstrap/bootstrap.php create mode 100644 packages/wp-mysql-proxy/tests/bootstrap/mysql-server-process.php create mode 100644 packages/wp-mysql-proxy/tests/bootstrap/run-server.php 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/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/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..b83a2b8e --- /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', '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(); From 14e0b409d0a8a9a69ce6dab9fe3848a61487cc05 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 3 Nov 2025 11:46:10 +0100 Subject: [PATCH 06/22] Fix "spl_object_id() expects parameter 1 to be object, resource given" --- .../wp-mysql-proxy/src/class-mysql-proxy.php | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/wp-mysql-proxy/src/class-mysql-proxy.php b/packages/wp-mysql-proxy/src/class-mysql-proxy.php index 1c5e2c55..2f8ee224 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-proxy.php +++ b/packages/wp-mysql-proxy/src/class-mysql-proxy.php @@ -40,7 +40,7 @@ public function start() { if ( $client ) { echo "New client connected.\n"; $this->clients[] = $client; - $client_id = spl_object_id( $client ); + $client_id = $this->get_client_id( $client ); $this->client_servers[ $client_id ] = new MySQL_Session( $this->query_handler ); // Send initial handshake @@ -75,7 +75,7 @@ public function start() { if ( false === $data || '' === $data ) { // Client disconnected echo "Client disconnected.\n"; - $client_id = spl_object_id( $client ); + $client_id = $this->get_client_id( $client ); $this->client_servers[ $client_id ]->reset(); unset( $this->client_servers[ $client_id ] ); socket_close( $client ); @@ -85,7 +85,7 @@ public function start() { try { // Process the data - $client_id = spl_object_id( $client ); + $client_id = $this->get_client_id( $client ); echo "Receiving bytes\n"; $response = $this->client_servers[ $client_id ]->receive_bytes( $data ); if ( $response ) { @@ -116,4 +116,18 @@ public function start() { 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 ); + } + } } From 5984d9658962d1b22c4a6eca36bc7a4471678b8a Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 3 Nov 2025 15:16:25 +0100 Subject: [PATCH 07/22] Add a basic MySQL proxy CLI --- packages/wp-mysql-proxy/bin/mysql-proxy.php | 49 +++++++++++++++++++ .../src/run-sqlite-translation.php | 15 ------ 2 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 packages/wp-mysql-proxy/bin/mysql-proxy.php delete mode 100644 packages/wp-mysql-proxy/src/run-sqlite-translation.php diff --git a/packages/wp-mysql-proxy/bin/mysql-proxy.php b/packages/wp-mysql-proxy/bin/mysql-proxy.php new file mode 100644 index 00000000..11df80ba --- /dev/null +++ b/packages/wp-mysql-proxy/bin/mysql-proxy.php @@ -0,0 +1,49 @@ + [--port ] + +Options: + -h, --help Show this help message and exit. + -d, --database= The path to the SQLite database file. + -p, --port= The port to listen on. + +USAGE; + +if ( isset( $opts['h'] ) || isset( $opts['help'] ) ) { + fwrite( STDERR, $help ); + exit( 0 ); +} + +$db_path = $opts['d'] ?? $opts['database'] ?? null; +if ( null === $db_path || '' === $db_path ) { + fwrite( STDERR, "Error: --database is required. Use --help for usage.\n" ); + exit( 1 ); +} + +$port = (int) ( $opts['p'] ?? $opts['port'] ?? 3306 ); +if ( $port < 1 || $port > 65535 ) { + fwrite( STDERR, "Error: --port must be an integer between 1 and 65535.\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/src/run-sqlite-translation.php b/packages/wp-mysql-proxy/src/run-sqlite-translation.php deleted file mode 100644 index a0e78c50..00000000 --- a/packages/wp-mysql-proxy/src/run-sqlite-translation.php +++ /dev/null @@ -1,15 +0,0 @@ - 3306 ) -); -$proxy->start(); From 44046ee4efaca3010f646948eb2ee8097dbd9168 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 3 Nov 2025 16:58:44 +0100 Subject: [PATCH 08/22] Add a CLI command to run the MySQL proxy --- packages/wp-mysql-proxy/README.md | 37 +++++++++++++++++++ .../{mysql-proxy.php => wp-mysql-proxy.php} | 19 ++++------ packages/wp-mysql-proxy/composer.json | 3 ++ 3 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 packages/wp-mysql-proxy/README.md rename packages/wp-mysql-proxy/bin/{mysql-proxy.php => wp-mysql-proxy.php} (67%) 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/mysql-proxy.php b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php similarity index 67% rename from packages/wp-mysql-proxy/bin/mysql-proxy.php rename to packages/wp-mysql-proxy/bin/wp-mysql-proxy.php index 11df80ba..0f763a76 100644 --- a/packages/wp-mysql-proxy/bin/mysql-proxy.php +++ b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php @@ -5,8 +5,6 @@ require_once __DIR__ . '/../vendor/autoload.php'; - - define( 'WP_SQLITE_AST_DRIVER', true ); // Process CLI arguments: @@ -15,29 +13,28 @@ $opts = getopt( $shortopts, $longopts ); $help = << [--port ] +Usage: php mysql-proxy.php [--database ] [--port ] Options: -h, --help Show this help message and exit. - -d, --database= The path to the SQLite database file. - -p, --port= The port to listen on. + -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 ); } -$db_path = $opts['d'] ?? $opts['database'] ?? null; -if ( null === $db_path || '' === $db_path ) { - fwrite( STDERR, "Error: --database is required. Use --help for usage.\n" ); - exit( 1 ); -} +// 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.\n" ); + fwrite( STDERR, "Error: --port must be an integer between 1 and 65535. Use --help for more information.\n" ); exit( 1 ); } diff --git a/packages/wp-mysql-proxy/composer.json b/packages/wp-mysql-proxy/composer.json index 65b2e914..65079872 100644 --- a/packages/wp-mysql-proxy/composer.json +++ b/packages/wp-mysql-proxy/composer.json @@ -1,6 +1,9 @@ { "name": "wordpress/wp-mysql-proxy", "type": "library", + "bin": [ + "bin/wp-mysql-proxy.php" + ], "scripts": { "test": "phpunit" }, From 340e42bbe8981418e4bd97a04efd61a41747e792 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 12 Nov 2025 13:48:18 +0100 Subject: [PATCH 09/22] Refactor MySQL_Proxy class, handle all socket errors, add logger --- .../wp-mysql-proxy/bin/wp-mysql-proxy.php | 24 +- packages/wp-mysql-proxy/src/class-logger.php | 143 +++++++ .../wp-mysql-proxy/src/class-mysql-proxy.php | 357 +++++++++++++----- .../src/class-mysql-session.php | 27 +- packages/wp-mysql-proxy/src/exceptions.php | 4 +- 5 files changed, 430 insertions(+), 125 deletions(-) create mode 100644 packages/wp-mysql-proxy/src/class-logger.php diff --git a/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php index 0f763a76..cd569fa5 100644 --- a/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php @@ -2,23 +2,25 @@ use WP_MySQL_Proxy\MySQL_Proxy; use WP_MySQL_Proxy\Adapter\SQLite_Adapter; +use WP_MySQL_Proxy\Logger; require_once __DIR__ . '/../vendor/autoload.php'; define( 'WP_SQLITE_AST_DRIVER', true ); // Process CLI arguments: -$shortopts = 'h:d:p'; -$longopts = array( 'help', 'database:', 'port:' ); +$shortopts = 'h:d:p:l:'; +$longopts = array( 'help', 'database:', 'port:', 'log-level:' ); $opts = getopt( $shortopts, $longopts ); $help = <<] [--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 + -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 + -l, --log-level= The log level to use. One of "error", "warning", "info", "debug". Default: info USAGE; @@ -38,9 +40,19 @@ 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 ) + array( + 'port' => $port, + 'log_level' => $log_level, + ) ); $proxy->start(); 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-proxy.php b/packages/wp-mysql-proxy/src/class-mysql-proxy.php index 2f8ee224..dc2ba606 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-proxy.php +++ b/packages/wp-mysql-proxy/src/class-mysql-proxy.php @@ -1,133 +1,294 @@ query_handler = $query_handler; - $this->port = $options['port'] ?? 3306; + /** + * The adapter to use to execute queries. + * + * @var Adapter + */ + private $adapter; + + /** + * A map of connected clients. + * + * Maps client IDs to their associated socket and session instances. + * + * @var array + */ + 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 ); } - 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"; + /** + * 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 ) { - // 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; - } + 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(); + } - // 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 ); + // No activity on any socket. + if ( $activity <= 0 ) { + continue; } - // 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 ); - } + // New client connection. + if ( in_array( $this->socket, $read, true ) ) { + $this->handle_new_client(); + unset( $read[ array_search( $this->socket, $read, true ) ] ); } - 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; + + // Handle client activity. + foreach ( $read as $socket ) { + $this->handle_client_activity( $this->get_client_id( $socket ) ); } + } catch ( Throwable $e ) { + $this->logger->error( $e->getMessage() ); + } + } + } - 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; + /** + * 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(); } } - echo "After the while loop\n"; - } catch ( IncompleteInputException $e ) { - echo "Incomplete input exception\n"; - continue; } + } catch ( Incomplete_Input_Exception $e ) { + break; } - 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. + * @param resource|object $socket 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 ); + private function get_client_id( $socket ): int { + if ( is_resource( $socket ) ) { + return get_resource_id( $socket ); } else { - return spl_object_id( $client ); + 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-session.php b/packages/wp-mysql-proxy/src/class-mysql-session.php index 95dd3e91..2609e0b4 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -6,15 +6,15 @@ class MySQL_Session { private $adapter; - private $connection_id; + private $client_id; private $auth_plugin_data; private $sequence_id; private $authenticated = false; private $buffer = ''; - public function __construct( Adapter $adapter ) { + public function __construct( Adapter $adapter, int $client_id ) { $this->adapter = $adapter; - $this->connection_id = random_int( 1, 1000 ); + $this->client_id = $client_id; $this->auth_plugin_data = ''; $this->sequence_id = 0; } @@ -25,7 +25,7 @@ public function __construct( Adapter $adapter ) { * @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 ); + $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; @@ -36,7 +36,7 @@ public function get_initial_handshake(): string { * * @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 + * @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 @@ -44,7 +44,7 @@ public function receive_bytes( string $data ): ?string { // Check if we have enough data for a header if ( strlen( $this->buffer ) < 4 ) { - throw new IncompleteInputException( 'Incomplete packet header, need more bytes' ); + throw new Incomplete_Input_Exception( 'Incomplete packet header, need more bytes' ); } // Parse packet header @@ -54,7 +54,7 @@ public function receive_bytes( string $data ): ?string { // Check if we have the complete packet $total_packet_length = 4 + $packet_length; if ( strlen( $this->buffer ) < $total_packet_length ) { - throw new IncompleteInputException( + throw new Incomplete_Input_Exception( 'Incomplete packet payload, have ' . strlen( $this->buffer ) . ' bytes, need ' . $total_packet_length . ' bytes' ); @@ -257,7 +257,7 @@ private function process_query( string $query ): string { try { $result = $this->adapter->handle_query( $query ); return $result->to_packets(); - } catch ( MySQLServerException $e ) { + } 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 ) . @@ -265,17 +265,6 @@ private function process_query( string $query ): string { } } - /** - * 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 * diff --git a/packages/wp-mysql-proxy/src/exceptions.php b/packages/wp-mysql-proxy/src/exceptions.php index 767d07eb..7220bf47 100644 --- a/packages/wp-mysql-proxy/src/exceptions.php +++ b/packages/wp-mysql-proxy/src/exceptions.php @@ -6,10 +6,10 @@ use Exception; -class MySQLServerException extends Exception { +class MySQL_Proxy_Exception extends Exception { } -class IncompleteInputException extends MySQLServerException { +class Incomplete_Input_Exception extends MySQL_Proxy_Exception { public function __construct( string $message = 'Incomplete input data, more bytes needed' ) { parent::__construct( $message ); } From 620238df8330252989427c1583507d123976cb7d Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 13 Nov 2025 15:20:37 +0100 Subject: [PATCH 10/22] Refactor MySQL_Protocol class, list all constants, add docs --- .../src/class-mysql-protocol.php | 288 +++++++++++------- .../src/class-mysql-session.php | 2 +- 2 files changed, 183 insertions(+), 107 deletions(-) diff --git a/packages/wp-mysql-proxy/src/class-mysql-protocol.php b/packages/wp-mysql-proxy/src/class-mysql-protocol.php index 965be70f..c72e71e3 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-protocol.php +++ b/packages/wp-mysql-proxy/src/class-mysql-protocol.php @@ -2,124 +2,200 @@ namespace WP_MySQL_Proxy; +/** + * MySQL wire protocol constants and helper functions. + */ class MySQL_Protocol { - // MySQL client/server capability flags (partial list) - const CLIENT_LONG_FLAG = 0x00000004; // Supports longer flags - const CLIENT_CONNECT_WITH_DB = 0x00000008; - const CLIENT_PROTOCOL_41 = 0x00000200; - const CLIENT_SECURE_CONNECTION = 0x00008000; - const CLIENT_MULTI_STATEMENTS = 0x00010000; - const CLIENT_MULTI_RESULTS = 0x00020000; - const CLIENT_PS_MULTI_RESULTS = 0x00040000; - const CLIENT_PLUGIN_AUTH = 0x00080000; - const CLIENT_CONNECT_ATTRS = 0x00100000; - const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 0x00200000; - const CLIENT_DEPRECATE_EOF = 0x01000000; - - // MySQL status flags - const SERVER_STATUS_AUTOCOMMIT = 0x0002; + /** + * MySQL client capability flags. + * + * @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. + const CLIENT_FOUND_ROWS = 1 << 1; // Send found rows instead of affected rows in EOF packet. + const CLIENT_LONG_FLAG = 1 << 2; // Get all column flags. + const CLIENT_CONNECT_WITH_DB = 1 << 3; // Database can be specified in handshake reponse packet. + const CLIENT_NO_SCHEMA = 1 << 4; // [DEPRECATED] Don't allow "database.table.column". + const CLIENT_COMPRESS = 1 << 5; // Compression protocol supported. + const CLIENT_ODBC = 1 << 6; // Special handling of ODBC behavior. None since 3.22. + const CLIENT_LOCAL_FILES = 1 << 7; // Can use LOAD DATA LOCAL. + const CLIENT_IGNORE_SPACE = 1 << 8; // Ignore spaces before "(" (function names). + const CLIENT_PROTOCOL_41 = 1 << 9; // New 4.1 protocol. + const CLIENT_INTERACTIVE = 1 << 10; // This is an interactive client. + const CLIENT_SSL = 1 << 11; // Use SSL encryption for the session. + const CLIENT_IGNORE_SIGPIPE = 1 << 12; // Do not issue SIGPIPE if network failures occur. + const CLIENT_TRANSACTIONS = 1 << 13; // Client knows about transactions. + const CLIENT_RESERVED = 1 << 14; // [DEPRECATED] Old flag for the 4.1 protocol. + const CLIENT_SECURE_CONNECTION = 1 << 15; // [DEPRECATED] Old flag for 4.1 authentication. + const CLIENT_MULTI_STATEMENTS = 1 << 16; // Multi-statement support. + const CLIENT_MULTI_RESULTS = 1 << 17; // Multi-result support. + const CLIENT_PS_MULTI_RESULTS = 1 << 18; // Multi-results and OUT parameters in PS-protocol. + const CLIENT_PLUGIN_AUTH = 1 << 19; // Plugin authentication. + const CLIENT_CONNECT_ATTRS = 1 << 20; // Permits connection attributes in 4.1 protocol. + const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; // Enable auth response packet to be larger than 255 bytes. + const CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS = 1 << 22; // Support for expired password extension. + const CLIENT_SESSION_TRACK = 1 << 23; // Capable of handling server state change information. + const CLIENT_DEPRECATE_EOF = 1 << 24; // Client no longer needs EOF packet. + const CLIENT_OPTIONAL_RESULTSET_METADATA = 1 << 25; // The client can handle optional metadata information in the resultset. + const CLIENT_ZSTD_COMPRESSION_ALGORITHM = 1 << 26; // Compression protocol extended to support zstd. + const CLIENT_QUERY_ATTRIBUTES = 1 << 27; // Support optional extension for query parameters in query and execute commands. + const CLIENT_MULTI_FACTOR_AUTHENTICATION = 1 << 28; // Support multi-factor authentication. + const CLIENT_CAPABILITY_EXTENSIONS = 1 << 29; // Reserved to extend the 32bit capabilities structure to 64bits. + const CLIENT_SSL_VERIFY_SERVER_CERT = 1 << 30; // Verify server certificate. + const CLIENT_REMEMBER_OPTIONS = 1 << 31; // Remember options between reconnects. + + /** + * MySQL server status flags. + * + * @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. + const SERVER_STATUS_AUTOCOMMIT = 1 << 1; // Server in autocommit mode. + const SERVER_STATUS_UNUSED_2 = 1 << 2; // [UNUSED] + const SERVER_MORE_RESULTS_EXISTS = 1 << 3; // Multi query - next query exists. + const SERVER_QUERY_NO_GOOD_INDEX_USED = 1 << 4; // No good index was used for the query. + const SERVER_QUERY_NO_INDEX_USED = 1 << 5; // No index was used for the query. + const SERVER_STATUS_CURSOR_EXISTS = 1 << 6; // A cursor exists for a query. FETCH must be used to get data. + const SERVER_STATUS_LAST_ROW_SENT = 1 << 7; // A cursor has been exhausted. Sent in reply to FETCH command. + const SERVER_STATUS_DB_DROPPED = 1 << 8; // A database was dropped. + const SERVER_STATUS_METADATA_CHANGED = 1 << 9; // A set of columns changed after a prepared statement was reprepared. + const SERVER_QUERY_WAS_SLOW = 1 << 10; // A query was slow. + const SERVER_PS_OUT_PARAMS = 1 << 11; // Mark ResultSet containing output parameter values. + const SERVER_STATUS_IN_TRANS_READONLY = 1 << 12; // Set together with SERVER_STATUS_IN_TRANS for read-only transactions. + const SERVER_SESSION_STATE_CHANGED = 1 << 13; // One of the server state information has changed during last statement. + + /** + * 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 + */ + 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. + const COM_INIT_DB = 2; // Change the default schema of the connection. + const COM_QUERY = 3; // Tells the server to execute a query. + const COM_FIELD_LIST = 4; // [DEPRECATED] Returns the list of fields for the given table. + const COM_CREATE_DB = 5; // Currently refused by the server. + const COM_DROP_DB = 6; // Currently refused by the server. + const COM_UNUSED_2 = 7; // [UNUSED] Used to be COM_REFRESH. + const COM_UNUSED_1 = 8; // [UNUSED] Used to be COM_SHUTDOWN. + const COM_STATISTICS = 9; // Get a human readable string of some internal status vars. + const COM_UNUSED_4 = 10; // [UNUSED] Used to be COM_PROCESS_INFO. + const COM_CONNECT = 11; // Currently refused by the server. + const COM_UNUSED_5 = 12; // [UNUSED] Used to be COM_PROCESS_KILL. + const COM_DEBUG = 13; // Dump debug info to server's stdout. + const COM_PING = 14; // Check if the server is alive. + const COM_TIME = 15; // Currently refused by the server. + const COM_DELAYED_INSERT = 16; // Functionality removed. + const COM_CHANGE_USER = 17; // Change the user of the connection. + const COM_BINLOG_DUMP = 18; // Tells the server to send the binlog dump. + const COM_TABLE_DUMP = 19; // Tells the server to send the table dump. + const COM_CONNECT_OUT = 20; // Currently refused by the server. + const COM_REGISTER_SLAVE = 21; // Tells the server to register a slave. + const COM_STMT_PREPARE = 22; // Tells the server to prepare a statement. + const COM_STMT_EXECUTE = 23; // Tells the server to execute a prepared statement. + const COM_STMT_SEND_LONG_DATA = 24; // Tells the server to send long data for a prepared statement. + const COM_STMT_CLOSE = 25; // Tells the server to close a prepared statement. + const COM_STMT_RESET = 26; // Tells the server to reset a prepared statement. + const COM_SET_OPTION = 27; // Tells the server to set an option. + const COM_STMT_FETCH = 28; // Tells the server to fetch a result from a prepared statement. + const COM_DAEMON = 29; // Currently refused by the server. + const COM_BINLOG_DUMP_GTID = 30; // Tells the server to send the binlog dump in GTID mode. + const COM_RESET_CONNECTION = 31; // Tells the server to reset the connection. + const COM_CLONE = 32; // Tells the server to clone a server. /** - * MySQL command types + * MySQL field types. * - * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_command_phase.html + * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/field_types.h#L55 + * + */ + const FIELD_TYPE_DECIMAL = 0; + const FIELD_TYPE_TINY = 1; + const FIELD_TYPE_SHORT = 2; + const FIELD_TYPE_LONG = 3; + const FIELD_TYPE_FLOAT = 4; + const FIELD_TYPE_DOUBLE = 5; + const FIELD_TYPE_NULL = 6; + const FIELD_TYPE_TIMESTAMP = 7; + const FIELD_TYPE_LONGLONG = 8; + const FIELD_TYPE_INT24 = 9; + const FIELD_TYPE_DATE = 10; + const FIELD_TYPE_TIME = 11; + const FIELD_TYPE_DATETIME = 12; + const FIELD_TYPE_YEAR = 13; + const FIELD_TYPE_NEWDATE = 14; + const FIELD_TYPE_VARCHAR = 15; + const FIELD_TYPE_BIT = 16; + const FIELD_TYPE_NEWDECIMAL = 246; + const FIELD_TYPE_ENUM = 247; + const FIELD_TYPE_SET = 248; + const FIELD_TYPE_TINY_BLOB = 249; + const FIELD_TYPE_MEDIUM_BLOB = 250; + const FIELD_TYPE_LONG_BLOB = 251; + const FIELD_TYPE_BLOB = 252; + const FIELD_TYPE_VAR_STRING = 253; + const FIELD_TYPE_STRING = 254; + const FIELD_TYPE_GEOMETRY = 255; + + /** + * MySQL field flags. + * + * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/include/mysql_com.h#L154 */ - const COM_SLEEP = 0x00; /** Tells the server to sleep for the given number of seconds. */ - const COM_QUIT = 0x01; /** Tells the server that the client wants it to close the connection. */ - const COM_INIT_DB = 0x02; /** Change the default schema of the connection. */ - const COM_QUERY = 0x03; /** Tells the server to execute a query. */ - const COM_FIELD_LIST = 0x04; /** Deprecated. Returns the list of fields for the given table. */ - const COM_CREATE_DB = 0x05; /** Currently refused by the server. */ - const COM_DROP_DB = 0x06; /** Currently refused by the server. */ - const COM_UNUSED_2 = 0x07; /** Unused. Used to be COM_REFRESH. */ - const COM_UNUSED_1 = 0x08; /** Unused. Used to be COM_SHUTDOWN. */ - const COM_STATISTICS = 0x09; /** Get a human readable string of some internal status vars. */ - const COM_UNUSED_4 = 0x0A; /** Unused. Used to be COM_PROCESS_INFO. */ - const COM_CONNECT = 0x0B; /** Currently refused by the server. */ - const COM_UNUSED_5 = 0x0C; /** Unused. Used to be COM_PROCESS_KILL. */ - const COM_DEBUG = 0x0D; /** Dump debug info to server's stdout. */ - const COM_PING = 0x0E; /** Check if the server is alive. */ - const COM_TIME = 0x0F; /** Currently refused by the server. */ - const COM_DELAYED_INSERT = 0x10; /** Functionality removed. */ - const COM_CHANGE_USER = 0x11; /** Change the user of the connection. */ - const COM_BINLOG_DUMP = 0x12; /** Tells the server to send the binlog dump. */ - const COM_TABLE_DUMP = 0x13; /** Tells the server to send the table dump. */ - const COM_CONNECT_OUT = 0x14; /** Currently refused by the server. */ - const COM_REGISTER_SLAVE = 0x15; /** Tells the server to register a slave. */ - const COM_STMT_PREPARE = 0x16; /** Tells the server to prepare a statement. */ - const COM_STMT_EXECUTE = 0x17; /** Tells the server to execute a prepared statement. */ - const COM_STMT_SEND_LONG_DATA = 0x18; /** Tells the server to send long data for a prepared statement. */ - const COM_STMT_CLOSE = 0x19; /** Tells the server to close a prepared statement. */ - const COM_STMT_RESET = 0x1A; /** Tells the server to reset a prepared statement. */ - const COM_SET_OPTION = 0x1B; /** Tells the server to set an option. */ - const COM_STMT_FETCH = 0x1C; /** Tells the server to fetch a result from a prepared statement. */ - const COM_DAEMON = 0x1D; /** Currently refused by the server. */ - const COM_BINLOG_DUMP_GTID = 0x1E; /** Tells the server to send the binlog dump in GTID mode. */ - const COM_RESET_CONNECTION = 0x1F; /** Tells the server to reset the connection. */ - const COM_CLONE = 0x20; /** Tells the server to clone a server. */ - - // Special packet markers - const OK_PACKET = 0x00; - const EOF_PACKET = 0xfe; - const ERR_PACKET = 0xff; - const AUTH_MORE_DATA = 0x01; // followed by 1 byte (caching_sha2_password specific) + const FIELD_NOT_NULL_FLAG = 1 << 0; // Field can't be NULL. + const FIELD_PRI_KEY_FLAG = 1 << 1; // Field is part of a primary key. + const FIELD_UNIQUE_KEY_FLAG = 1 << 2; // Field is part of a unique key. + const FIELD_MULTIPLE_KEY_FLAG = 1 << 3; // Field is part of a key. + const FIELD_BLOB_FLAG = 1 << 4; // Field is a blob. + const FIELD_UNSIGNED_FLAG = 1 << 5; // Field is an unsigned integer. + const FIELD_ZEROFILL_FLAG = 1 << 6; // Field is a zero-filled integer. + const FIELD_BINARY_FLAG = 1 << 7; // Field is binary. + const FIELD_ENUM_FLAG = 1 << 8; // Field is an enum. + const FIELD_AUTO_INCREMENT_FLAG = 1 << 9; // Field is an auto-increment field. + const FIELD_TIMESTAMP_FLAG = 1 << 10; // Field is a timestamp. + const FIELD_SET_FLAG = 1 << 11; // Field is a set. + const FIELD_NO_DEFAULT_VALUE_FLAG = 1 << 12; // Field doesn't have default value. + const FIELD_ON_UPDATE_NOW_FLAG = 1 << 13; // Field is set to NOW on UPDATE. + const FIELD_PART_KEY_FLAG = 1 << 14; // [INTERNAL] Field is part of a key. + const FIELD_NUM_FLAG = 1 << 15; // Field is a number. + const FIELD_UNIQUE_FLAG = 1 << 16; // [INTERNAL] + const FIELD_BINCMP_FLAG = 1 << 17; // [INTERNAL] + const FIELD_GET_FIXED_FIELDS_FLAG = 1 << 18; // Used to get fields in item tree. + const FIELD_IN_PART_FUNC_FLAG = 1 << 19; // Field part of partition function. + const FIELD_IN_ADD_INDEX_FLAG = 1 << 20; // [INTERNAL] + const FIELD_IS_RENAMED_FLAG = 1 << 21; // [INTERNAL] + const FIELD_FLAGS_STORAGE_MEDIA_FLAG = 1 << 22; // Field storage media, bit 22-23. + const FIELD_FLAGS_STORAGE_MEDIA_MASK = 3 << self::FIELD_FLAGS_STORAGE_MEDIA_FLAG; + const FIELD_FLAGS_COLUMN_FORMAT_FLAG = 1 << 24; // Field column format, bit 24-25. + const FIELD_FLAGS_COLUMN_FORMAT_MASK = 3 << self::FIELD_FLAGS_COLUMN_FORMAT_FLAG; + const FIELD_IS_DROPPED_FLAG = 1 << 26; // [INTERNAL] + const FIELD_EXPLICIT_NULL_FLAG = 1 << 27; // Field is explicitly specified as NULL by user. + const FIELD_GROUP_FLAG = 1 << 28; // [INTERNAL] + const FIELD_NOT_SECONDARY_FLAG = 1 << 29; // Field will not be loaded in secondary engine. + const FIELD_IS_INVISIBLE_FLAG = 1 << 30; // Field is explicitly marked as invisible by user. + + /** + * Special packet headers. + * + * @see https://github.com/mysql/mysql-server/blob/056a391cdc1af9b17b5415aee243483d1bac532d/extra/boost/boost_1_87_0/boost/mysql/impl/internal/protocol/deserialization.hpp#L257 + */ + const OK_PACKET_HEADER = 0x00; + const EOF_PACKET_HEADER = 0xfe; + const ERR_PACKET_HEADER = 0xff; // 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'; - // Field types - const FIELD_TYPE_DECIMAL = 0x00; - const FIELD_TYPE_TINY = 0x01; - const FIELD_TYPE_SHORT = 0x02; - const FIELD_TYPE_LONG = 0x03; - const FIELD_TYPE_FLOAT = 0x04; - const FIELD_TYPE_DOUBLE = 0x05; - const FIELD_TYPE_NULL = 0x06; - const FIELD_TYPE_TIMESTAMP = 0x07; - const FIELD_TYPE_LONGLONG = 0x08; - const FIELD_TYPE_INT24 = 0x09; - const FIELD_TYPE_DATE = 0x0a; - const FIELD_TYPE_TIME = 0x0b; - const FIELD_TYPE_DATETIME = 0x0c; - const FIELD_TYPE_YEAR = 0x0d; - const FIELD_TYPE_NEWDATE = 0x0e; - const FIELD_TYPE_VARCHAR = 0x0f; - const FIELD_TYPE_BIT = 0x10; - const FIELD_TYPE_NEWDECIMAL = 0xf6; - const FIELD_TYPE_ENUM = 0xf7; - const FIELD_TYPE_SET = 0xf8; - const FIELD_TYPE_TINY_BLOB = 0xf9; - const FIELD_TYPE_MEDIUM_BLOB = 0xfa; - const FIELD_TYPE_LONG_BLOB = 0xfb; - const FIELD_TYPE_BLOB = 0xfc; - const FIELD_TYPE_VAR_STRING = 0xfd; - const FIELD_TYPE_STRING = 0xfe; - const FIELD_TYPE_GEOMETRY = 0xff; - - // Field flags - const NOT_NULL_FLAG = 0x1; - const PRI_KEY_FLAG = 0x2; - const UNIQUE_KEY_FLAG = 0x4; - const MULTIPLE_KEY_FLAG = 0x8; - const BLOB_FLAG = 0x10; - const UNSIGNED_FLAG = 0x20; - const ZEROFILL_FLAG = 0x40; - const BINARY_FLAG = 0x80; - const ENUM_FLAG = 0x100; - const AUTO_INCREMENT_FLAG = 0x200; - const TIMESTAMP_FLAG = 0x400; - const SET_FLAG = 0x800; - // Character set and collation constants (using utf8mb4 general collation) const CHARSET_UTF8MB4 = 0xff; // Collation ID 255 (utf8mb4_0900_ai_ci) // Max packet length constant const MAX_PACKET_LENGTH = 0x00ffffff; - private $current_db = ''; - // Helper: Packets assembly and parsing public static function encode_int_8( int $val ): string { return chr( $val & 0xff ); @@ -167,7 +243,7 @@ public static function sha_256_hash( string $password, string $salt ): string { // Build initial handshake packet (server greeting) public static function build_handshake_packet( int $conn_id, string &$auth_plugin_data ): string { $protocol_version = 0x0a; // Handshake protocol version (10) - $server_version = '5.7.30-php-mysql-server'; // Fake server version + $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) @@ -209,7 +285,7 @@ public static function build_handshake_packet( int $conn_id, string &$auth_plugi // 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 = 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 @@ -220,7 +296,7 @@ public static function build_ok_packet( int $affected_rows = 0, int $last_insert // 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 = chr( self::ERR_PACKET_HEADER ); $payload .= self::encode_int_16( $error_code ); $payload .= '#' . strtoupper( $sql_state ); $payload .= $message; @@ -267,7 +343,7 @@ public static function build_result_set_packets( array $columns, array $rows ): $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 ); + $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) @@ -290,7 +366,7 @@ public static function build_result_set_packets( array $columns, array $rows ): } // 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 ); + $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; diff --git a/packages/wp-mysql-proxy/src/class-mysql-session.php b/packages/wp-mysql-proxy/src/class-mysql-session.php index 2609e0b4..6462f629 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -157,7 +157,7 @@ private function process_authentication( string $payload ): string { $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 ); + $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; From bddd45b65b6e5e29d9abeac1d07cd0c1c3eb2825 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 12 Nov 2025 17:27:30 +0100 Subject: [PATCH 11/22] Add support for MySQL PING command --- packages/wp-mysql-proxy/src/class-mysql-session.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wp-mysql-proxy/src/class-mysql-session.php b/packages/wp-mysql-proxy/src/class-mysql-session.php index 6462f629..f459ec1e 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -83,6 +83,8 @@ public function receive_bytes( string $data ): ?string { 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' ); From c0f27bbfef488920184f892d99ebcb19626aa562 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 13 Nov 2025 15:28:40 +0100 Subject: [PATCH 12/22] Fix help and usage --- packages/wp-mysql-proxy/README.md | 11 ++++++----- packages/wp-mysql-proxy/bin/wp-mysql-proxy.php | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/wp-mysql-proxy/README.md b/packages/wp-mysql-proxy/README.md index 82e13787..b50f6a2c 100644 --- a/packages/wp-mysql-proxy/README.md +++ b/packages/wp-mysql-proxy/README.md @@ -14,12 +14,13 @@ on SQLite. ### CLI: ```bash -$ php mysql-proxy.php [--database ] [--port ] +$ php bin/wp-mysql-proxy.php [--port ] [--database ] [--log-level ] 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 + -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: @@ -31,7 +32,7 @@ require_once __DIR__ . '/vendor/autoload.php'; $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), - array( 'port' => $port ) + 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 index cd569fa5..d23984a6 100644 --- a/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/wp-mysql-proxy/bin/wp-mysql-proxy.php @@ -14,13 +14,13 @@ $opts = getopt( $shortopts, $longopts ); $help = <<] [--port ] +Usage: php bin/wp-mysql-proxy.php [--port ] [--database ] [--log-level ] 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 - -l, --log-level= The log level to use. One of "error", "warning", "info", "debug". Default: info + -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; From 9ed033151c5b608480a6353c2ed63ad5bb167bc3 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 14 Nov 2025 16:23:30 +0100 Subject: [PATCH 13/22] Refactor packet building --- .../src/class-mysql-protocol.php | 482 ++++++++++++------ .../wp-mysql-proxy/src/class-mysql-result.php | 14 - .../src/class-mysql-session.php | 147 +++++- 3 files changed, 441 insertions(+), 202 deletions(-) diff --git a/packages/wp-mysql-proxy/src/class-mysql-protocol.php b/packages/wp-mysql-proxy/src/class-mysql-protocol.php index c72e71e3..8994496e 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,342 @@ 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 ); - } - - public static function encode_int_16( int $val ): string { - return pack( 'v', $val & 0xffff ); + /** + * 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 $affected_rows Number of rows affected by the query. + * @param int $last_insert_id The last insert ID. + * @param int $server_status The status flags representing the server state. + * @param int $warning_count The warning count. + * @return string The OK packet. + */ + public static function build_ok_packet( + int $sequence_id, + int $affected_rows, + int $last_insert_id, + int $server_status, + int $warning_count + ): 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', + self::OK_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. + // No human-readable message for simplicity + ); + return self::build_packet( $sequence_id, $payload ); } - 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 + ): 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++ ); - } - // 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; - - 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++ ); + /** + * 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 ); } - - // 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; } - // 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; + /** + * 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; } } 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..82a0e5b1 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -5,6 +5,37 @@ use WP_MySQL_Proxy\Adapter\Adapter; class MySQL_Session { + /** + * Client capabilites that are supported by the server. + */ + const CAPABILITIES = ( + MySQL_Protocol::CLIENT_PROTOCOL_41 + | MySQL_Protocol::CLIENT_SECURE_CONNECTION + | MySQL_Protocol::CLIENT_PLUGIN_AUTH + | MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA + ); + + /** + * The version of the MySQL server. + * + * @var string + */ + private $server_version = '8.0.38-php-mysql-server'; + + /** + * The character set that is used by the server. + * + * @var int + */ + private $character_set = MySQL_Protocol::CHARSET_UTF8MB4; + + /** + * The status flags representing the server state. + * + * @var int + */ + private $status_flags = MySQL_Protocol::SERVER_STATUS_AUTOCOMMIT; + private $adapter; private $client_id; private $auth_plugin_data; @@ -17,18 +48,28 @@ public function __construct( Adapter $adapter, int $client_id ) { $this->client_id = $client_id; $this->auth_plugin_data = ''; $this->sequence_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 * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html#sect_protocol_connection_phase_initial_handshake + * * @return string Binary packet data to send to client */ 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->client_id, + $this->auth_plugin_data, + self::CAPABILITIES, + $this->status_flags + ); } /** @@ -50,6 +91,7 @@ public function receive_bytes( string $data ): ?string { // Parse packet header $packet_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; $received_sequence_id = ord( $this->buffer[3] ); + $sequence_id = $received_sequence_id + 1; // Check if we have the complete packet $total_packet_length = 4 + $packet_length; @@ -84,13 +126,14 @@ public function receive_bytes( string $data ): ?string { } 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 ); + return $this->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; + return MySQL_Protocol::build_err_packet( + $received_sequence_id + 1, + 0x04D2, + 'HY000', + sprintf( 'Unsupported command: %d', $command ) + ); } } @@ -136,6 +179,7 @@ private function process_authentication( string $payload ): string { $offset = min( $payload_length, $offset + $auth_response_length ); } else { $auth_response = $this->read_null_terminated_string( $payload, $offset ); + $offset = min( $payload_length, $offset + strlen( $auth_response ) ); } $database = ''; @@ -158,21 +202,55 @@ private function process_authentication( string $payload ): string { $response_packets = ''; - if ( MySQL_Protocol::AUTH_PLUGIN_NAME === $auth_plugin_name ) { + if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $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; + $response_packets .= MySQL_Protocol::build_packet( $this->sequence_id++, $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; - + $response_packets .= $this->build_ok_packet( $this->sequence_id++ ); return $response_packets; } + // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) + public 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 + $packet_stream .= MySQL_Protocol::build_column_count_packet( $sequence_id++, count( $columns ) ); + + // 2. Column definition packets for each column + foreach ( $columns as $column ) { + $packet_stream .= MySQL_Protocol::build_column_definition_packet( $sequence_id++, $column ); + } + + // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) + $packet_stream .= MySQL_Protocol::build_eof_packet( $sequence_id++, $this->status_flags, 0 ); + + // 4. Row data packets (each row is a series of length-encoded values) + foreach ( $rows as $row ) { + $packet_stream .= MySQL_Protocol::build_row_packet( $sequence_id++, $columns, $row ); + } + + // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) + $packet_stream .= MySQL_Protocol::build_eof_packet( $sequence_id++, $this->status_flags, 0 ); + return $packet_stream; + } + + private function build_ok_packet( + int $sequence_id, + int $affected_rows = 0, + int $last_insert_id = 0 + ): string { + return MySQL_Protocol::build_ok_packet( + $sequence_id, + $affected_rows, + $last_insert_id, + $this->status_flags, + 0 + ); + } + private function read_unsigned_int_little_endian( string $payload, int $offset, int $length ): int { $slice = substr( $payload, $offset, $length ); if ( '' === $slice || $length <= 0 ) { @@ -258,12 +336,31 @@ private function process_query( string $query ): string { try { $result = $this->adapter->handle_query( $query ); - return $result->to_packets(); + if ( $result->error_info ) { + return MySQL_Protocol::build_err_packet( + 1, + $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 ); + } + + return $this->build_ok_packet( + 1, + $result->affected_rows, + $result->last_insert_id + ); } 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; + return MySQL_Protocol::build_err_packet( + 1, + 0x04A7, + '42000', + 'Syntax error or unsupported query: ' . $e->getMessage() + ); } } @@ -273,7 +370,7 @@ private function process_query( string $query ): string { * @return bool True if there's data in the buffer */ public function has_buffered_data(): bool { - return ! empty( $this->buffer ); + return strlen( $this->buffer ) > 0; } /** From f77f6ffc84af60bc62f8255b1362f1cff4cd0fad Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Wed, 19 Nov 2025 11:00:15 +0100 Subject: [PATCH 14/22] Refactor packet parsing and authentication --- .../src/class-mysql-protocol.php | 111 +++- .../src/class-mysql-session.php | 480 +++++++++--------- 2 files changed, 347 insertions(+), 244 deletions(-) diff --git a/packages/wp-mysql-proxy/src/class-mysql-protocol.php b/packages/wp-mysql-proxy/src/class-mysql-protocol.php index 8994496e..8423394a 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-protocol.php +++ b/packages/wp-mysql-proxy/src/class-mysql-protocol.php @@ -229,18 +229,20 @@ class MySQL_Protocol { * @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 $server_status The status flags representing the server state. * @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 $affected_rows, - int $last_insert_id, int $server_status, - int $warning_count + 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. @@ -255,16 +257,47 @@ public static function build_ok_packet( */ $payload = pack( 'Ca*a*vv', - self::OK_PACKET_HEADER, // (C) OK packet header. + $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. - // No human-readable message for simplicity ); 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. * @@ -316,7 +349,7 @@ public static function build_err_packet( public static function build_eof_packet( int $sequence_id, int $server_status, - int $warning_count + int $warning_count = 0 ): string { /** * Assemble the EOF packet payload. @@ -534,4 +567,68 @@ public static function encode_length_encoded_int( int $value ): string { 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-session.php b/packages/wp-mysql-proxy/src/class-mysql-session.php index 82a0e5b1..a7f1007e 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -2,70 +2,142 @@ 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 ); /** - * The version of the MySQL server. + * MySQL server version. * * @var string */ private $server_version = '8.0.38-php-mysql-server'; /** - * The character set that is used by the server. + * Character set that is used by the server. * * @var int */ private $character_set = MySQL_Protocol::CHARSET_UTF8MB4; /** - * The status flags representing the server state. + * 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 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 Binary packet data to send to client + * @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->client_id, + $this->connection_id, $this->auth_plugin_data, self::CAPABILITIES, $this->status_flags @@ -73,263 +145,169 @@ public function get_initial_handshake(): string { } /** - * 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] ); - $sequence_id = $received_sequence_id + 1; + $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 $this->build_ok_packet( $received_sequence_id + 1 ); - } else { - return MySQL_Protocol::build_err_packet( - $received_sequence_id + 1, - 0x04D2, - 'HY000', - sprintf( 'Unsupported command: %d', $command ) - ); + 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; - - $client_character_set = 0; - if ( $offset < $payload_length ) { - $client_character_set = ord( $payload[ $offset ] ); - } - $offset += 1; + // Decode the first 5 fields. + $data = unpack( + 'Vclient_flags/Vmax_packet_size/Ccharacter_set/x23filler/Z*username', + $payload + ); - // 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 ); - $offset = min( $payload_length, $offset + strlen( $auth_response ) ); + $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 ); } + // 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 = ''; - + /** + * 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 ) { - $fast_auth_payload = chr( MySQL_Protocol::AUTH_MORE_DATA_HEADER ) . chr( MySQL_Protocol::CACHING_SHA2_FAST_AUTH ); - $response_packets .= MySQL_Protocol::build_packet( $this->sequence_id++, $fast_auth_payload ); - } - - $response_packets .= $this->build_ok_packet( $this->sequence_id++ ); - return $response_packets; - } - - // Build Result Set packets from a SelectQueryResult (column count, column definitions, rows, EOF) - public 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 - $packet_stream .= MySQL_Protocol::build_column_count_packet( $sequence_id++, count( $columns ) ); - - // 2. Column definition packets for each column - foreach ( $columns as $column ) { - $packet_stream .= MySQL_Protocol::build_column_definition_packet( $sequence_id++, $column ); - } - - // 3. EOF packet to mark end of column definitions (if not using CLIENT_DEPRECATE_EOF) - $packet_stream .= MySQL_Protocol::build_eof_packet( $sequence_id++, $this->status_flags, 0 ); - - // 4. Row data packets (each row is a series of length-encoded values) - foreach ( $rows as $row ) { - $packet_stream .= MySQL_Protocol::build_row_packet( $sequence_id++, $columns, $row ); + // 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 ); } - // 5. EOF packet to mark end of data rows (if not using CLIENT_DEPRECATE_EOF) - $packet_stream .= MySQL_Protocol::build_eof_packet( $sequence_id++, $this->status_flags, 0 ); - return $packet_stream; - } - - private function build_ok_packet( - int $sequence_id, - int $affected_rows = 0, - int $last_insert_id = 0 - ): string { - return MySQL_Protocol::build_ok_packet( - $sequence_id, - $affected_rows, - $last_insert_id, - $this->status_flags, - 0 + // Unsupported authentication plugin. + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 0x04D2, + 'HY000', + 'Unsupported authentication plugin: ' . $auth_plugin_name ); } - 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 + * 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 ); @@ -338,7 +316,7 @@ private function process_query( string $query ): string { $result = $this->adapter->handle_query( $query ); if ( $result->error_info ) { return MySQL_Protocol::build_err_packet( - 1, + $this->packet_id++, $result->error_info[1], $result->error_info[0], $result->error_info[2] @@ -346,39 +324,67 @@ private function process_query( string $query ): string { } if ( count( $result->columns ) > 0 ) { - return $this->build_result_set_packets( $result->columns, $result->rows ); + return $this->build_result_set_packets( + $result->columns, + $result->rows, + $result->affected_rows, + $result->last_insert_id + ); } - return $this->build_ok_packet( - 1, + return MySQL_Protocol::build_ok_packet( + $this->packet_id++, + $this->status_flags, $result->affected_rows, $result->last_insert_id ); - } catch ( MySQL_Proxy_Exception $e ) { + } catch ( Throwable $e ) { return MySQL_Protocol::build_err_packet( - 1, - 0x04A7, - '42000', - 'Syntax error or unsupported query: ' . $e->getMessage() + $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 strlen( $this->buffer ) > 0; - } - - /** - * 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; } } From 38a74769c19f37372f6ef6e1a7a5462ed4716aeb Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 20 Nov 2025 09:07:12 +0100 Subject: [PATCH 15/22] Support specifying database name in authentication phase --- packages/wp-mysql-proxy/src/class-mysql-session.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/wp-mysql-proxy/src/class-mysql-session.php b/packages/wp-mysql-proxy/src/class-mysql-session.php index a7f1007e..70d5a18b 100644 --- a/packages/wp-mysql-proxy/src/class-mysql-session.php +++ b/packages/wp-mysql-proxy/src/class-mysql-session.php @@ -251,6 +251,17 @@ private function process_authentication( string $payload ): string { // 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. From 6b2da526da03d7b5bfcbbbfe4739a8125ce19032 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 20 Nov 2025 09:48:15 +0100 Subject: [PATCH 16/22] Add MySQL CLI tests --- .../tests/WP_MySQL_Proxy_CLI_Test.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php 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() + ); + } +} From 41612c114de35d469272a1f077225337d488555c Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 20 Nov 2025 09:52:12 +0100 Subject: [PATCH 17/22] Fix and improve tests --- packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_MySQLi_Test.php | 2 +- packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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' ); } From 9cc0249fb025ff5e125f8bb0b68c7a5380bdd64b Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 5 Sep 2025 13:57:00 +0200 Subject: [PATCH 18/22] Add PHP 8.5 unit tests to CI --- .github/workflows/phpunit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }} From 1047bdacba4a894a87aa95571bd43dc270f5bea4 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 27 Nov 2025 18:06:15 +0100 Subject: [PATCH 19/22] Use PDO\SQLite in PHP >= 8.4 --- packages/wp-mysql-proxy/tests/WP_MySQL_Proxy_PDO_Test.php | 3 ++- tests/WP_SQLite_Driver_Metadata_Tests.php | 3 ++- tests/WP_SQLite_Driver_Query_Tests.php | 3 ++- tests/WP_SQLite_Driver_Tests.php | 6 ++++-- tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php | 3 ++- tests/WP_SQLite_Metadata_Tests.php | 3 ++- tests/WP_SQLite_PDO_User_Defined_Functions_Tests.php | 5 +++-- tests/WP_SQLite_Query_Tests.php | 3 ++- tests/WP_SQLite_Translator_Tests.php | 3 ++- wp-includes/sqlite-ast/class-wp-sqlite-connection.php | 3 ++- wp-includes/sqlite/class-wp-sqlite-translator.php | 5 +++-- wp-includes/sqlite/install-functions.php | 6 ++++-- 12 files changed, 30 insertions(+), 16 deletions(-) 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 eb0dcd81..561121ab 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 @@ -7,7 +7,8 @@ class WP_MySQL_Proxy_PDO_Test extends WP_MySQL_Proxy_Test { public function setUp(): void { parent::setUp(); - $this->pdo = new PDO( + $pdo_class = PHP_VERSION_ID >= 80400 ? PDO\SQLite::class : PDO::class; + $this->pdo = new $pdo_class( sprintf( 'mysql:host=127.0.0.1;port=%d', $this->port ), 'user', 'password' 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/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-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 ) { From 6cdf6d17f9a0882e1d4ade5397cf65f7818463f9 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 27 Nov 2025 18:07:08 +0100 Subject: [PATCH 20/22] Use `PDO\Sqlite::createFunction()` instead of deprecated `PDO::sqliteCreateFunction()` --- .../class-wp-sqlite-pdo-user-defined-functions.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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; } From ca9a49ff57d556eb9fae99d860a31d5cc3d34c85 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 27 Nov 2025 18:08:08 +0100 Subject: [PATCH 21/22] Fix "ord(): Providing an empty string is deprecated" with PHP 8.5 --- wp-includes/mysql/class-wp-mysql-lexer.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From b0ca303acb08d4ed9b1336bebf87e145213ce659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 28 Nov 2025 10:16:45 +0100 Subject: [PATCH 22/22] Update version to v2.2.15 (#290) --- load.php | 2 +- readme.txt | 2 +- version.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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/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' );