From efc9e625bc1271dbbee4b18b1b58b20d62e28f35 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 15:17:48 +0200 Subject: [PATCH 1/8] 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 5a41eb66155358c8dcebfb8163a4092d6cabbee7 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 15:58:12 +0200 Subject: [PATCH 2/8] 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 d7470b7399789f2756127601c4384fdda7ce6cb4 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 20 Oct 2025 16:02:27 +0200 Subject: [PATCH 3/8] 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 9b0cdf74ee474b2681f2cb64bc826635282b6406 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 31 Oct 2025 15:24:27 +0100 Subject: [PATCH 4/8] 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 c8aa5bc99ee3a60129c3c1442bb8eec8318fd924 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 30 Oct 2025 14:31:52 +0100 Subject: [PATCH 5/8] 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 4c4c4b03667616d2e0eca42b7e12fed1b14dc082 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 3 Nov 2025 11:46:10 +0100 Subject: [PATCH 6/8] 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 5c837d5397397f31943448bf4cae49a9754c672f Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 3 Nov 2025 15:16:25 +0100 Subject: [PATCH 7/8] 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 2049cf0a21fd0fa6a70e0d67678ee26eded20c57 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 3 Nov 2025 16:58:44 +0100 Subject: [PATCH 8/8] 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" },