'127.0.0.1', 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb']); * $my->connect(); * $id = $my->prepare('SELECT * FROM users WHERE id = ?'); * $res = $my->execute($id, [1]); // $res['rows'] * $my->demolish($id); */ class Mysql implements DbInterface { // コマンド private const COM_QUIT = 0x01; private const COM_INIT_DB = 0x02; private const COM_QUERY = 0x03; private const COM_PING = 0x0E; private const COM_STMT_PREPARE = 0x16; private const COM_STMT_EXECUTE = 0x17; private const COM_STMT_CLOSE = 0x19; // クライアント機能フラグ private const CLIENT_LONG_PASSWORD = 0x00000001; private const CLIENT_LONG_FLAG = 0x00000004; private const CLIENT_CONNECT_WITH_DB = 0x00000008; private const CLIENT_PROTOCOL_41 = 0x00000200; private const CLIENT_SSL = 0x00000800; private const CLIENT_TRANSACTIONS = 0x00002000; private const CLIENT_SECURE_CONNECTION = 0x00008000; private const CLIENT_MULTI_RESULTS = 0x00020000; private const CLIENT_PS_MULTI_RESULTS = 0x00040000; private const CLIENT_PLUGIN_AUTH = 0x00080000; private const CLIENT_PLUGIN_AUTH_LENENC = 0x00200000; // サーバーの状況 private const SERVER_STATUS_IN_TRANS = 0x0001; private const SERVER_MORE_RESULTS_EXISTS = 0x0008; // 列のフラグ private const UNSIGNED_FLAG = 0x0020; // 列の種類 public const TYPE_DECIMAL = 0x00; public const TYPE_TINY = 0x01; public const TYPE_SHORT = 0x02; public const TYPE_LONG = 0x03; public const TYPE_FLOAT = 0x04; public const TYPE_DOUBLE = 0x05; public const TYPE_NULL = 0x06; public const TYPE_TIMESTAMP = 0x07; public const TYPE_LONGLONG = 0x08; public const TYPE_INT24 = 0x09; public const TYPE_DATE = 0x0A; public const TYPE_TIME = 0x0B; public const TYPE_DATETIME = 0x0C; public const TYPE_YEAR = 0x0D; public const TYPE_NEWDATE = 0x0E; public const TYPE_VARCHAR = 0x0F; public const TYPE_BIT = 0x10; public const TYPE_JSON = 0xF5; public const TYPE_NEWDECIMAL = 0xF6; public const TYPE_ENUM = 0xF7; public const TYPE_SET = 0xF8; public const TYPE_TINY_BLOB = 0xF9; public const TYPE_MEDIUM_BLOB = 0xFA; public const TYPE_LONG_BLOB = 0xFB; public const TYPE_BLOB = 0xFC; public const TYPE_VAR_STRING = 0xFD; public const TYPE_STRING = 0xFE; public const TYPE_GEOMETRY = 0xFF; // 文字セット => 照合順序のID(ハンドシェイクで送る) private const CHARSETS = [ 'utf8mb4' => 45, 'utf8mb3' => 33, 'utf8' => 33, 'latin1' => 8, 'ascii' => 11, 'binary' => 63, 'ujis' => 12, 'sjis' => 13, 'cp932' => 95, 'eucjpms' => 97, ]; private const PLUGINS = ['mysql_native_password', 'caching_sha2_password', 'sha256_password', 'mysql_clear_password']; private const MAX_PACKET = 0x01000000; /** @var resource|null */ private $socket = null; private bool $connected = false; private bool $secure = false; private bool $debug = false; private array $packetLog = []; private array $serverInfo = []; public string $username; private string $password; public string $dbname; public string $host; private int $port; private string $socketPath; private string $charset; private bool|array $tls; private int $timeout; private string $pubkey; private int $seq = 0; private int $status = 0; private string $plugin = ''; private array $prepared = []; /** * コンストラクタ * * 接続に必要な設定を初期化します。$infoを省略するとDBINFOを使います。 * * @param array|null $info host、port(3306)、socket(UNIXソケットのパス、指定するとhost・portの代わりに使う)、 * username、password、dbname、charset(utf8mb4)、 * tls(true=証明書を確認するTLS、又はStd\Lib\Tlsのオプションの配列:cafile、verify_peer等)、 * timeout(秒、10)、pubkey(caching_sha2_password・sha256_password用のサーバーの公開鍵のPEMファイル、任意)、debug * @throws \Exception $infoを省略し、DB_ENABLEDが無効な場合 */ public function __construct(?array $info = null) { if ($info === null && !(defined('DB_ENABLED') && DB_ENABLED)) { throw new \Exception('データベースは無効です。'); } $info ??= DBINFO; $this->host = (string)($info['host'] ?? '127.0.0.1'); $this->port = (int)($info['port'] ?? 3306); $this->socketPath = (string)($info['socket'] ?? ''); $this->username = (string)($info['username'] ?? ''); $this->password = (string)($info['password'] ?? ''); $this->dbname = (string)($info['dbname'] ?? ''); $this->charset = strtolower((string)($info['charset'] ?? 'utf8mb4')); $this->tls = $info['tls'] ?? false; $this->timeout = max(1, (int)($info['timeout'] ?? 10)); $this->pubkey = (string)($info['pubkey'] ?? ''); $this->debug = (bool)($info['debug'] ?? false); if (!isset(self::CHARSETS[$this->charset])) { throw new \InvalidArgumentException("未対応の文字セット: {$this->charset}"); } } /** * デストラクタ * * オブジェクト破棄時に接続を閉じます。 */ public function __destruct() { $this->close(); } /** * デバッギングの有無 * * 有効にすると、送受信したパケットを出力し、ログに残します。 * * @param bool $debug デバッグモードを有効にするかどうか * @return Mysql 自身を返す(メソッドチェーン用) */ public function setDebug(bool $debug): Mysql { $this->debug = $debug; return $this; } /** * パケットログの取得 * * @return array 送信および受信したパケットのログ */ public function getPacketLog(): array { return $this->packetLog; } /** * サーバーの情報(挨拶パケットから) * * @return array protocol、version、threadId、capabilities、charset、status、plugin(サーバーの既定)、authPlugin(実際に使った) */ public function getServerInfo(): array { $info = $this->serverInfo; unset($info['scramble']); $info['authPlugin'] = $this->plugin; return $info; } /** * MySQLサーバーに接続 * * サーバーに接続後、(設定された場合)TLSを開始し、認証とデータベース選択を行います。 * * @return bool 接続成功時はtrue * @throws DbException 接続または認証に失敗した場合 */ public function connect(): bool { if ($this->connected) return true; $target = $this->socketPath !== '' ? "unix://{$this->socketPath}" : "tcp://{$this->host}:{$this->port}"; $errno = 0; $errstr = ''; // 自分のコンテキスト(既定のコンテキストだと、TLSの設定が他の接続に残る) $socket = @stream_socket_client($target, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, stream_context_create()); if ($socket === false) { $this->fail("ソケットに接続に失敗: {$errstr} ({$errno})"); } stream_set_timeout($socket, $this->timeout); $this->socket = $socket; $this->secure = $this->socketPath !== ''; $this->seq = 0; try { $this->parseServerGreeting($this->readPacket()); $this->authenticate(); $this->connected = true; // CONNECT_WITH_DBに対応していないサーバー if ($this->dbname !== '' && !($this->serverInfo['capabilities'] & self::CLIENT_CONNECT_WITH_DB)) { $this->selectDatabase($this->dbname); } } catch (\Throwable $e) { $this->drop(); throw $e; } return true; } /** * 接続を閉じる * * COM_QUITコマンドを送信し、ソケットを閉じます。 * * @return void */ public function close(): void { if ($this->socket && $this->connected) { try { $this->sendCommand(self::COM_QUIT); } catch (\Throwable $e) { // 既に切れている } } $this->drop(); } /** * 接続しているかどうか * * @return bool */ public function isConnected(): bool { return $this->connected; } /** * 接続がまだ生きているか確認する(COM_PING) * * @return bool サーバーが応答したらtrue */ public function ping(): bool { if (!$this->connected) return false; try { $this->sendCommand(self::COM_PING); $res = $this->readPacket(); } catch (\Throwable $e) { return false; } if (ord($res[0] ?? "\xFF") !== 0x00) return false; $this->parseOkPacket($res); return true; } /** * 利用するデータベースを選択する * * COM_INIT_DBコマンドを使用してデータベースを選択します。 * * @param string $database データベース名 * @return bool 成功時はtrue * @throws DbException データベース選択に失敗した場合 */ public function selectDatabase(string $database): bool { $this->requireConnection(); $this->sendCommand(self::COM_INIT_DB, $database); $res = $this->readPacket(); if (ord($res[0]) === 0xFF) { $this->failPacket('データベースの選択に失敗', $res); } $this->parseOkPacket($res); $this->dbname = $database; return true; } /** * プリペアドステートメントの準備 * * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。 * * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?") * @return int 成功時はステートメントID * @throws DbException 準備に失敗した場合 */ public function prepare(string $query): int { $this->requireConnection(); $this->sendCommand(self::COM_STMT_PREPARE, $query); $res = $this->readPacket(); if (ord($res[0]) === 0xFF) { $this->failPacket('準備に失敗', $res); } // 0x00、ステートメントID(4)、列数(2)、パラメータ数(2)、予約(1)、警告数(2) $head = unpack('Vid/vcols/vparams', substr($res, 1, 8)); $statementId = $head['id']; $this->prepared[$statementId] = [ 'num_params' => $head['params'], 'num_columns' => $head['cols'], 'params' => [], 'columns' => [], ]; if ($head['params'] > 0) { for ($i = 0; $i < $head['params']; $i++) { $this->prepared[$statementId]['params'][] = $this->parseFieldPacket($this->readPacket()); } $this->readEof(); } if ($head['cols'] > 0) { for ($i = 0; $i < $head['cols']; $i++) { $this->prepared[$statementId]['columns'][] = $this->parseFieldPacket($this->readPacket()); } $this->readEof(); } return $statementId; } /** * プリペアドステートメントの実行 * * 指定されたステートメントIDとパラメータを使用してクエリを実行します。 * int=BIGINT、float=DOUBLE、bool=TINYINT、null=NULL、DateTimeInterface=「Y-m-d H:i:s.u」、 * BackedEnum=その値、その他=文字列として送ります。 * * @param int $statementId プリペアドステートメントID * @param array $params パラメータ値の配列(順番通り) * @return array 結果(DbInterface::query()と同じ形) * @throws DbException 実行に失敗した場合 */ public function execute(int $statementId, array $params = []): array { $this->requireConnection(); if (!isset($this->prepared[$statementId])) { $this->fail("不正なステートメントID: {$statementId}"); } $stmtInfo = $this->prepared[$statementId]; $params = array_values($params); if (count($params) !== $stmtInfo['num_params']) { $this->fail("パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params)); } $data = chr(self::COM_STMT_EXECUTE); $data .= pack('V', $statementId); $data .= chr(0); // 0 = カーソルなし $data .= pack('V', 1); // 繰り返し数(常に1) if ($stmtInfo['num_params'] > 0) { $nullBitmap = str_repeat("\0", intdiv($stmtInfo['num_params'] + 7, 8)); $paramTypes = ''; $paramValues = ''; foreach ($params as $k => $param) { if ($param instanceof \BackedEnum) $param = $param->value; if ($param instanceof \DateTimeInterface) $param = $param->format('Y-m-d H:i:s.u'); if ($param === null) { $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7))); $paramTypes .= pack('v', self::TYPE_NULL); } else if (is_bool($param)) { $paramTypes .= pack('v', self::TYPE_TINY); $paramValues .= chr((int)$param); } else if (is_int($param)) { $paramTypes .= pack('v', self::TYPE_LONGLONG); $paramValues .= pack('P', $param); } else if (is_float($param)) { $paramTypes .= pack('v', self::TYPE_DOUBLE); $paramValues .= pack('e', $param); } else if (is_scalar($param) || $param instanceof \Stringable) { $param = (string)$param; $paramTypes .= pack('v', self::TYPE_VAR_STRING); $paramValues .= $this->encodeLengthEncodedInteger(strlen($param)).$param; } else { $this->fail("パラメータ{$k}の型は未対応です: ".get_debug_type($param)); } } $data .= $nullBitmap.chr(1).$paramTypes.$paramValues; // 1 = 新しいパラメータの種類 } $this->seq = 0; $this->sendPacket($data); return $this->readResult($this->readPacket(), true, '実行に失敗'); } /** * プリペアドステートメントの解放 * * 指定されたステートメントIDを解放し、リソースをクリーンアップします。 * * @param int $statementId プリペアドステートメントID * @return bool 成功時はtrue */ public function demolish(int $statementId): bool { if (!isset($this->prepared[$statementId])) return false; unset($this->prepared[$statementId]); if (!$this->connected) return false; $this->sendCommand(self::COM_STMT_CLOSE, pack('V', $statementId)); // 返事はない return true; } /** * SQLクエリの実行 * * COM_QUERYを使用してSQLクエリを実行し、結果を返します。 * * @param string $query 実行するSQLクエリ * @return array 結果(DbInterface::query()を参照) * @throws DbException クエリ実行に失敗した場合 */ public function query(string $query): array { $this->requireConnection(); $this->sendCommand(self::COM_QUERY, $query); return $this->readResult($this->readPacket(), false, 'クエリに失敗'); } /** * トランザクションを開始する * * @return bool * @throws DbException */ public function beginTransaction(): bool { $this->query('START TRANSACTION'); return true; } /** * トランザクションをコミットする * * @return bool * @throws DbException */ public function commit(): bool { $this->query('COMMIT'); return true; } /** * トランザクションをロールバックする * * @return bool * @throws DbException */ public function rollback(): bool { $this->query('ROLLBACK'); return true; } /** * トランザクション中かどうか(サーバーの状況フラグから) * * @return bool */ public function inTransaction(): bool { return $this->connected && ($this->status & self::SERVER_STATUS_IN_TRANS) !== 0; } /** * 識別子(テーブル名・列名)を囲む * * @param string $name 識別子 * @return string `name` */ public function quoteIdentifier(string $name): string { return '`'.str_replace('`', '``', $name).'`'; } /** * パケットログをファイルに保存する * * デバッグ用に収集したパケットログを指定されたファイルに保存します。 * * @param string $filename 保存先ファイル名(ROOT/log/の中) * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse */ public function savePacketLogToFile(string $filename): bool|int { $output = ''; foreach ($this->packetLog as $index => $packetInfo) { $direction = $packetInfo['direction']; $length = $packetInfo['length']; $seqNum = $packetInfo['seqNum']; $data = $packetInfo['data']; $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']); $output .= "=== パケット #{$index} ({$timestamp}) {$direction} "; $output .= "(長さ: {$length}, シーケンス: {$seqNum}) ===\n"; $output .= "16進数: ".$this->hexDump($data)."\n"; $output .= "ASCII: ".$this->asciiDump($data)."\n"; $output .= "==========================================\n\n"; } return file_put_contents(ROOT.'/log/'.basename($filename), $output); } // 機能性メソッド /** * MySQLサーバーで認証する * * 必要ならTLSを開始し、クライアント機能フラグと認証情報を送信してサーバー認証を行います。 * サーバーが別の認証プラグインを求めたら(Auth Switch)、それに従います。 * * @return bool 認証成功時はtrue * @throws DbException 認証に失敗した場合 */ private function authenticate(): bool { $server = $this->serverInfo['capabilities']; if (!($server & self::CLIENT_PROTOCOL_41)) { $this->fail('サーバーが古すぎます(MySQL 4.1以上が必要)'); } $caps = self::CLIENT_LONG_PASSWORD | self::CLIENT_LONG_FLAG | self::CLIENT_PROTOCOL_41 | self::CLIENT_TRANSACTIONS | self::CLIENT_SECURE_CONNECTION | self::CLIENT_MULTI_RESULTS | self::CLIENT_PS_MULTI_RESULTS | self::CLIENT_PLUGIN_AUTH | self::CLIENT_PLUGIN_AUTH_LENENC; if ($this->dbname !== '') $caps |= self::CLIENT_CONNECT_WITH_DB; $caps &= $server; $charset = chr(self::CHARSETS[$this->charset]); $head = pack('VV', 0, self::MAX_PACKET).$charset.str_repeat("\0", 23); // TLS:SSLリクエストを送ってから暗号化する if ($this->tls !== false) { if (!($server & self::CLIENT_SSL)) { $this->fail('サーバーがTLSに対応していません'); } $caps |= self::CLIENT_SSL; $this->sendPacket(substr_replace($head, pack('V', $caps), 0, 4)); $this->startTls(); } $plugin = in_array($this->serverInfo['plugin'], self::PLUGINS, true) ? $this->serverInfo['plugin'] : 'mysql_native_password'; $scramble = $this->serverInfo['scramble']; $auth = $this->authResponse($plugin, $scramble); $data = substr_replace($head, pack('V', $caps), 0, 4); $data .= $this->username."\0"; if ($caps & self::CLIENT_PLUGIN_AUTH_LENENC) { $data .= $this->encodeLengthEncodedInteger(strlen($auth)).$auth; } else { $data .= chr(strlen($auth)).$auth; } if ($caps & self::CLIENT_CONNECT_WITH_DB) $data .= $this->dbname."\0"; if ($caps & self::CLIENT_PLUGIN_AUTH) $data .= $plugin."\0"; $this->sendPacket($data); while (true) { $res = $this->readPacket(); $first = ord($res[0] ?? "\xFF"); if ($first === 0x00) { $this->parseOkPacket($res); $this->plugin = $plugin; return true; } if ($first === 0xFF) { $this->failPacket('認証に失敗', $res); } if ($first === 0xFE) { // Auth Switch:プラグイン名\0、新しいスクランブル if (strlen($res) === 1) { $this->fail('古い認証方法(4.1以前)は未対応です'); } $end = strpos($res, "\0", 1); $plugin = substr($res, 1, ($end === false ? strlen($res) : $end) - 1); $scramble = $end === false ? '' : substr($res, $end + 1); if (str_ends_with($scramble, "\0")) $scramble = substr($scramble, 0, -1); if (!in_array($plugin, self::PLUGINS, true)) { $this->fail("未対応の認証プラグイン: {$plugin}"); } $this->sendPacket($this->authResponse($plugin, $scramble)); continue; } if ($first === 0x01) { // Auth More Data(caching_sha2_password・sha256_password) $more = substr($res, 1); if (str_starts_with($more, '-----BEGIN')) { $this->sendPacket($this->rsaEncrypt($more, $scramble)); } else if ($plugin === 'caching_sha2_password' && $more === "\x03") { continue; // 高速認証に成功、OKが続く } else if ($plugin === 'caching_sha2_password' && $more === "\x04") { // 完全な認証:TLSならそのまま、それ以外は公開鍵で暗号化 if ($this->secure) { $this->sendPacket($this->password."\0"); } else if ($this->pubkey !== '') { $this->sendPacket($this->rsaEncrypt($this->readPubkey(), $scramble)); } else { $this->sendPacket("\x02"); // 公開鍵を求める } } else { $this->fail('想定外の認証データ'); } continue; } $this->fail(sprintf('想定外の認証応答: 0x%02X', $first)); } } /** * 認証プラグイン毎の認証データ * * @param string $plugin プラグイン名 * @param string $scramble サーバーから提供されたスクランブル文字列 * @return string * @throws DbException */ private function authResponse(string $plugin, string $scramble): string { switch ($plugin) { case 'mysql_native_password': return $this->password === '' ? '' : $this->scramblePassword($this->password, $scramble); case 'caching_sha2_password': return $this->password === '' ? '' : $this->scrambleSha256($this->password, $scramble); case 'sha256_password': if ($this->password === '') return "\0"; if ($this->secure) return $this->password."\0"; if ($this->pubkey !== '') return $this->rsaEncrypt($this->readPubkey(), $scramble); return "\x01"; // 公開鍵を求める case 'mysql_clear_password': if (!$this->secure) { $this->fail('mysql_clear_passwordはTLS又はUNIXソケットでしか使えません'); } return $this->password."\0"; } $this->fail("未対応の認証プラグイン: {$plugin}"); } /** * TLSを開始する * * @return void * @throws DbException */ private function startTls(): void { $opts = is_array($this->tls) ? $this->tls : []; $opts += ['peer_name' => $this->host]; try { $this->socket = Tls::enable($this->socket, $opts); } catch (TlsException $e) { $this->fail('TLSの開始に失敗: '.$e->getMessage()); } $this->secure = true; } /** * パスワードをサーバーの公開鍵で暗号化する(caching_sha2_password・sha256_password) * * @param string $pem 公開鍵(PEM) * @param string $scramble スクランブル文字列 * @return string * @throws DbException */ private function rsaEncrypt(string $pem, string $scramble): string { $pw = $this->password."\0"; $xor = ''; $len = strlen($scramble); for ($i = 0; $i < strlen($pw); $i++) { $xor .= $pw[$i] ^ $scramble[$i % $len]; } // OAEP(SHA-1、MySQLのRSA_PKCS1_OAEP_PADDINGと同じ) try { return Rsa::fromPem($pem)->encrypt($xor, 'sha1'); } catch (\InvalidArgumentException $e) { $this->fail('パスワードの暗号化に失敗: '.$e->getMessage()); } } /** * 設定された公開鍵のファイルを読む * * @return string PEM * @throws DbException */ private function readPubkey(): string { $pem = @file_get_contents($this->pubkey); if ($pem === false) { $this->fail("公開鍵を読めません: {$this->pubkey}"); } return $pem; } /** * サーバーからの結果を読む * * OKパケット、又は結果セット(列の説明、行)を解析します。 * 結果が複数ある場合(CALL等)、2つ目からは「more」に入ります。 * * @param string $firstPacket 最初のパケット * @param bool $binary バイナリプロトコル(プリペアドステートメント)の行かどうか * @param string $what 失敗した時のメッセージ * @return array fields、rows、affectedRows、insertId、serverStatus、warningCount、info(、more) * @throws DbException */ private function readResult(string $firstPacket, bool $binary, string $what): array { $first = ord($firstPacket[0] ?? "\xFF"); if ($first === 0xFF) { $this->failPacket($what, $firstPacket); } if ($first === 0xFB) { // LOCAL INFILEリクエスト(CLIENT_LOCAL_FILESを送っていないので、普通は来ない):断る $this->sendPacket(''); $this->readPacket(); $this->fail('LOCAL INFILEリクエストは未対応です'); } if ($first === 0x00) { $res = ['fields' => [], 'rows' => []] + $this->parseOkPacket($firstPacket); } else { $res = $this->parseResultSet($firstPacket, $binary); } if ($this->status & self::SERVER_MORE_RESULTS_EXISTS) { $next = $this->readResult($this->readPacket(), $binary, $what); $more = $next['more'] ?? []; unset($next['more']); $res['more'] = array_merge([$next], $more); } return $res; } /** * サーバーから結果セットを解析する * * クエリ結果のフィールドと行データを解析して返します。 * * @param string $firstPacket 最初の結果セットパケット(列数) * @param bool $binary バイナリプロトコルの行かどうか * @return array フィールドと行データの配列 * @throws DbException EOFパケットが期待通りに受信できない場合 */ private function parseResultSet(string $firstPacket, bool $binary): array { $pos = 0; $fieldCnt = (int)$this->readLengthEncodedInteger($firstPacket, $pos); $fields = []; for ($i = 0; $i < $fieldCnt; $i++) { $fields[] = $this->parseFieldPacket($this->readPacket()); } $this->readEof(); $rows = []; $warnings = 0; while (true) { $rowPacket = $this->readPacket(); $first = ord($rowPacket[0] ?? "\xFF"); if ($first === 0xFF) { $this->failPacket('行の読み込みに失敗', $rowPacket); } // 行データの終了を示すEOFパケットを確認 if ($first === 0xFE && strlen($rowPacket) < 9) { $eof = $this->parseEofPacket($rowPacket); $warnings = $eof['warningCount']; break; } $rows[] = $binary ? $this->parseBinaryRowPacket($rowPacket, $fields) : $this->parseRowPacket($rowPacket, $fields); } return [ 'fields' => $fields, 'rows' => $rows, 'affectedRows' => 0, 'insertId' => 0, 'serverStatus' => $this->status, 'warningCount' => $warnings, 'info' => '', ]; } /** * EOFパケットを読む(列の説明の後) * * @return void * @throws DbException */ private function readEof(): void { $eofPacket = $this->readPacket(); if (ord($eofPacket[0] ?? "\0") === 0xFF) { $this->failPacket('結果の読み込みに失敗', $eofPacket); } if (ord($eofPacket[0] ?? "\0") !== 0xFE) { $this->fail('フィールド説明の後にEOFパケットが期待されます'); } $this->parseEofPacket($eofPacket); } /** * フィールドパケットを解析する * * フィールドのメタデータ(Protocol::ColumnDefinition41)を解析して返します。 * * @param string $packet フィールドパケット * @return array catalog、db、table、org_table、name、org_name、charset、length、type、flags、decimals */ private function parseFieldPacket(string $packet): array { $pos = 0; $field = []; $field['catalog'] = (string)$this->readLengthEncodedString($packet, $pos); // 常に「def」 $field['db'] = (string)$this->readLengthEncodedString($packet, $pos); $field['table'] = (string)$this->readLengthEncodedString($packet, $pos); $field['org_table'] = (string)$this->readLengthEncodedString($packet, $pos); $field['name'] = (string)$this->readLengthEncodedString($packet, $pos); $field['org_name'] = (string)$this->readLengthEncodedString($packet, $pos); // 固定長の部分の長さ(常に0x0C) $this->readLengthEncodedInteger($packet, $pos); $fixed = unpack('vcharset/Vlength/Ctype/vflags/Cdecimals', str_pad(substr($packet, $pos, 10), 10, "\0")); $field['charset'] = $fixed['charset']; $field['length'] = $fixed['length']; $field['type'] = $fixed['type']; $field['flags'] = $fixed['flags']; $field['decimals'] = $fixed['decimals']; return $field; } /** * 行パケットを解析する(テキストプロトコル) * * 結果セットの行データを解析して返します。数値の列はint・floatにします。 * * @param string $packet 行パケット * @param array $fields フィールドメタデータの配列 * @return array 行データの連想配列 */ private function parseRowPacket(string $packet, array $fields): array { $pos = 0; $row = []; foreach ($fields as $field) { $value = $this->readLengthEncodedString($packet, $pos); // 0xFB = NULL $row[$field['name']] = $value === null ? null : $this->castText($value, $field); } return $row; } /** * 行パケットを解析する(バイナリプロトコル) * * 0x00、NULLビットマップ(2ビットずらし)、値の順。 * * @param string $packet 行パケット * @param array $fields フィールドメタデータの配列 * @return array 行データの連想配列 */ private function parseBinaryRowPacket(string $packet, array $fields): array { $cnt = count($fields); $bitmapLen = intdiv($cnt + 7 + 2, 8); $bitmap = substr($packet, 1, $bitmapLen); $pos = 1 + $bitmapLen; $row = []; foreach ($fields as $i => $field) { $bit = $i + 2; if (ord($bitmap[$bit >> 3]) & (1 << ($bit & 7))) { $row[$field['name']] = null; continue; } $row[$field['name']] = $this->readBinaryValue($packet, $pos, $field); } return $row; } /** * テキストプロトコルの値をPHPの型にする * * @param string $value 値 * @param array $field フィールドメタデータ * @return mixed 整数の列はint(PHP_INT_MAXを超えたら文字列のまま)、FLOAT・DOUBLEはfloat、その他は文字列 */ private function castText(string $value, array $field): mixed { switch ($field['type']) { case self::TYPE_TINY: case self::TYPE_SHORT: case self::TYPE_LONG: case self::TYPE_INT24: case self::TYPE_LONGLONG: case self::TYPE_YEAR: $int = (int)$value; return (string)$int === $value ? $int : $value; case self::TYPE_FLOAT: case self::TYPE_DOUBLE: return (float)$value; } return $value; } /** * バイナリプロトコルの値を1つ読む * * @param string $packet 行パケット * @param int $pos 位置(進める) * @param array $field フィールドメタデータ * @return mixed */ private function readBinaryValue(string $packet, int &$pos, array $field): mixed { $unsigned = ($field['flags'] & self::UNSIGNED_FLAG) !== 0; switch ($field['type']) { case self::TYPE_TINY: $v = ord($packet[$pos]); $pos += 1; return $unsigned || $v < 0x80 ? $v : $v - 0x100; case self::TYPE_SHORT: case self::TYPE_YEAR: $v = unpack('v', $packet, $pos)[1]; $pos += 2; return $unsigned || $v < 0x8000 ? $v : $v - 0x10000; case self::TYPE_LONG: case self::TYPE_INT24: $v = unpack('V', $packet, $pos)[1]; $pos += 4; return $unsigned || $v < 0x80000000 ? $v : $v - 0x100000000; case self::TYPE_LONGLONG: $v = unpack('P', $packet, $pos)[1]; $pos += 8; return $unsigned && $v < 0 ? sprintf('%u', $v) : $v; case self::TYPE_FLOAT: $v = unpack('g', $packet, $pos)[1]; $pos += 4; return (float)sprintf('%.6g', $v); // テキストプロトコルと同じ精度 case self::TYPE_DOUBLE: $v = unpack('e', $packet, $pos)[1]; $pos += 8; return $v; case self::TYPE_DATE: case self::TYPE_DATETIME: case self::TYPE_TIMESTAMP: return $this->readBinaryDateTime($packet, $pos, $field); case self::TYPE_TIME: return $this->readBinaryTime($packet, $pos, $field); case self::TYPE_NULL: return null; } // DECIMAL、文字列、BLOB、BIT、ENUM、SET、JSON、GEOMETRY等 return $this->readLengthEncodedString($packet, $pos); } /** * バイナリのDATE・DATETIME・TIMESTAMPを読む * * 長さ(0、4、7、11)、年(2)、月、日、時、分、秒、マイクロ秒(4)。 * * @return string 「Y-m-d」又は「Y-m-d H:i:s[.u]」 */ private function readBinaryDateTime(string $packet, int &$pos, array $field): string { $len = ord($packet[$pos]); $pos++; $b = substr($packet, $pos, $len)."\0\0\0\0\0\0\0\0\0\0\0"; $pos += $len; $date = sprintf('%04d-%02d-%02d', unpack('v', $b)[1], ord($b[2]), ord($b[3])); if ($field['type'] === self::TYPE_DATE) return $date; return $date.sprintf(' %02d:%02d:%02d', ord($b[4]), ord($b[5]), ord($b[6])) .$this->fraction(unpack('V', $b, 7)[1], $field['decimals']); } /** * バイナリのTIMEを読む * * 長さ(0、8、12)、負数(1)、日(4)、時、分、秒、マイクロ秒(4)。 * * @return string 「[-]H:i:s[.u]」(時は24以上になる事がある) */ private function readBinaryTime(string $packet, int &$pos, array $field): string { $len = ord($packet[$pos]); $pos++; $b = substr($packet, $pos, $len)."\0\0\0\0\0\0\0\0\0\0\0\0"; $pos += $len; $hours = unpack('V', $b, 1)[1] * 24 + ord($b[5]); return (ord($b[0]) ? '-' : '').sprintf('%02d:%02d:%02d', $hours, ord($b[6]), ord($b[7])) .$this->fraction(unpack('V', $b, 8)[1], $field['decimals']); } /** * 秒の小数部分(列の桁数に合わせる) * * @param int $usec マイクロ秒 * @param int $decimals 列の小数点以下の桁数(0~6、31=不明) * @return string */ private function fraction(int $usec, int $decimals): string { if ($decimals > 0 && $decimals <= 6) return '.'.substr(sprintf('%06d', $usec), 0, $decimals); if ($decimals > 6 && $usec > 0) return '.'.sprintf('%06d', $usec); return ''; } /** * OKパケットを解析する * * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。 * * @param string $packet OKパケット * @return array affectedRows、insertId、serverStatus、warningCount、info * @throws DbException パケットが不完全な場合 */ private function parseOkPacket(string $packet): array { if (strlen($packet) < 7) { $this->fail("OKパケットが短すぎます: ".strlen($packet)."バイト"); } $pos = 1; // ヘッダーバイト(0x00)をスキップする $affectedRows = (int)$this->readLengthEncodedInteger($packet, $pos); $insertId = (int)$this->readLengthEncodedInteger($packet, $pos); $serverStatus = unpack('v', $packet, $pos)[1]; $warningCount = unpack('v', $packet, $pos + 2)[1]; $this->status = $serverStatus; return [ 'affectedRows' => $affectedRows, 'insertId' => $insertId, 'serverStatus' => $serverStatus, 'warningCount' => $warningCount, 'info' => (string)substr($packet, $pos + 4), ]; } /** * EOFパケットを解析する * * @param string $packet EOFパケット(0xFE、警告数(2)、サーバーの状況(2)) * @return array warningCount、serverStatus */ private function parseEofPacket(string $packet): array { if (strlen($packet) < 5) return ['warningCount' => 0, 'serverStatus' => $this->status]; $eof = unpack('vwarningCount/vserverStatus', $packet, 1); $this->status = $eof['serverStatus']; return $eof; } /** * MySQLサーバーからパケットを読み込む * * 16MiB以上のパケット(複数のパケットに分かれる)は1つにまとめます。 * * @return string 受信したパケットデータ * @throws DbException 読み込みに失敗した場合 */ private function readPacket(): string { $data = ''; do { $header = $this->readBytes(4); // パケットのヘッダー=4バイト // パケットの長さを最初3バイトからパーシングする $len = unpack('V', substr($header, 0, 3)."\0")[1]; // パケットの順序番号は第4目のバイト $seqNum = ord($header[3]); $this->seq = ($seqNum + 1) & 0xFF; $chunk = $len > 0 ? $this->readBytes($len) : ''; $data .= $chunk; // デバッグ if ($this->debug) { $this->logPacket([ 'direction' => 'RECV', 'length' => $len, 'seqNum' => $seqNum, 'data' => $chunk, 'timestamp' => microtime(true), ]); } } while ($len === 0xFFFFFF); return $data; } /** * ソケットから指定したバイト数を読む * * @param int $len バイト数 * @return string * @throws DbException 接続が切れた、又はタイムアウトした場合 */ private function readBytes(int $len): string { if (!$this->socket) $this->fail('接続していません'); $buf = ''; while (strlen($buf) < $len) { $chunk = @fread($this->socket, min($len - strlen($buf), 1048576)); if ($chunk === false || $chunk === '') { $timedOut = Tls::timedOut($this->socket) || (bool)(stream_get_meta_data($this->socket)['timed_out'] ?? false); if (!$timedOut && $chunk === '' && !feof($this->socket)) continue; $this->drop(); $this->fail($timedOut ? "パケットの読み込みがタイムアウト({$this->timeout}秒)" : 'サーバーとの接続が切れました'); } $buf .= $chunk; } return $buf; } /** * MySQLサーバーにパケットを送信する * * 16MiB以上のデータは複数のパケットに分けます。 * * @param string $data 送信するデータ * @return bool 成功時はtrue * @throws DbException 送信に失敗した場合 */ private function sendPacket(string $data): bool { $total = strlen($data); $pos = 0; do { $len = min($total - $pos, 0xFFFFFF); $chunk = substr($data, $pos, $len); $pos += $len; // パケットヘッダー:長さ=3バイト、順序番号=1バイト $header = substr(pack('V', $len), 0, 3).chr($this->seq); // デバッグ if ($this->debug) { $this->logPacket([ 'direction' => 'SEND', 'length' => $len, 'seqNum' => $this->seq, 'data' => $chunk, 'timestamp' => microtime(true), ]); } $this->seq = ($this->seq + 1) & 0xFF; $this->writeBytes($header.$chunk); } while ($len === 0xFFFFFF); // ちょうど16MiB-1の倍数なら、空のパケットで終わる return true; } /** * ソケットに全部書く * * @param string $data * @return void * @throws DbException */ private function writeBytes(string $data): void { if (!$this->socket) $this->fail('接続していません'); $len = strlen($data); $sent = 0; while ($sent < $len) { $n = @fwrite($this->socket, substr($data, $sent, 1048576)); if ($n === false || $n === 0) { $this->drop(); $this->fail('パケットデータの送信に失敗'); } $sent += $n; } } /** * MySQLサーバーにコマンドを送信する * * 新しいコマンドなので、順序番号は0から。 * * @param int $command コマンド(例: 0x03 = COM_QUERY) * @param string $data 付加データ(デフォルトは空) * @return bool 成功時はtrue * @throws DbException 送信に失敗した場合 */ private function sendCommand(int $command, string $data = ''): bool { $this->seq = 0; return $this->sendPacket(chr($command).$data); } /** * 接続していなければ例外 * * @return void * @throws DbException */ private function requireConnection(): void { if (!$this->connected) $this->fail('接続していません(先にconnect()を呼んで下さい)'); } /** * ソケットを閉じ、状態を戻す(COM_QUITを送らない) * * @return void */ private function drop(): void { if ($this->socket) @fclose($this->socket); $this->socket = null; $this->connected = false; $this->secure = false; $this->status = 0; $this->prepared = []; } /** * ログに書いて例外を投げる * * @param string $msg メッセージ * @param int $code MySQLのエラーコード * @param string $sqlState SQLSTATE * @return never * @throws DbException */ private function fail(string $msg, int $code = 0, string $sqlState = ''): never { if (function_exists('logger')) logger(\LogType::Db, $msg); throw new DbException($msg, $code, $sqlState); } /** * エラーパケット(0xFF、コード(2)、「#」、SQLSTATE(5)、メッセージ)から例外を投げる * * @param string $what 何に失敗したか * @param string $packet エラーパケット * @return never * @throws DbException */ private function failPacket(string $what, string $packet): never { $code = strlen($packet) >= 3 ? unpack('v', $packet, 1)[1] : 0; $state = ''; $mes = (string)substr($packet, 3); if (str_starts_with($mes, '#')) { $state = substr($mes, 1, 5); $mes = (string)substr($mes, 6); } $this->fail("{$what}: {$code} - {$mes}", $code, $state); } /** * サーバーの挨拶パケットを解析する * * サーバーからの初期挨拶パケット(Protocol::HandshakeV10)を解析し、サーバー情報を保存します。 * * @param string $packet 挨拶パケット * @return void * @throws DbException エラー(接続の拒否等)又は未対応のプロトコルの場合 */ private function parseServerGreeting(string $packet): void { if (ord($packet[0] ?? "\0") === 0xFF) { $this->failPacket('接続を拒否されました', $packet); } $pos = 0; // プロトコールバージョン(1バイト) $this->serverInfo['protocol'] = ord($packet[$pos]); $pos++; if ($this->serverInfo['protocol'] !== 10) { $this->fail("未対応のプロトコル: {$this->serverInfo['protocol']}"); } // サーバーバージョン $end = strpos($packet, "\0", $pos); $this->serverInfo['version'] = substr($packet, $pos, $end - $pos); $pos = $end + 1; // スレッドID(4バイト) $this->serverInfo['threadId'] = unpack('V', $packet, $pos)[1]; $pos += 4; // スクランブルバッファの最初の部分(8バイト) $scramble = substr($packet, $pos, 8); $pos += 8; // フィルターバイトをスキップする $pos++; // サーバー機能の下位(2バイト) $caps = unpack('v', $packet, $pos)[1]; $pos += 2; $this->serverInfo['charset'] = 0; $this->serverInfo['status'] = 0; $this->serverInfo['plugin'] = ''; if ($pos < strlen($packet)) { // サーバー言語(1バイト) $this->serverInfo['charset'] = ord($packet[$pos]); $pos++; // サーバー状況(2バイト) $this->serverInfo['status'] = unpack('v', $packet, $pos)[1]; $pos += 2; // サーバー機能の上位(2バイト) $caps |= unpack('v', $packet, $pos)[1] << 16; $pos += 2; // スクランブルの長さ(1バイト)と予約(10バイト) $authLen = ord($packet[$pos]); $pos += 11; // スクランブルの残り(最後の\0を除く) if ($caps & self::CLIENT_SECURE_CONNECTION) { $len = max(13, $authLen - 8); $scramble .= rtrim(substr($packet, $pos, $len), "\0"); $pos += $len; } // 認証プラグイン名 if ($caps & self::CLIENT_PLUGIN_AUTH) { $end = strpos($packet, "\0", $pos); $this->serverInfo['plugin'] = substr($packet, $pos, ($end === false ? strlen($packet) : $end) - $pos); } } $this->serverInfo['capabilities'] = $caps; $this->serverInfo['scramble'] = $scramble; $this->status = $this->serverInfo['status']; } /** * パスワードをスクランブルする(mysql_native_password) * * SHA1(password) XOR SHA1(scramble + SHA1(SHA1(password))) * * @param string $password プレーンテキストのパスワード * @param string $scramble サーバーから提供されたスクランブル文字列 * @return string スクランブルされたパスワード(20バイト) */ private function scramblePassword(string $password, string $scramble): string { $stage1 = sha1($password, true); $stage2 = sha1($stage1, true); $stage3 = sha1($scramble.$stage2, true); return $stage1 ^ $stage3; } /** * パスワードをスクランブルする(caching_sha2_password) * * SHA256(password) XOR SHA256(SHA256(SHA256(password)) + scramble) * * @param string $password プレーンテキストのパスワード * @param string $scramble サーバーから提供されたスクランブル文字列 * @return string スクランブルされたパスワード(32バイト) */ private function scrambleSha256(string $password, string $scramble): string { $stage1 = hash('sha256', $password, true); $stage2 = hash('sha256', $stage1, true); $stage3 = hash('sha256', $stage2.$scramble, true); return $stage1 ^ $stage3; } /** * デバッグのためにパケットをログする * * パケット情報をログに追加し、デバッグ出力を表示します。 * * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ) * @return void */ private function logPacket(array $packetInfo): void { $this->packetLog[] = $packetInfo; $direction = $packetInfo['direction']; $length = $packetInfo['length']; $seqNum = $packetInfo['seqNum']; $data = $packetInfo['data']; echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n"; echo $this->hexDumpWithAscii(substr($data, 0, 4096))."\n"; $this->interpretPacket($data); echo "==========================================\n\n"; } /** * バイナリデータを16進数で出力する * * デバッグ用にデータを16進数形式で表示します。 * * @param string $data バイナリデータ * @return string 16進数文字列 */ private function hexDump(string $data): string { $res = ''; $len = strlen($data); for ($i = 0; $i < $len; $i++) { $res .= sprintf('%02X ', ord($data[$i])); // 読み易さの為、各16バイトで新行列を入る if (($i + 1) % 16 === 0 && $i !== $len - 1) { $res .= "\n"; } } return $res; } /** * バイナリデータをASCIIで出力する * * デバッグ用にデータをASCII形式で表示します。 * * @param string $data バイナリデータ * @return string ASCII文字列 */ private function asciiDump(string $data): string { $res = ''; $len = strlen($data); for ($i = 0; $i < $len; $i++) { $char = ord($data[$i]); // 表示出来るASCII文字だけを書き出す if ($char >= 32 && $char <= 126) { $res .= $data[$i]; } else { $res .= '.'; } // 読み易さの為、各16バイトで新行列を入る if (($i + 1) % 16 === 0 && $i !== $len - 1) { $res .= "\n"; } } return $res; } /** * バイナリデータを2進数で出力する * * デバッグ用にデータを2進数形式で表示します。 * * @param string $data バイナリデータ * @return string 2進数文字列 */ private function binaryDump(string $data): string { $res = ''; $len = strlen($data); for ($i = 0; $i < $len; $i++) { $res .= sprintf('%08b ', ord($data[$i])); // 読み易さの為、各8バイトで新行列を入る if (($i + 1) % 8 === 0 && $i !== $len - 1) { $res .= "\n"; } } return $res; } /** * パケットを最初のバイトに基づいて解釈する * * パケットの種類を特定し、デバッグ情報を出力します。 * * @param string $data パケットデータ * @return void */ private function interpretPacket(string $data): void { if ($data === '') { echo "解釈: 空パケット\n"; return; } $firstByte = ord($data[0]); switch ($firstByte) { case 0x00: echo "解釈: OKパケット\n"; $this->debugOkPacket($data); break; case 0x17: echo "解釈: COM_STMT_EXECUTEパケット\n"; $this->debugStmtExecutePacket($data); break; case 0xFF: echo "解釈: エラーパケット\n"; $this->debugErrorPacket($data); break; case 0xFE: echo "解釈: EOFパケット\n"; break; case 0xFB: echo "解釈: LOCAL INFILEリクエスト\n"; break; default: if ($firstByte === 3 && substr($data, 1, 3) === 'def') { // フィールドパケットかどうかの確認 echo "解釈: フィールド説明パケット\n"; $this->debugFieldPacket($data); } else if ($firstByte > 0 && $firstByte < 251 && strlen($data) <= 9) { // 結果セットパケットかどうかの確認 echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n"; } else { // 以上じゃないと、列データパケットでかもしん echo "解釈: 列データパケット又はその他のパケット種類\n"; $this->debugLengthEncodedStrings($data); } break; } } /** * OKパケット構造をデバッグする * * OKパケットの内容を解析し、デバッグ情報を出力します。 * * @param string $data OKパケットデータ * @return void */ private function debugOkPacket(string $data): void { if (strlen($data) < 2) { echo ' エラー: OKパケットが短すぎます ('.strlen($data)."バイト)\n"; return; } $pos = 1; // ヘッダーバイトをスキップ // 影響を受けた行数を取得 $affectedRows = $this->readLengthEncodedInteger($data, $pos); echo " 影響を受けた行数: {$affectedRows}\n"; // 最後の挿入IDを取得 $insertId = $this->readLengthEncodedInteger($data, $pos); echo " 最後の挿入ID: {$insertId}\n"; // サーバーステータス if (strlen($data) >= $pos + 2) { $serverStatus = unpack('v', $data, $pos)[1]; echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n"; $pos += 2; } else { echo " サーバーステータス: 利用不可\n"; } // 警告カウント if (strlen($data) >= $pos + 2) { $warningCount = unpack('v', $data, $pos)[1]; echo " 警告カウント: {$warningCount}\n"; $pos += 2; } else { echo " 警告カウント: 利用不可\n"; } // サーバーメッセージ(存在する場合) if (strlen($data) > $pos) { $message = substr($data, $pos); echo " メッセージ: ".$this->safeString($message)."\n"; } } /** * エラーパケット構造をデバッグする * * エラーパケットの内容を解析し、デバッグ情報を出力します。 * * @param string $data エラーパケットデータ * @return void */ private function debugErrorPacket(string $data): void { $errorCode = unpack('v', substr($data, 1, 2)."\0\0")[1]; echo " エラーコード: {$errorCode}\n"; // SQLステートマーカーをスキップ(#) $sqlState = substr($data, 4, 5); echo " SQLステート: {$sqlState}\n"; $errorMessage = substr($data, 9); echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n"; } /** * フィールドパケット構造をデバッグする * * フィールドパケットの内容を解析し、デバッグ情報を出力します。 * * @param string $data フィールドパケットデータ * @return void */ private function debugFieldPacket(string $data): void { $field = $this->parseFieldPacket($data); echo " フィールドパケット構造:\n"; echo " カタログ: ".$this->safeString($field['catalog'])."\n"; echo " データベース: ".$this->safeString($field['db'])."\n"; echo " テーブル: ".$this->safeString($field['table'])."\n"; echo " 元のテーブル: ".$this->safeString($field['org_table'])."\n"; echo " 名前: ".$this->safeString($field['name'])."\n"; echo " 元の名前: ".$this->safeString($field['org_name'])."\n"; echo " 文字セット: ".sprintf('0x%04X', $field['charset'])."\n"; echo " 列の長さ: {$field['length']}\n"; echo " 列の種類: ".sprintf('0x%02X', $field['type'])." (".$this->getMysqlTypeName($field['type']).")\n"; echo " フラグ: ".sprintf('0x%04X', $field['flags'])."\n"; echo " 小数点以下の桁数: {$field['decimals']}\n"; } /** * パケット内の長さエンコード文字列をデバッグする * * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。 * * @param string $data パケットデータ * @return void */ private function debugLengthEncodedStrings(string $data): void { $pos = 0; $length = strlen($data); $stringCount = 0; echo " 長さエンコード文字列:\n"; while ($pos < $length) { $firstByte = ord($data[$pos]); if ($firstByte === 0xFF) break; $start = $pos; $value = $this->readLengthEncodedString($data, $pos); if ($pos > $length) { echo " 位置 {$start} での無効な長さエンコーディング\n"; break; } if ($value === null) { echo " 文字列 ".(++$stringCount).": NULL\n"; } else { echo " 文字列 ".(++$stringCount).": ".$this->safeString(substr($value, 0, 256)) ." (長さ: ".strlen($value).")\n"; } } if ($stringCount === 0) { echo " 長さエンコード文字列はありません\n"; } } /** * 文字列を安全に出力用に変換する * * 表示可能な文字のみを含み、非表示文字は16進数で表現します。 * * @param string $str 変換する文字列 * @return string 安全な文字列 */ private function safeString(string $str): string { $result = ''; $length = strlen($str); for ($i = 0; $i < $length; $i++) { $char = ord($str[$i]); if ($char >= 32 && $char <= 126) { $result .= $str[$i]; } else { $result .= '\\x'.sprintf('%02X', $char); } } return $result; } /** * 長さエンコード整数を読む * * 0xFB=NULL、0xFC=2バイト、0xFD=3バイト、0xFE=8バイト、それ以外=その値。 * * @param string $data パケットデータ * @param int $pos 開始位置(読んだ分だけ進める) * @return int|null 整数値、NULLの場合はnull */ private function readLengthEncodedInteger(string $data, int &$pos): ?int { $firstByte = ord($data[$pos] ?? "\0"); $pos++; if ($firstByte < 0xFB) return $firstByte; if ($firstByte === 0xFB) return null; $size = [0xFC => 2, 0xFD => 3, 0xFE => 8][$firstByte] ?? 0; $bytes = str_pad(substr($data, $pos, $size), 8, "\0"); $pos += $size; return unpack('P', $bytes)[1]; } /** * 長さエンコード文字列を読む * * @param string $data パケットデータ * @param int $pos 開始位置(読んだ分だけ進める) * @return string|null 文字列、NULLの場合はnull */ private function readLengthEncodedString(string $data, int &$pos): ?string { $len = $this->readLengthEncodedInteger($data, $pos); if ($len === null) return null; $value = (string)substr($data, $pos, $len); $pos += $len; return $value; } /** * 整数を長さエンコード形式に変換する * * MySQLプロトコルに基づいて整数を長さエンコードします。 * * @param int $value 変換する整数 * @return string 長さエンコードされた文字列 */ private function encodeLengthEncodedInteger(int $value): string { if ($value < 251) { return chr($value); } else if ($value < 65536) { return chr(0xFC).pack('v', $value); } else if ($value < 16777216) { return chr(0xFD).substr(pack('V', $value), 0, 3); } else { return chr(0xFE).pack('P', $value); } } /** * STMTパケットをデバッグする * * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。 * * @param string $data パケットデータ * @return void */ private function debugStmtExecutePacket(string $data): void { if (strlen($data) < 10) { echo " エラー: パケットが短すぎます\n"; return; } $pos = 1; // コマンドバイトをスキップ $statementId = unpack('V', $data, $pos)[1]; echo " ステートメントID: {$statementId}\n"; $pos += 5; // ステートメントIDとカーソルフラグをスキップ $iterationCount = unpack('V', $data, $pos)[1]; echo " 繰り返し数: {$iterationCount}\n"; $pos += 4; $numParams = $this->prepared[$statementId]['num_params'] ?? 0; if ($numParams > 0) { $nullBitmapLen = intdiv($numParams + 7, 8); $nullBitmap = substr($data, $pos, $nullBitmapLen); echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n"; $pos += $nullBitmapLen; $newParamsFlag = ord($data[$pos]); echo " 新パラメータフラグ: {$newParamsFlag}\n"; $pos++; if ($newParamsFlag) { echo " パラメータ種類:\n"; for ($i = 0; $i < $numParams; $i++) { $type = ord($data[$pos]); $typeName = $this->getMysqlTypeName($type); echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n"; $pos += 2; } echo " パラメータ値: (生バイナリが続く)\n"; } } } /** * 16進値をMySQLタイプ名に変換する * * MySQLのデータ型コードを対応する型名に変換します。 * * @param int $type 16進数の型コード * @return string 型名(不明な場合は'不明') */ private function getMysqlTypeName(int $type): string { $types = [ 0x00 => 'MYSQL_TYPE_DECIMAL', 0x01 => 'MYSQL_TYPE_TINY', 0x02 => 'MYSQL_TYPE_SHORT', 0x03 => 'MYSQL_TYPE_LONG', 0x04 => 'MYSQL_TYPE_FLOAT', 0x05 => 'MYSQL_TYPE_DOUBLE', 0x06 => 'MYSQL_TYPE_NULL', 0x07 => 'MYSQL_TYPE_TIMESTAMP', 0x08 => 'MYSQL_TYPE_LONGLONG', 0x09 => 'MYSQL_TYPE_INT24', 0x0A => 'MYSQL_TYPE_DATE', 0x0B => 'MYSQL_TYPE_TIME', 0x0C => 'MYSQL_TYPE_DATETIME', 0x0D => 'MYSQL_TYPE_YEAR', 0x0E => 'MYSQL_TYPE_NEWDATE', 0x0F => 'MYSQL_TYPE_VARCHAR', 0x10 => 'MYSQL_TYPE_BIT', 0xF5 => 'MYSQL_TYPE_JSON', 0xF6 => 'MYSQL_TYPE_NEWDECIMAL', 0xF7 => 'MYSQL_TYPE_ENUM', 0xF8 => 'MYSQL_TYPE_SET', 0xF9 => 'MYSQL_TYPE_TINY_BLOB', 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB', 0xFB => 'MYSQL_TYPE_LONG_BLOB', 0xFC => 'MYSQL_TYPE_BLOB', 0xFD => 'MYSQL_TYPE_VAR_STRING', 0xFE => 'MYSQL_TYPE_STRING', 0xFF => 'MYSQL_TYPE_GEOMETRY', ]; return $types[$type] ?? '不明'; } /** * 16進数とASCII値を横に並べて出力する * * 16進エディタのように、16進数とASCII値を並べて表示します。 * * @param string $data バイナリデータ * @return string フォーマットされた文字列 */ private function hexDumpWithAscii(string $data): string { $output = ''; $len = strlen($data); $offset = 0; while ($offset < $len) { $hex = ''; $ascii = ''; $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト // 16進数部分 for ($i = 0; $i < 16; $i++) { if ($i < $bytesInLine) { $hex .= sprintf('%02X ', ord($data[$offset + $i])); } else { $hex .= ' '; // 揃えのためにスペースを埋める } } // ASCII部分 for ($i = 0; $i < $bytesInLine; $i++) { $char = ord($data[$offset + $i]); $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.'; } $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii); $offset += 16; } return $output; } }